hackerbhai commited on
Commit
efa9a24
·
1 Parent(s): 9c7ae30

Advanced PDF editor with OCR: upload, OCR (19 languages), find/replace, add text (multi-language), page tools, merge/split, watermark, compress, encrypt, forms

Browse files
Files changed (5) hide show
  1. README.md +31 -5
  2. app.py +1149 -0
  3. fonts_data.py +0 -0
  4. packages.txt +20 -0
  5. requirements.txt +6 -0
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
- title: PDFEDITER
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PDF EDITER
3
+ emoji: 📝
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
+ license: mit
12
  ---
13
 
14
+ # 📝 PDF EDITER Advanced PDF Editor with OCR
15
+
16
+ Upload a PDF → OCR scanned pages → edit text → manage pages → merge/split → watermark → compress → protect → download. Built with **Gradio + PyMuPDF + Tesseract OCR**.
17
+
18
+ ## ✨ Features
19
+
20
+ | Tab | What you can do |
21
+ | --- | --- |
22
+ | 📥 Upload & Preview | Upload one or more PDFs (auto-merged), browse pages, zoom, click preview to set coordinates |
23
+ | 🔍 OCR | Tesseract OCR in **19 languages** (English, Hindi, Tamil, Telugu, Kannada, Malayalam, Bengali, Marathi, Gujarati, Punjabi, Spanish, French, German, Italian, Portuguese, Russian, Arabic, Chinese, Japanese), makes scanned PDFs **searchable**, extracts `.txt` |
24
+ | ✏️ Edit Text | Find & **Replace** across the document (with match highlighting), **add text** anywhere in 9 fonts incl. Devanagari / Tamil / Bengali / Arabic / Gurmukhi, click-to-position |
25
+ | 🖼️ Page Tools | Delete, rotate, duplicate, move up/down, **reorder**, **split**, **extract** pages with thumbnails |
26
+ | 🧩 Merge & Advanced | **Merge** PDFs, **watermark** (angle/opacity), **compress**, **encrypt (AES-256)** / unlock, **fill PDF forms** |
27
+ | ⤴️ Top bar | **Undo / Redo** (25 steps), reset session, download the current PDF |
28
+
29
+ ## 🚀 Run locally
30
+
31
+ ```bash
32
+ pip install -r requirements.txt
33
+ sudo apt-get install -y tesseract-ocr tesseract-ocr-eng tesseract-ocr-hin # + more language packs
34
+ python app.py
35
+ ```
36
+
37
+ ## ⏱️ Performance note
38
+
39
+ OCR runs on the Space's CPU — for best results start with 1–2 pages at 200 DPI to check quality, then process the rest.
app.py ADDED
@@ -0,0 +1,1149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 📝 PDF EDITER — Advanced PDF Editor with OCR
5
+ =============================================
6
+ Upload PDFs → OCR scanned pages → Edit text → Manage pages →
7
+ Merge / split → Watermark → Compress → Protect → Download
8
+
9
+ Built with Gradio + PyMuPDF + Tesseract OCR.
10
+ """
11
+
12
+ import io
13
+ import base64
14
+ import math
15
+ import os
16
+ import tempfile
17
+
18
+ from PIL import Image, ImageDraw, ImageFont
19
+
20
+ import gradio as gr
21
+ import pymupdf as fitz
22
+
23
+ # --------------------------------------------------------------------------
24
+ # Constants
25
+ # --------------------------------------------------------------------------
26
+ RENDER_ZOOM = 1.6 # zoom used for the interactive page preview
27
+ THUMB_SCALE = 0.35 # zoom used for gallery thumbnails
28
+ MAX_UNDO = 25
29
+
30
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
31
+
32
+ # Noto Sans script fonts, embedded as base64 (keeps the HF repo text-only).
33
+ # Latin/Cyrillic/Greek is served by the pip package `pymupdf-fonts` (fontname "notos").
34
+ try:
35
+ from fonts_data import FONTS_B64
36
+ except Exception:
37
+ FONTS_B64 = {}
38
+
39
+ _font_cache = {}
40
+
41
+
42
+ def get_font_file(key):
43
+ """Decode an embedded font once, cache its temp-file path."""
44
+ if key in _font_cache:
45
+ return _font_cache[key]
46
+ data = base64.b64decode(FONTS_B64[key])
47
+ fd, path = tempfile.mkstemp(suffix=".ttf", prefix=f"pdfediter_{key}_")
48
+ with os.fdopen(fd, "wb") as f:
49
+ f.write(data)
50
+ _font_cache[key] = path
51
+ return path
52
+
53
+
54
+ def resolve_font(key):
55
+ """(fontname, fontfile) ready for insert_text; fontfile None = built-in/pip font."""
56
+ name, ref = FONTS[key]
57
+ return name, (get_font_file(ref) if ref else None)
58
+
59
+
60
+ # Font choices: display name -> (pymupdf name, embedded-font key or None)
61
+ FONTS = {
62
+ "Helvetica (default)": ("helv", None),
63
+ "Times Roman": ("tiro", None),
64
+ "Courier": ("cour", None),
65
+ "Noto Sans (Latin/Cyrillic/Greek)": ("notos", None),
66
+ "Noto Sans Devanagari (हिन्दी)": ("notosdev", "devanagari"),
67
+ "Noto Sans Tamil (தமிழ்)": ("notostam", "tamil"),
68
+ "Noto Sans Bengali (বাংলা)": ("notosben", "bengali"),
69
+ "Noto Sans Arabic (العربية)": ("notosara", "arabic"),
70
+ "Noto Sans Gurmukhi (ਪੰਜਾਬੀ)": ("notosgur", "gurmukhi"),
71
+ }
72
+
73
+ # Tesseract languages: display name -> tesseract code
74
+ OCR_LANGS = {
75
+ "English": "eng",
76
+ "Hindi (हिन्दी)": "hin",
77
+ "Tamil (தமிழ்)": "tam",
78
+ "Telugu (తెలుగు)": "tel",
79
+ "Kannada (ಕನ್ನಡ)": "kan",
80
+ "Malayalam (മലയാളം)": "mal",
81
+ "Bengali (বাংলা)": "ben",
82
+ "Marathi (मराठी)": "mar",
83
+ "Gujarati (ગુજરાતી)": "guj",
84
+ "Punjabi (ਪੰਜਾਬੀ)": "pan",
85
+ "Spanish": "spa",
86
+ "French": "fra",
87
+ "German": "deu",
88
+ "Italian": "ita",
89
+ "Portuguese": "por",
90
+ "Russian (русский)": "rus",
91
+ "Arabic (العربية)": "ara",
92
+ "Chinese Simplified (简体中文)": "chi-sim",
93
+ "Japanese (日本語)": "jpn",
94
+ }
95
+
96
+ # Unicode ranges used to auto-pick a font for arbitrary text
97
+ R_DEV = (0x0900, 0x097F)
98
+ R_TAM = (0x0B80, 0x0BFF)
99
+ R_BEN = (0x0980, 0x09FF)
100
+ R_ARA = (0x0600, 0x06FF)
101
+ R_GUR = (0x0A00, 0x0A7F)
102
+
103
+
104
+ # --------------------------------------------------------------------------
105
+ # Small helpers
106
+ # --------------------------------------------------------------------------
107
+ def new_state():
108
+ return {
109
+ "bytes": None,
110
+ "name": "untitled.pdf",
111
+ "page": 0,
112
+ "undo": [],
113
+ "redo": [],
114
+ "form_fields": [],
115
+ "pw": "",
116
+ "last": "",
117
+ }
118
+
119
+
120
+ def open_doc(S):
121
+ if not S.get("bytes"):
122
+ raise gr.Error("📄 No PDF loaded yet — upload a file or press “Load demo PDF”.")
123
+ doc = fitz.open(stream=S["bytes"], filetype="pdf")
124
+ if doc.needs_pass:
125
+ pw = S.get("pw") or ""
126
+ if not doc.authenticate(pw):
127
+ doc.close()
128
+ raise gr.Error("🔒 This PDF is password-protected — unlock it in the "
129
+ "Merge & Advanced tab first.")
130
+ return doc
131
+
132
+
133
+ def snapshot(S):
134
+ S["undo"].append(S["bytes"])
135
+ if len(S["undo"]) > MAX_UNDO:
136
+ S["undo"].pop(0)
137
+ S["redo"] = []
138
+
139
+
140
+ def commit_bytes(S, data, label):
141
+ snapshot(S)
142
+ S["bytes"] = data
143
+ S["last"] = label
144
+ if S.get("page") is not None:
145
+ try:
146
+ d = fitz.open(stream=data, filetype="pdf")
147
+ S["page"] = min(S["page"], d.page_count - 1)
148
+ d.close()
149
+ except Exception:
150
+ S["page"] = 0
151
+
152
+
153
+ def commit(S, doc, label):
154
+ commit_bytes(S, doc.tobytes(garbage=4, deflate=True), label)
155
+
156
+
157
+ def hex_to_rgb(h):
158
+ """'#rrggbb' → (r, g, b) floats in 0..1 (PyMuPDF color convention)."""
159
+ h = h.lstrip("#")
160
+ return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
161
+
162
+
163
+ def has_script(text, ranges):
164
+ for ch in text:
165
+ o = ord(ch)
166
+ for a, b in ranges:
167
+ if a <= o <= b:
168
+ return True
169
+ return False
170
+
171
+
172
+ def pick_font(text):
173
+ """Return (fontname, fontfile) that can render the given text."""
174
+ if has_script(text, [R_DEV]):
175
+ return resolve_font("Noto Sans Devanagari (हिन्दी)")
176
+ if has_script(text, [R_TAM]):
177
+ return resolve_font("Noto Sans Tamil (தமிழ்)")
178
+ if has_script(text, [R_BEN]):
179
+ return resolve_font("Noto Sans Bengali (বাংলা)")
180
+ if has_script(text, [R_ARA]):
181
+ return resolve_font("Noto Sans Arabic (العربية)")
182
+ if has_script(text, [R_GUR]):
183
+ return resolve_font("Noto Sans Gurmukhi (ਪੰਜਾਬੀ)")
184
+ return resolve_font("Noto Sans (Latin/Cyrillic/Greek)")
185
+
186
+
187
+ def fmt_size(n):
188
+ if n < 1024:
189
+ return f"{n} B"
190
+ if n < 1024 * 1024:
191
+ return f"{n / 1024:.1f} KB"
192
+ return f"{n / (1024 * 1024):.2f} MB"
193
+
194
+
195
+ def save_temp(data, suffix, display_name):
196
+ fd, path = tempfile.mkstemp(suffix=suffix, prefix="pdfediter_")
197
+ with os.fdopen(fd, "wb") as f:
198
+ f.write(data)
199
+ pretty = os.path.join(os.path.dirname(path), display_name)
200
+ try:
201
+ os.replace(path, pretty)
202
+ return pretty
203
+ except OSError:
204
+ return path
205
+
206
+
207
+ def parse_page_ranges(text, n):
208
+ """Parse '1,3,5-8' (1-based) into a sorted 0-based unique int list."""
209
+ out = set()
210
+ for part in str(text).replace(" ", "").split(","):
211
+ if not part:
212
+ continue
213
+ if "-" in part:
214
+ a, b = part.split("-")
215
+ lo, hi = int(a), int(b)
216
+ if lo > hi:
217
+ lo, hi = hi, lo
218
+ out.update(range(max(1, lo), min(n, hi) + 1))
219
+ else:
220
+ v = int(part)
221
+ if 1 <= v <= n:
222
+ out.add(v)
223
+ return sorted(v - 1 for v in out)
224
+
225
+
226
+ def pg_slider_update(n_pages, current):
227
+ return gr.update(maximum=n_pages, value=current + 1)
228
+
229
+
230
+ def xy_slider_update(w, h):
231
+ return gr.update(maximum=w, value=0.0), gr.update(maximum=h, value=0.0)
232
+
233
+
234
+ # --------------------------------------------------------------------------
235
+ # Rendering helpers
236
+ # --------------------------------------------------------------------------
237
+ def render_pil(S, pno=None, zoom=RENDER_ZOOM):
238
+ doc = open_doc(S)
239
+ if pno is None:
240
+ pno = min(S["page"], doc.page_count - 1)
241
+ page = doc[pno]
242
+ pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
243
+ img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
244
+ doc.close()
245
+ return img
246
+
247
+
248
+ def gallery_pils(S, max_pages=250):
249
+ doc = open_doc(S)
250
+ imgs = []
251
+ for pno in range(min(doc.page_count, max_pages)):
252
+ page = doc[pno]
253
+ pix = page.get_pixmap(matrix=fitz.Matrix(THUMB_SCALE, THUMB_SCALE), alpha=False)
254
+ imgs.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
255
+ doc.close()
256
+ return imgs
257
+
258
+
259
+ def session_info(S):
260
+ if not S.get("bytes"):
261
+ return "No document loaded"
262
+ doc = open_doc(S)
263
+ n = doc.page_count
264
+ doc.close()
265
+ return (f"**{S['name']}** — {n} page{'s' if n != 1 else ''} · "
266
+ f"{fmt_size(len(S['bytes']))} · view page **{S['page'] + 1}/{n}** · "
267
+ f"last action: _{S['last'] or '—'}_")
268
+
269
+
270
+ def page_dims(S):
271
+ doc = open_doc(S)
272
+ page = doc[min(S["page"], doc.page_count - 1)]
273
+ w, h = page.rect.width, page.rect.height
274
+ doc.close()
275
+ return float(w), float(h)
276
+
277
+
278
+ # --------------------------------------------------------------------------
279
+ # Demo PDF generator
280
+ # --------------------------------------------------------------------------
281
+ def make_demo_pdf():
282
+ doc = fitz.open()
283
+
284
+ # --- page 1: title + text to test find/replace
285
+ p = doc.new_page()
286
+ p.insert_text((72, 110), "PDF EDITER — Demo Document", fontsize=24,
287
+ fontname="helv", color=hex_to_rgb("#4f46e5"))
288
+ p.insert_text((72, 150), "This demo was generated automatically so you can try every feature.",
289
+ fontsize=12, fontname="helv")
290
+ p.insert_text((72, 185), "Replace Me: apples bananas cherries durian",
291
+ fontsize=13, fontname="helv")
292
+ p.insert_text((72, 215), "Try the Edit Text tab: search for “apples” and replace it.",
293
+ fontsize=11, fontname="helv")
294
+
295
+ # --- page 2: 'scanned style' page (text baked into an image) for OCR demo
296
+ p2 = doc.new_page()
297
+ try:
298
+ fnt = ImageFont.truetype(get_font_file("devanagari"), 44) # font incl. Latin glyphs
299
+ except Exception:
300
+ fnt = ImageFont.load_default()
301
+ img = Image.new("RGB", (1600, 900), "white")
302
+ d = ImageDraw.Draw(img)
303
+ d.text((120, 120), "SCANNED STYLE PAGE - FOR OCR", fill="black", font=fnt)
304
+ d.text((120, 240), "This page is a picture of text, not real text.", fill="black", font=fnt)
305
+ d.text((120, 320), "Use the OCR tab to extract a searchable text layer.", fill="black", font=fnt)
306
+ d.text((120, 400), "Invoice number 2026-0815 amount 4,999.99", fill="black", font=fnt)
307
+ buf = io.BytesIO()
308
+ img.save(buf, format="PNG")
309
+ p2.insert_image(p2.rect, stream=buf.getvalue())
310
+
311
+ # --- page 3: a simple form to demo the Forms tab
312
+ p3 = doc.new_page()
313
+ p3.insert_text((72, 100), "Sample Form", fontsize=18, fontname="helv",
314
+ color=hex_to_rgb("#4f46e5"))
315
+ for lab, y in [("Name", 140), ("Email", 190), ("City", 240), ("Amount", 290)]:
316
+ p3.insert_text((72, y), f"{lab}:", fontsize=12, fontname="helv")
317
+ w = fitz.Widget()
318
+ w.field_type = fitz.PDF_WIDGET_TYPE_TEXT
319
+ w.field_name = lab.lower()
320
+ w.rect = fitz.Rect(150, y - 12, 420, y + 8)
321
+ w.field_value = ""
322
+ p3.add_widget(w)
323
+
324
+ data = doc.tobytes(garbage=4, deflate=True)
325
+ doc.close()
326
+ return data
327
+
328
+
329
+ # --------------------------------------------------------------------------
330
+ # Top bar actions
331
+ # --------------------------------------------------------------------------
332
+ def load_demo(S):
333
+ S["bytes"] = make_demo_pdf()
334
+ S["name"] = "demo.pdf"
335
+ S["page"] = 0
336
+ S["undo"], S["redo"] = [], []
337
+ S["last"] = "Demo loaded"
338
+ w, h = page_dims(S)
339
+ xup, yup = xy_slider_update(w, h)
340
+ return (render_pil(S), gallery_pils(S), session_info(S),
341
+ "✅ Demo PDF loaded — try OCR, replace, add text, forms…", xup, yup,
342
+ pg_slider_update(3, 0), S)
343
+
344
+
345
+ def load_files(files, S):
346
+ if not files:
347
+ raise gr.Error("Select at least one PDF file.")
348
+ paths = [f if isinstance(f, str) else f.name for f in files]
349
+ merged = fitz.open()
350
+ for path in paths:
351
+ try:
352
+ src = fitz.open(path)
353
+ except Exception:
354
+ raise gr.Error(f"Could not open {os.path.basename(path)} — is it a valid PDF?")
355
+ merged.insert_pdf(src)
356
+ src.close()
357
+ n = merged.page_count
358
+ if n == 0:
359
+ merged.close()
360
+ raise gr.Error("The file(s) contain no pages.")
361
+ S["bytes"] = merged.tobytes(garbage=4, deflate=True)
362
+ merged.close()
363
+ S["name"] = os.path.basename(paths[0]) if len(paths) == 1 else f"merged_{len(paths)}_files.pdf"
364
+ S["page"] = 0
365
+ S["undo"], S["redo"] = [], []
366
+ S["last"] = "Loaded"
367
+ w, h = page_dims(S)
368
+ xup, yup = xy_slider_update(w, h)
369
+ return (render_pil(S), gallery_pils(S), session_info(S),
370
+ f"✅ Loaded {len(paths)} file(s) — {n} page(s) in the working document.",
371
+ xup, yup, pg_slider_update(n, 0), S)
372
+
373
+
374
+ def nav_go(page_no, S):
375
+ doc = open_doc(S)
376
+ n = doc.page_count
377
+ doc.close()
378
+ page_no = max(0, min(int(page_no) - 1, n - 1))
379
+ S["page"] = page_no
380
+ w, h = page_dims(S)
381
+ xup, yup = xy_slider_update(w, h)
382
+ return render_pil(S), session_info(S), xup, yup, pg_slider_update(n, page_no), S
383
+
384
+
385
+ def nav_delta(delta, S):
386
+ doc = open_doc(S)
387
+ n = doc.page_count
388
+ doc.close()
389
+ S["page"] = max(0, min(S["page"] + delta, n - 1))
390
+ w, h = page_dims(S)
391
+ xup, yup = xy_slider_update(w, h)
392
+ return render_pil(S), session_info(S), xup, yup, pg_slider_update(n, S["page"]), S
393
+
394
+
395
+ def undo_action(S):
396
+ if not S["undo"]:
397
+ return (gr.update(), gr.update(), gr.update(), "Nothing to undo.",
398
+ gr.update(), gr.update(), gr.update(), S)
399
+ S["redo"].append(S["bytes"])
400
+ S["bytes"] = S["undo"].pop()
401
+ S["last"] = "Undo"
402
+ n, w, h = _count_dims(S)
403
+ xup, yup = xy_slider_update(w, h)
404
+ return (render_pil(S), gallery_pils(S), session_info(S), "↩️ Undo applied.",
405
+ xup, yup, pg_slider_update(n, S["page"]), S)
406
+
407
+
408
+ def redo_action(S):
409
+ if not S["redo"]:
410
+ return (gr.update(), gr.update(), gr.update(), "Nothing to redo.",
411
+ gr.update(), gr.update(), gr.update(), S)
412
+ S["undo"].append(S["bytes"])
413
+ S["bytes"] = S["redo"].pop()
414
+ S["last"] = "Redo"
415
+ n, w, h = _count_dims(S)
416
+ xup, yup = xy_slider_update(w, h)
417
+ return (render_pil(S), gallery_pils(S), session_info(S), "↪️ Redo applied.",
418
+ xup, yup, pg_slider_update(n, S["page"]), S)
419
+
420
+
421
+ def reset_all(S):
422
+ S.update(new_state())
423
+ return (None, [], "No document loaded", "🧹 Session reset.",
424
+ gr.update(maximum=595, value=0.0), gr.update(maximum=842, value=0.0),
425
+ gr.update(maximum=1, value=1), S)
426
+
427
+
428
+ def _count_dims(S):
429
+ doc = open_doc(S)
430
+ n = doc.page_count
431
+ page = doc[min(S["page"], n - 1)]
432
+ w, h = page.rect.width, page.rect.height
433
+ doc.close()
434
+ return n, float(w), float(h)
435
+
436
+
437
+ def download_current(S):
438
+ if not S.get("bytes"):
439
+ raise gr.Error("Nothing to download yet.")
440
+ path = save_temp(S["bytes"], ".pdf", S["name"])
441
+ return path, "⬇️ Download ready — click the file above to save it."
442
+
443
+
444
+ # --------------------------------------------------------------------------
445
+ # Tab 2 — OCR
446
+ # --------------------------------------------------------------------------
447
+ def run_ocr(lang_labels, dpi, page_sel, S, progress=gr.Progress()):
448
+ if not S.get("bytes"):
449
+ raise gr.Error("Load a PDF first.")
450
+ langs = "+".join(OCR_LANGS[l] for l in lang_labels if l in OCR_LANGS) or "eng"
451
+
452
+ doc = open_doc(S)
453
+ pages = list(range(doc.page_count)) if page_sel == "All pages" else [S["page"]]
454
+ out_parts = []
455
+ progress(0, desc="Preparing OCR…")
456
+ for i, pno in enumerate(pages):
457
+ progress((i + 1) / len(pages), desc=f"OCR page {pno + 1}/{doc.page_count} [{langs}] @ {dpi}dpi")
458
+ page = doc[pno]
459
+ tp = page.get_textpage_ocr(language=langs, dpi=int(dpi), full=True)
460
+ text = page.get_text("text", textpage=tp)
461
+ out_parts.append(f"\n\n===== PAGE {pno + 1} =====\n{text}")
462
+ # Embed an invisible, searchable & selectable text layer — but only on
463
+ # pages that have NO real text yet (they are the genuinely scanned ones).
464
+ existing = page.get_text("text").strip()
465
+ if text.strip() and not existing:
466
+ words = page.get_text("words", textpage=tp)
467
+ for w in words:
468
+ x0, y0, x1, y1, word = w[0], w[1], w[2], w[3], w[4]
469
+ h = max(1.0, y1 - y0)
470
+ fn, ff = pick_font(word)
471
+ page.insert_text(fitz.Point(x0, y0 + h * 0.85), word,
472
+ fontsize=h * 0.9, fontname=fn, fontfile=ff,
473
+ render_mode=3, color=(1, 1, 1), overlay=False)
474
+ commit(S, doc, f"OCR ({langs}) on {len(pages)} page(s)")
475
+ doc.close()
476
+
477
+ out_text = "".join(out_parts).strip()
478
+ txt_path = save_temp(out_text.encode("utf-8"), ".txt", "ocr_text.txt")
479
+ pdf_path = save_temp(S["bytes"], ".pdf", "searchable.pdf")
480
+ return (out_text, txt_path, pdf_path, render_pil(S), gallery_pils(S),
481
+ session_info(S),
482
+ f"✅ OCR finished on {len(pages)} page(s). Text below is copyable — "
483
+ f"download the searchable PDF or the .txt file.", S)
484
+
485
+
486
+ def extract_text_txt(S):
487
+ doc = open_doc(S)
488
+ parts = []
489
+ for pno in range(doc.page_count):
490
+ parts.append(f"\n===== PAGE {pno + 1} =====\n{doc[pno].get_text('text')}")
491
+ doc.close()
492
+ txt = "".join(parts).strip()
493
+ path = save_temp(txt.encode("utf-8"), ".txt", "extracted_text.txt")
494
+ return path, f"📄 Extracted {len(txt)} characters from all pages."
495
+
496
+
497
+ # --------------------------------------------------------------------------
498
+ # Tab 3 — Edit text
499
+ # --------------------------------------------------------------------------
500
+ def _search_hits(page, find, case):
501
+ hits = []
502
+ for r in page.search_for(find):
503
+ # ignore sub-pixel artifacts (e.g. from 1pt invisible text layers)
504
+ if r.width < 2 or r.height < 2:
505
+ continue
506
+ if case:
507
+ if page.get_textbox(r).strip() == find:
508
+ hits.append(r)
509
+ else:
510
+ hits.append(r)
511
+ return hits
512
+
513
+
514
+ def highlight_matches(find, case, S):
515
+ if not find.strip():
516
+ raise gr.Error("Type something to search for.")
517
+ img = render_pil(S)
518
+ doc = open_doc(S)
519
+ page = doc[S["page"]]
520
+ hits = _search_hits(page, find, case)
521
+ doc.close()
522
+ draw = ImageDraw.Draw(img)
523
+ for r in hits:
524
+ draw.rectangle([r.x0 * RENDER_ZOOM, r.y0 * RENDER_ZOOM,
525
+ r.x1 * RENDER_ZOOM, r.y1 * RENDER_ZOOM],
526
+ outline=(220, 38, 38), width=4)
527
+ return img, f"🔎 {len(hits)} match(es) on page {S['page'] + 1} (highlighted in red)."
528
+
529
+
530
+ def replace_text(find, repl, case, current_only, S):
531
+ if not find.strip():
532
+ raise gr.Error("Enter the text to find.")
533
+ doc = open_doc(S)
534
+ pages = [S["page"]] if current_only else range(doc.page_count)
535
+ total = 0
536
+ for pno in pages:
537
+ page = doc[pno]
538
+ hits = _search_hits(page, find, case)
539
+ if not hits:
540
+ continue
541
+ for r in hits:
542
+ page.add_redact_annot(r, fill=(1, 1, 1))
543
+ page.apply_redactions()
544
+ fontname, fontfile = pick_font(repl)
545
+ for r in hits:
546
+ fs = max(6.0, r.height * 0.9)
547
+ lines = repl.split("\n")
548
+ y = r.y0 + fs
549
+ for ln in lines:
550
+ page.insert_text(fitz.Point(r.x0, y), ln, fontsize=fs,
551
+ fontname=fontname, fontfile=fontfile, color=(0, 0, 0))
552
+ y += fs * 1.25
553
+ total += len(hits)
554
+ commit(S, doc, f"Replaced {total} occurrence(s) of “{find}”")
555
+ doc.close()
556
+ return (render_pil(S), gallery_pils(S), session_info(S),
557
+ f"✏️ Replaced {total} occurrence(s). Undo is available in the top bar.", S)
558
+
559
+
560
+ def add_text(text, font_key, size, color, x, y, S):
561
+ if not text.strip():
562
+ raise gr.Error("Enter the text to add.")
563
+ doc = open_doc(S)
564
+ page = doc[S["page"]]
565
+ fontname, fontfile = resolve_font(font_key)
566
+ try:
567
+ page.insert_text(fitz.Point(float(x), float(y)), text, fontsize=float(size),
568
+ fontname=fontname, fontfile=fontfile, color=hex_to_rgb(color))
569
+ except Exception as e:
570
+ doc.close()
571
+ raise gr.Error(f"Could not place text: {e}")
572
+ commit(S, doc, f"Added text on page {S['page'] + 1}")
573
+ doc.close()
574
+ return (render_pil(S), gallery_pils(S), session_info(S),
575
+ f"✅ Text added on page {S['page'] + 1} at ({x:.0f}, {y:.0f}).", S)
576
+
577
+
578
+ def on_preview_click(evt: gr.SelectData, S):
579
+ if not S.get("bytes"):
580
+ return 0.0, 0.0
581
+ x, y = evt.index
582
+ px, py = x / RENDER_ZOOM, y / RENDER_ZOOM
583
+ w, h = page_dims(S)
584
+ return round(min(max(px, 0), w), 1), round(min(max(py, 0), h), 1)
585
+
586
+
587
+ # --------------------------------------------------------------------------
588
+ # Tab 4 — Page tools
589
+ # --------------------------------------------------------------------------
590
+ def gallery_select(evt: gr.SelectData, S):
591
+ idx = int(evt.index)
592
+ S["page"] = idx
593
+ n, w, h = _count_dims(S)
594
+ xup, yup = xy_slider_update(w, h)
595
+ return (render_pil(S), session_info(S), xup, yup,
596
+ pg_slider_update(n, idx), S)
597
+
598
+
599
+ def delete_pages(pages_str, S):
600
+ doc = open_doc(S)
601
+ n = doc.page_count
602
+ idx = parse_page_ranges(pages_str, n)
603
+ if not idx:
604
+ doc.close()
605
+ raise gr.Error("Enter valid page numbers, e.g. 1,3 or 2-5.")
606
+ for i in sorted(idx, reverse=True):
607
+ doc.delete_page(i)
608
+ commit(S, doc, f"Deleted {len(idx)} page(s)")
609
+ doc.close()
610
+ n2, w, h = _count_dims(S)
611
+ xup, yup = xy_slider_update(w, h)
612
+ return (render_pil(S), gallery_pils(S), session_info(S),
613
+ f"🗑️ Deleted page(s): {', '.join(str(i + 1) for i in idx)}",
614
+ xup, yup, pg_slider_update(n2, S["page"]), S)
615
+
616
+
617
+ def rotate_pages(deg, all_pages, S):
618
+ doc = open_doc(S)
619
+ pages = range(doc.page_count) if all_pages else [S["page"]]
620
+ for pno in pages:
621
+ p = doc[pno]
622
+ p.set_rotation((p.rotation + deg) % 360)
623
+ commit(S, doc, f"Rotated {deg}° ({'all pages' if all_pages else 'current page'})")
624
+ doc.close()
625
+ return (render_pil(S), gallery_pils(S), session_info(S),
626
+ f"🔄 Rotated {deg}°.", S)
627
+
628
+
629
+ def duplicate_page(S):
630
+ doc = open_doc(S)
631
+ pno = S["page"]
632
+ doc.copy_page(pno) # appends a copy at the end
633
+ doc.move_page(doc.page_count - 1, pno + 1) # place it right after the original
634
+ commit(S, doc, f"Duplicated page {pno + 1}")
635
+ doc.close()
636
+ n, w, h = _count_dims(S)
637
+ xup, yup = xy_slider_update(w, h)
638
+ return (render_pil(S), gallery_pils(S), session_info(S),
639
+ f"📑 Duplicated page {pno + 1}.",
640
+ xup, yup, pg_slider_update(n, S["page"]), S)
641
+
642
+
643
+ def move_page(dirn, S):
644
+ doc = open_doc(S)
645
+ pno = S["page"]
646
+ target = pno + dirn
647
+ if 0 <= target < doc.page_count:
648
+ doc.move_page(pno, target)
649
+ S["page"] = target
650
+ commit(S, doc, f"Moved page {pno + 1} {'up' if dirn < 0 else 'down'}")
651
+ doc.close()
652
+ return (render_pil(S), gallery_pils(S), session_info(S), "↕️ Page moved.",
653
+ gr.update(), gr.update(), gr.update(), S)
654
+ doc.close()
655
+ return (gr.update(), gr.update(), gr.update(), "Already at the edge.",
656
+ gr.update(), gr.update(), gr.update(), S)
657
+
658
+
659
+ def reorder_pages(order_str, S):
660
+ doc = open_doc(S)
661
+ n = doc.page_count
662
+ parts = [p.strip() for p in str(order_str).split(",") if p.strip()]
663
+ try:
664
+ order = [int(p) for p in parts]
665
+ except ValueError:
666
+ doc.close()
667
+ raise gr.Error("Enter a comma-separated list of page numbers, e.g. 3,1,2,4")
668
+ if sorted(order) != list(range(1, n + 1)):
669
+ doc.close()
670
+ raise gr.Error(f"Order must be a permutation of 1..{n} (all pages, no repeats).")
671
+ nd = fitz.open()
672
+ for p in order:
673
+ nd.insert_pdf(doc, from_page=p - 1, to_page=p - 1)
674
+ doc.close()
675
+ commit_bytes(S, nd.tobytes(garbage=4, deflate=True), "Pages reordered")
676
+ nd.close()
677
+ n, w, h = _count_dims(S)
678
+ xup, yup = xy_slider_update(w, h)
679
+ return (render_pil(S), gallery_pils(S), session_info(S),
680
+ f"🔀 Pages reordered to: {order_str}",
681
+ xup, yup, pg_slider_update(n, S["page"]), S)
682
+
683
+
684
+ def split_pdf(chunk, S):
685
+ doc = open_doc(S)
686
+ n = doc.page_count
687
+ chunk = max(1, int(chunk))
688
+ paths = []
689
+ nd = fitz.open()
690
+ for pno in range(n):
691
+ if pno > 0 and pno % chunk == 0:
692
+ paths.append(save_temp(nd.tobytes(garbage=4, deflate=True), ".pdf",
693
+ f"part_{len(paths) + 1}.pdf"))
694
+ nd.close()
695
+ nd = fitz.open()
696
+ nd.insert_pdf(doc, from_page=pno, to_page=pno)
697
+ if nd.page_count:
698
+ paths.append(save_temp(nd.tobytes(garbage=4, deflate=True), ".pdf",
699
+ f"part_{len(paths) + 1}.pdf"))
700
+ nd.close()
701
+ doc.close()
702
+ return paths, f"✂️ Split into {len(paths)} part(s) of up to {chunk} page(s) each."
703
+
704
+
705
+ def extract_pages(pages_str, S):
706
+ doc = open_doc(S)
707
+ n = doc.page_count
708
+ idx = parse_page_ranges(pages_str, n)
709
+ if not idx:
710
+ doc.close()
711
+ raise gr.Error("Enter valid page numbers, e.g. 1,3 or 2-5.")
712
+ nd = fitz.open()
713
+ for i in idx:
714
+ nd.insert_pdf(doc, from_page=i, to_page=i)
715
+ doc.close()
716
+ path = save_temp(nd.tobytes(garbage=4, deflate=True), ".pdf", "extracted_pages.pdf")
717
+ nd.close()
718
+ return path, f"📦 Extracted {len(idx)} page(s)."
719
+
720
+
721
+ # --------------------------------------------------------------------------
722
+ # Tab 5 — Merge & advanced
723
+ # --------------------------------------------------------------------------
724
+ def merge_files(files, S):
725
+ if not files:
726
+ raise gr.Error("Select at least two PDFs to merge.")
727
+ paths = [f if isinstance(f, str) else f.name for f in files]
728
+ nd = fitz.open()
729
+ for path in paths:
730
+ try:
731
+ src = fitz.open(path)
732
+ except Exception:
733
+ raise gr.Error(f"Could not open {os.path.basename(path)}")
734
+ nd.insert_pdf(src)
735
+ src.close()
736
+ n = nd.page_count
737
+ if n == 0:
738
+ nd.close()
739
+ raise gr.Error("Nothing to merge.")
740
+ commit_bytes(S, nd.tobytes(garbage=4, deflate=True), "Merged PDFs")
741
+ S["name"] = "merged.pdf"
742
+ path = save_temp(S["bytes"], ".pdf", "merged.pdf")
743
+ nd.close()
744
+ n, w, h = _count_dims(S)
745
+ xup, yup = xy_slider_update(w, h)
746
+ return (path, render_pil(S), gallery_pils(S), session_info(S),
747
+ f"🔗 Merged {len(paths)} file(s) → {n} pages (now your working document).",
748
+ xup, yup, pg_slider_update(n, S["page"]), S)
749
+
750
+
751
+ def add_watermark(text, size, color, opacity, angle, S):
752
+ if not text.strip():
753
+ raise gr.Error("Enter watermark text.")
754
+ doc = open_doc(S)
755
+ fontname, fontfile = pick_font(text)
756
+ f = fitz.Font(fontname=fontname, fontfile=fontfile)
757
+ rgb = hex_to_rgb(color)
758
+ rad = math.radians(angle)
759
+ cos, sin = math.cos(rad), math.sin(rad)
760
+ m = fitz.Matrix(cos, sin, -sin, cos, 0, 0)
761
+ for pno in range(doc.page_count):
762
+ page = doc[pno]
763
+ rect = page.rect
764
+ tw = fitz.TextWriter(rect, color=rgb)
765
+ cx, cy = rect.width / 2, rect.height / 2
766
+ tw.append(fitz.Point(cx, cy), text, font=f, fontsize=float(size))
767
+ tw.write_text(page, morph=(fitz.Point(cx, cy), m),
768
+ opacity=float(opacity), overlay=True)
769
+ commit(S, doc, "Watermark added to all pages")
770
+ doc.close()
771
+ return (render_pil(S), gallery_pils(S), session_info(S),
772
+ f"💧 Watermark “{text}” applied to all pages.", S)
773
+
774
+
775
+ def compress_pdf(S):
776
+ doc = open_doc(S)
777
+ before = len(S["bytes"])
778
+ try:
779
+ doc.subset_fonts()
780
+ except Exception:
781
+ pass
782
+ data = doc.tobytes(garbage=4, deflate=True)
783
+ doc.close()
784
+ after = len(data)
785
+ pct = (1 - after / before) * 100 if before else 0
786
+ commit_bytes(S, data, "Compressed")
787
+ path = save_temp(data, ".pdf", "compressed.pdf")
788
+ return (path, render_pil(S), gallery_pils(S), session_info(S),
789
+ f"🗜️ Compressed: {fmt_size(before)} → {fmt_size(after)} "
790
+ f"({pct:+.1f}%). Download below.", S)
791
+
792
+
793
+ def encrypt_pdf(pw, S):
794
+ if not pw or len(pw) < 3:
795
+ raise gr.Error("Password must be at least 3 characters.")
796
+ doc = open_doc(S)
797
+ data = doc.tobytes(garbage=4, deflate=True, encryption=fitz.PDF_ENCRYPT_AES_256,
798
+ user_pw=pw, owner_pw=pw)
799
+ doc.close()
800
+ commit_bytes(S, data, "Encrypted with password")
801
+ S["pw"] = pw
802
+ path = save_temp(data, ".pdf", "protected.pdf")
803
+ return (path, render_pil(S), gallery_pils(S), session_info(S),
804
+ f"🔐 Encrypted (AES-256). Password: “{pw}” — keep it safe!", S)
805
+
806
+
807
+ def decrypt_pdf(pw, S):
808
+ doc = open_doc(S)
809
+ if doc.needs_pass:
810
+ if not doc.authenticate(pw or ""):
811
+ doc.close()
812
+ raise gr.Error("❌ Wrong password.")
813
+ data = doc.tobytes(garbage=4, deflate=True)
814
+ doc.close()
815
+ commit_bytes(S, data, "Password removed")
816
+ S["pw"] = ""
817
+ path = save_temp(data, ".pdf", "unlocked.pdf")
818
+ return (path, render_pil(S), gallery_pils(S), session_info(S),
819
+ "🔓 Password removed — file is now open.", S)
820
+
821
+
822
+ # --------------------------------------------------------------------------
823
+ # Tab 6 — Forms
824
+ # --------------------------------------------------------------------------
825
+ def list_forms(S):
826
+ doc = open_doc(S)
827
+ fields = []
828
+ for pno in range(doc.page_count):
829
+ for w in doc[pno].widgets():
830
+ name = w.field_name or f"field_{pno + 1}"
831
+ fields.append({"label": f"{name} · p{pno + 1} ({w.field_type_string})",
832
+ "pno": pno, "name": name})
833
+ doc.close()
834
+ S["form_fields"] = fields
835
+ choices = [f["label"] for f in fields]
836
+ if not fields:
837
+ return gr.update(choices=[], value=None), "📋 No form fields found in this PDF."
838
+ return (gr.update(choices=choices, value=choices[0]),
839
+ f"📋 Found {len(fields)} form field(s). Pick one, type a value, press Apply.")
840
+
841
+
842
+ def set_form(label, value, S):
843
+ fields = S.get("form_fields") or []
844
+ if not fields:
845
+ raise gr.Error("Click “Scan form fields” first.")
846
+ f = next((x for x in fields if x["label"] == label), None)
847
+ if not f:
848
+ raise gr.Error("Pick a field from the list.")
849
+ doc = open_doc(S)
850
+ page = doc[f["pno"]]
851
+ done = False
852
+ for w in page.widgets():
853
+ if w.field_name == f["name"]:
854
+ w.field_value = value
855
+ w.update()
856
+ done = True
857
+ break
858
+ if not done:
859
+ doc.close()
860
+ raise gr.Error("Field not found on the page.")
861
+ commit(S, doc, f"Filled form field “{f['name']}”")
862
+ doc.close()
863
+ return (render_pil(S), gallery_pils(S), session_info(S),
864
+ f"✅ Field “{f['name']}” set to “{value}”.", S)
865
+
866
+
867
+ # ==========================================================================
868
+ # UI
869
+ # ==========================================================================
870
+ CSS = """
871
+ .gradio-container {max-width: 1320px !important; margin: 0 auto;}
872
+ #hd {background: linear-gradient(135deg,#4f46e5 0%,#7c3aed 55%,#2563eb 100%);
873
+ border-radius: 20px; padding: 26px 30px; color:#fff; margin: 8px 0 16px;
874
+ box-shadow: 0 10px 30px -12px rgba(79,70,229,.55);}
875
+ #hd h1 {margin:0; font-size:32px; font-weight:800; letter-spacing:-.5px;}
876
+ #hd p {margin:8px 0 0; opacity:.95; font-size:15px;}
877
+ #hd .sub {margin-top:14px;}
878
+ .badge {display:inline-block; background:rgba(255,255,255,.16); border:1px solid rgba(255,255,255,.35);
879
+ border-radius:999px; padding:3px 13px; font-size:12.5px; margin:0 6px 6px 0;}
880
+ #toolbar {display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 4px;}
881
+ footer {display:none !important;}
882
+ """
883
+
884
+ theme = gr.themes.Soft(
885
+ primary_hue="indigo",
886
+ secondary_hue="blue",
887
+ neutral_hue="slate",
888
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
889
+ )
890
+
891
+
892
+ def build_demo():
893
+ with gr.Blocks(title="PDF EDITER — Advanced PDF Editor with OCR") as demo:
894
+ state = gr.State(value=new_state())
895
+
896
+ # ---------------- header ----------------
897
+ with gr.Column(elem_id="hd"):
898
+ gr.HTML("""
899
+ <h1>📝 PDF EDITER</h1>
900
+ <p>Advanced PDF editor with <b>OCR</b> — upload, edit text, manage pages,
901
+ merge, watermark, compress &amp; protect. Powered by PyMuPDF + Tesseract.</p>
902
+ <div class="sub">
903
+ <span class="badge">🔍 OCR · searchable PDF</span>
904
+ <span class="badge">✏️ Find &amp; Replace</span>
905
+ <span class="badge">➕ Add text (multi-language)</span>
906
+ <span class="badge">🖼️ Page tools</span>
907
+ <span class="badge">🔗 Merge / Split</span>
908
+ <span class="badge">💧 Watermark</span>
909
+ <span class="badge">🗜️ Compress</span>
910
+ <span class="badge">🔐 Protect</span>
911
+ <span class="badge">📋 Forms</span>
912
+ </div>
913
+ """)
914
+
915
+ # ---------------- top toolbar ----------------
916
+ with gr.Row(elem_id="toolbar"):
917
+ session_info = gr.Markdown("No document loaded", elem_id="status")
918
+ undo_btn = gr.Button("↩️ Undo", scale=0)
919
+ redo_btn = gr.Button("↪️ Redo", scale=0)
920
+ reset_btn = gr.Button("🧹 Reset", scale=0)
921
+ dl_btn = gr.Button("⬇️ Download PDF", variant="primary", scale=0)
922
+ dl_file = gr.File(visible=False)
923
+ status = gr.Markdown("👋 Welcome! Upload a PDF below, or press “Load demo PDF” to explore.")
924
+
925
+ # ---------------- tabs ----------------
926
+ with gr.Tabs():
927
+ # ================= TAB 1 : UPLOAD =================
928
+ with gr.Tab("📥 Upload & Preview"):
929
+ with gr.Row():
930
+ with gr.Column(scale=1):
931
+ upload = gr.File(file_count="multiple", file_types=[".pdf"],
932
+ label="Upload one or more PDFs (multiple = merged)")
933
+ load_btn = gr.Button("📂 Load PDF(s)", variant="primary")
934
+ demo_btn = gr.Button("✨ Load demo PDF (try everything instantly)")
935
+ gr.Markdown("### Navigate")
936
+ prev_btn = gr.Button("◀ Previous page")
937
+ page_slider = gr.Slider(1, 100, step=1, value=1, label="Go to page (1-based)")
938
+ next_btn = gr.Button("Next page ▶")
939
+ with gr.Column(scale=2):
940
+ preview = gr.Image(label="Page preview — click to set text position",
941
+ interactive=False, height=680)
942
+ gr.Markdown("💡 **Tip:** upload a scanned PDF and go to the **OCR** tab to make it "
943
+ "searchable; then **Edit Text** works on the extracted text.")
944
+
945
+ # ================= TAB 2 : OCR =================
946
+ with gr.Tab("🔍 OCR"):
947
+ with gr.Row():
948
+ with gr.Column(scale=1):
949
+ ocr_langs = gr.Dropdown(choices=list(OCR_LANGS.keys()),
950
+ value=["English"], multiselect=True,
951
+ label="OCR language(s) — hold Ctrl/Cmd to multi-select")
952
+ ocr_dpi = gr.Slider(150, 400, value=200, step=10,
953
+ label="Scan resolution (DPI — higher = slower but more accurate)")
954
+ ocr_pages = gr.Radio(["All pages", "Current page only"], value="All pages",
955
+ label="Pages to OCR")
956
+ ocr_btn = gr.Button("🧠 Run OCR — make it searchable", variant="primary")
957
+ extract_btn = gr.Button("📄 Extract text as .txt (no OCR)")
958
+ ocr_txt_dl = gr.File(label="OCR / extracted text (.txt)")
959
+ ocr_pdf_dl = gr.File(label="Searchable PDF (download)")
960
+ gr.Markdown("⏱️ OCR runs on CPU — large documents take a while. "
961
+ "Start with 1–2 pages to check quality.")
962
+ with gr.Column(scale=2):
963
+ ocr_out = gr.Textbox(label="Recognized text (editable)", lines=24)
964
+
965
+ # ================= TAB 3 : EDIT TEXT =================
966
+ with gr.Tab("✏️ Edit Text"):
967
+ with gr.Row():
968
+ with gr.Column(scale=1):
969
+ gr.Markdown("### 🔎 Find & Replace")
970
+ find_tb = gr.Textbox(label="Text to find", placeholder="e.g. apples")
971
+ repl_tb = gr.Textbox(label="Replace with", placeholder="e.g. oranges")
972
+ case_cb = gr.Checkbox(label="Case-sensitive", value=False)
973
+ cur_cb = gr.Checkbox(label="Current page only", value=False)
974
+ with gr.Row():
975
+ find_btn = gr.Button("🔎 Highlight matches")
976
+ repl_btn = gr.Button("✏️ Replace", variant="primary")
977
+ gr.Markdown("---")
978
+ gr.Markdown("### ➕ Add text (multi-language)")
979
+ add_tb = gr.Textbox(label="Text", placeholder="नमस्ते / Hello / مرحبا …",
980
+ value="Hello")
981
+ font_dd = gr.Dropdown(choices=list(FONTS.keys()),
982
+ value="Noto Sans (Latin/Cyrillic/Greek)",
983
+ label="Font")
984
+ with gr.Row():
985
+ size_sl = gr.Slider(4, 96, value=14, step=1, label="Font size")
986
+ color_pk = gr.ColorPicker(value="#111111", label="Color")
987
+ with gr.Row():
988
+ x_sl = gr.Slider(0, 595, value=72, step=1, label="X (pt)")
989
+ y_sl = gr.Slider(0, 842, value=100, step=1, label="Y (pt)")
990
+ add_btn = gr.Button("➕ Place text on this page", variant="primary")
991
+ gr.Markdown("💡 Click anywhere on the preview image to auto-fill X/Y.")
992
+ with gr.Column(scale=2):
993
+ preview2 = gr.Image(label="Preview", interactive=False, height=680)
994
+
995
+ # ================= TAB 4 : PAGE TOOLS =================
996
+ with gr.Tab("🖼️ Page Tools"):
997
+ with gr.Row():
998
+ with gr.Column(scale=1):
999
+ gr.Markdown("### Selected page (click a thumbnail)")
1000
+ sel_info = gr.Markdown("Click a thumbnail to select the working page.")
1001
+ with gr.Row():
1002
+ rot_cw = gr.Button("⟳ Rotate 90° CW")
1003
+ rot_ccw = gr.Button("⟲ Rotate 90° CCW")
1004
+ rot_all_cb = gr.Checkbox(label="Apply rotation to ALL pages", value=False)
1005
+ with gr.Row():
1006
+ dup_btn = gr.Button("📑 Duplicate page")
1007
+ del_btn = gr.Button("🗑️ Delete page(s)")
1008
+ del_tb = gr.Textbox(label="Pages to delete (1-based, e.g. 2,5 or 3-6)",
1009
+ placeholder="2,5")
1010
+ with gr.Row():
1011
+ mv_up = gr.Button("⬆️ Move up")
1012
+ mv_dn = gr.Button("⬇️ Move down")
1013
+ gr.Markdown("---")
1014
+ gr.Markdown("### 🔀 Reorder / Split / Extract")
1015
+ order_tb = gr.Textbox(label="New page order (permutation of 1..N)",
1016
+ placeholder="3,1,2,4")
1017
+ order_btn = gr.Button("🔀 Apply order")
1018
+ split_sl = gr.Slider(1, 50, value=1, step=1, label="Split after every N pages")
1019
+ split_btn = gr.Button("✂️ Split PDF")
1020
+ split_files = gr.File(file_count="multiple", label="Split parts")
1021
+ extract_tb = gr.Textbox(label="Extract pages (e.g. 1,3 or 2-5)",
1022
+ placeholder="1,3")
1023
+ extract_btn = gr.Button("📦 Extract pages")
1024
+ extract_file = gr.File(label="Extracted PDF")
1025
+ with gr.Column(scale=2):
1026
+ gallery = gr.Gallery(label="All pages — click to select", columns=3,
1027
+ rows=2, height=520, object_fit="contain")
1028
+ preview3 = gr.Image(label="Selected page preview", interactive=False, height=500)
1029
+
1030
+ # ================= TAB 5 : MERGE & ADVANCED =================
1031
+ with gr.Tab("🧩 Merge & Advanced"):
1032
+ with gr.Row():
1033
+ with gr.Column(scale=1):
1034
+ gr.Markdown("### 🔗 Merge PDFs")
1035
+ merge_files_in = gr.File(file_count="multiple", file_types=[".pdf"],
1036
+ label="Pick PDFs to merge (in order)")
1037
+ merge_btn = gr.Button("🔗 Merge into working document", variant="primary")
1038
+ merge_dl = gr.File(label="Merged PDF")
1039
+ gr.Markdown("---")
1040
+ gr.Markdown("### 💧 Watermark")
1041
+ wm_tb = gr.Textbox(label="Watermark text", value="CONFIDENTIAL")
1042
+ wm_size = gr.Slider(10, 120, value=48, step=2, label="Size")
1043
+ wm_color = gr.ColorPicker(value="#999999", label="Color")
1044
+ wm_opa = gr.Slider(0.05, 1.0, value=0.25, step=0.05, label="Opacity")
1045
+ wm_ang = gr.Slider(-90, 90, value=-45, step=5, label="Angle (°)")
1046
+ wm_btn = gr.Button("💧 Apply watermark to all pages")
1047
+ with gr.Column(scale=1):
1048
+ gr.Markdown("### 🗜️ Compress & 🔐 Protect")
1049
+ comp_btn = gr.Button("🗜️ Compress PDF (garbage collect + subset fonts)")
1050
+ comp_dl = gr.File(label="Compressed PDF")
1051
+ pw_tb = gr.Textbox(label="Password", type="password")
1052
+ with gr.Row():
1053
+ enc_btn = gr.Button("🔐 Encrypt (AES-256)")
1054
+ dec_btn = gr.Button("🔓 Unlock / remove password")
1055
+ prot_dl = gr.File(label="Protected / unlocked PDF")
1056
+ gr.Markdown("---")
1057
+ gr.Markdown("### 📋 Fill PDF forms")
1058
+ scan_form_btn = gr.Button("📋 Scan form fields")
1059
+ form_dd = gr.Dropdown(choices=[], label="Field")
1060
+ form_val = gr.Textbox(label="Value")
1061
+ form_set_btn = gr.Button("✅ Set field value")
1062
+ with gr.Column(scale=2):
1063
+ preview4 = gr.Image(label="Preview", interactive=False, height=680)
1064
+
1065
+ # ================= events =================
1066
+ # tab1
1067
+ load_btn.click(load_files, [upload, state],
1068
+ [preview, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1069
+ demo_btn.click(load_demo, [state],
1070
+ [preview, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1071
+ prev_btn.click(nav_delta, [gr.State(-1), state],
1072
+ [preview, session_info, x_sl, y_sl, page_slider, state])
1073
+ next_btn.click(nav_delta, [gr.State(1), state],
1074
+ [preview, session_info, x_sl, y_sl, page_slider, state])
1075
+ page_slider.change(nav_go, [page_slider, state],
1076
+ [preview, session_info, x_sl, y_sl, page_slider, state])
1077
+
1078
+ # top toolbar
1079
+ undo_btn.click(undo_action, [state],
1080
+ [preview, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1081
+ redo_btn.click(redo_action, [state],
1082
+ [preview, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1083
+ reset_btn.click(reset_all, [state],
1084
+ [preview, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1085
+ dl_btn.click(download_current, [state], [dl_file, status])
1086
+
1087
+ # tab2
1088
+ ocr_btn.click(run_ocr, [ocr_langs, ocr_dpi, ocr_pages, state],
1089
+ [ocr_out, ocr_txt_dl, ocr_pdf_dl, preview, gallery,
1090
+ session_info, status, state],
1091
+ api_name="ocr")
1092
+ extract_btn.click(extract_text_txt, [state], [ocr_txt_dl, status])
1093
+
1094
+ # tab3
1095
+ find_btn.click(highlight_matches, [find_tb, case_cb, state], [preview2, status])
1096
+ repl_btn.click(replace_text, [find_tb, repl_tb, case_cb, cur_cb, state],
1097
+ [preview2, gallery, session_info, status, state], api_name="replace")
1098
+ add_btn.click(add_text, [add_tb, font_dd, size_sl, color_pk, x_sl, y_sl, state],
1099
+ [preview2, gallery, session_info, status, state], api_name="add_text")
1100
+ preview2.select(on_preview_click, [state], [x_sl, y_sl])
1101
+
1102
+ # tab4
1103
+ gallery.select(gallery_select, [state],
1104
+ [preview3, sel_info, x_sl, y_sl, page_slider, state])
1105
+ rot_cw.click(rotate_pages, [gr.State(90), rot_all_cb, state],
1106
+ [preview3, gallery, session_info, status, state])
1107
+ rot_ccw.click(rotate_pages, [gr.State(-90), rot_all_cb, state],
1108
+ [preview3, gallery, session_info, status, state])
1109
+ dup_btn.click(duplicate_page, [state],
1110
+ [preview3, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1111
+ del_btn.click(delete_pages, [del_tb, state],
1112
+ [preview3, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1113
+ mv_up.click(move_page, [gr.State(-1), state],
1114
+ [preview3, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1115
+ mv_dn.click(move_page, [gr.State(1), state],
1116
+ [preview3, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1117
+ order_btn.click(reorder_pages, [order_tb, state],
1118
+ [preview3, gallery, session_info, status, x_sl, y_sl, page_slider, state])
1119
+ split_btn.click(split_pdf, [split_sl, state], [split_files, status])
1120
+ extract_btn.click(extract_pages, [extract_tb, state], [extract_file, status])
1121
+
1122
+ # tab5
1123
+ merge_btn.click(merge_files, [merge_files_in, state],
1124
+ [merge_dl, preview4, gallery, session_info, status,
1125
+ x_sl, y_sl, page_slider, state])
1126
+ wm_btn.click(add_watermark, [wm_tb, wm_size, wm_color, wm_opa, wm_ang, state],
1127
+ [preview4, gallery, session_info, status, state])
1128
+ comp_btn.click(compress_pdf, [state],
1129
+ [comp_dl, preview4, gallery, session_info, status, state])
1130
+ enc_btn.click(encrypt_pdf, [pw_tb, state],
1131
+ [prot_dl, preview4, gallery, session_info, status, state])
1132
+ dec_btn.click(decrypt_pdf, [pw_tb, state],
1133
+ [prot_dl, preview4, gallery, session_info, status, state])
1134
+ scan_form_btn.click(list_forms, [state], [form_dd, status])
1135
+ form_set_btn.click(set_form, [form_dd, form_val, state],
1136
+ [preview4, gallery, session_info, status, state])
1137
+
1138
+ return demo
1139
+
1140
+
1141
+ demo = build_demo()
1142
+
1143
+ if __name__ == "__main__":
1144
+ demo.queue(default_concurrency_limit=1).launch(
1145
+ theme=theme,
1146
+ css=CSS,
1147
+ server_name="0.0.0.0",
1148
+ server_port=int(os.environ.get("PORT", 7860)),
1149
+ )
fonts_data.py ADDED
The diff for this file is too large to render. See raw diff
 
packages.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ tesseract-ocr
2
+ tesseract-ocr-eng
3
+ tesseract-ocr-hin
4
+ tesseract-ocr-tam
5
+ tesseract-ocr-tel
6
+ tesseract-ocr-kan
7
+ tesseract-ocr-mal
8
+ tesseract-ocr-ben
9
+ tesseract-ocr-mar
10
+ tesseract-ocr-guj
11
+ tesseract-ocr-pan
12
+ tesseract-ocr-spa
13
+ tesseract-ocr-fra
14
+ tesseract-ocr-deu
15
+ tesseract-ocr-ita
16
+ tesseract-ocr-por
17
+ tesseract-ocr-rus
18
+ tesseract-ocr-ara
19
+ tesseract-ocr-chi-sim
20
+ tesseract-ocr-jpn
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ pymupdf>=1.24.0
2
+ pymupdf-fonts>=1.0.5
3
+ pytesseract>=0.3.10
4
+ pillow>=10.1.0
5
+ numpy>=1.24
6
+ gradio==6.24.0