HarshItPDF / app.py
hursheesh's picture
Upload 5 files
4578ce6 verified
Raw
History Blame Contribute Delete
35.8 kB
"""
HarshItPDF β€” HF Spaces Gradio deployment (Free Tier).
Build-time (packages.txt): installs Java 17 JDK, Tesseract, unzip, wget.
Runtime: downloads JAR, patches bytecode, starts mock license + Java backend.
Gradio UI: splash screen with JS auto-redirect to /proxy/{BACKEND_PORT}/
"""
import os
import sys
import time
import json
import shutil
import struct
import glob
import re
import threading
import subprocess
import urllib.request
import zipfile
import logging
logging.basicConfig(
level=logging.INFO,
format="[%(name)s] %(levelname)s %(message)s",
)
logger = logging.getLogger("harshitpdf")
# ═══════════════════════════════════════════════════════════════
# Configuration
# ═══════════════════════════════════════════════════════════════
PUBLIC_PORT = int(os.environ.get("PORT", "7860"))
BACKEND_PORT = int(os.environ.get("BACKEND_PORT", "7870"))
LICENCE_SERVER_PORT = int(os.environ.get("LICENCE_SERVER_PORT", "7871"))
RELEASE_TAG = "v2.11.0"
JAR_URL = (
f"https://github.com/Stirling-Tools/Stirling-PDF/releases/download/"
f"{RELEASE_TAG}/Stirling-PDF-with-login.jar"
)
JAR_PATH = "Stirling-PDF-with-login.jar"
EXTRACT_DIR = "jar-extracted"
STATIC_DIR = os.path.join(EXTRACT_DIR, "BOOT-INF", "classes", "static")
ASSETS_DIR = os.path.join(STATIC_DIR, "assets")
CLASSES_DIR = os.path.join(EXTRACT_DIR, "BOOT-INF", "classes")
LIB_DIR = os.path.join(EXTRACT_DIR, "BOOT-INF", "lib")
CONFIGS_DIR = "configs"
PATCHED_FLAG = os.path.join(EXTRACT_DIR, ".harshitpdf-patched-v9")
BRAND_REPLACEMENTS = [
("Stirling PDF", "HarshItPDF"),
("Stirling-PDF", "HarshItPDF"),
("StirlingPDF", "HarshItPDF"),
("stirling-pdf", "harshitpdf"),
("stirlingpdf", "harshitpdf"),
("Stirling Tools", "HarshIt"),
("Stirling-Tools", "HarshIt"),
("Stirling Tools ", "HarshIt "),
]
# ═══════════════════════════════════════════════════════════════
# Branding helper
# ═══════════════════════════════════════════════════════════════
def _brand(text):
for old, new in BRAND_REPLACEMENTS:
text = text.replace(old, new)
return text
# ═══════════════════════════════════════════════════════════════
# Import mock license server
# ═══════════════════════════════════════════════════════════════
SELF_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SELF_DIR)
from license_server import start_license_server
# ═══════════════════════════════════════════════════════════════
# JAR lifecycle
# ═══════════════════════════════════════════════════════════════
def _download_jar():
if os.path.exists(JAR_PATH):
return
logger.info(f"Downloading {JAR_URL} ...")
urllib.request.urlretrieve(JAR_URL, JAR_PATH)
logger.info("Download complete.")
def _extract_jar():
if os.path.exists(os.path.join(EXTRACT_DIR, "BOOT-INF")):
if not os.path.exists(PATCHED_FLAG):
logger.info("Patch version changed β€” re-extracting JAR...")
shutil.rmtree(EXTRACT_DIR, ignore_errors=True)
else:
return
logger.info("Extracting JAR...")
try:
subprocess.run(
["unzip", "-q", "-o", JAR_PATH, "-d", EXTRACT_DIR],
check=True, timeout=120,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
with zipfile.ZipFile(JAR_PATH) as zf:
zf.extractall(EXTRACT_DIR)
logger.info("Extraction complete.")
def _get_main_class():
with zipfile.ZipFile(JAR_PATH) as zf:
manifest = zf.read("META-INF/MANIFEST.MF").decode()
for line in manifest.split("\n"):
s = line.strip()
if s.startswith("Main-Class:"):
return s.split(":", 1)[1].strip()
return "org.springframework.boot.loader.launch.JarLauncher"
# ═══════════════════════════════════════════════════════════════
# Constant-pool parser (robust)
# ═══════════════════════════════════════════════════════════════
CP_TAG_SIZES = {
1: None, 2: None,
3: 4, 4: 4, 5: 8, 6: 8,
7: 2, 8: 2, 16: 2, 19: 2, 20: 2,
9: 4, 10: 4, 11: 4, 12: 4, 17: 4, 18: 4,
15: 3,
}
def _parse_constant_pool(class_data):
if len(class_data) < 10:
return [], 10
cp_count = struct.unpack(">H", class_data[8:10])[0]
idx = 10
cp = [None]
i = 1
while i < cp_count:
if idx >= len(class_data):
break
tag = class_data[idx]
idx += 1
if tag == 1 or tag == 2:
if idx + 2 > len(class_data):
break
length = struct.unpack(">H", class_data[idx:idx+2])[0]
if idx + 2 + length > len(class_data):
break
val = class_data[idx+2:idx+2+length].decode("utf-8", errors="replace")
cp.append((tag, val))
idx += 2 + length
i += 1
elif tag in (5, 6):
if idx + 8 > len(class_data):
break
cp.append((tag, None))
cp.append((tag, None))
idx += 8
i += 2
elif tag in CP_TAG_SIZES and CP_TAG_SIZES[tag] is not None:
size = CP_TAG_SIZES[tag]
if idx + size > len(class_data):
break
if tag in (7, 8, 16, 19, 20):
val = struct.unpack(">H", class_data[idx:idx+2])[0]
cp.append((tag, val))
elif tag in (9, 10, 11, 12, 17, 18):
a = struct.unpack(">H", class_data[idx:idx+2])[0]
b = struct.unpack(">H", class_data[idx+2:idx+4])[0]
cp.append((tag, (a, b)))
elif tag == 15:
cp.append((tag, (class_data[idx], struct.unpack(">H", class_data[idx+1:idx+3])[0])))
else:
cp.append((tag, None))
idx += size
i += 1
else:
cp.append((tag, None))
break
return cp, idx
def _get_method_code_ranges(class_data):
_, idx = _parse_constant_pool(class_data)
if not idx:
return []
idx += 6
if idx + 2 > len(class_data):
return []
interfaces_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2 + interfaces_count * 2
if idx + 2 > len(class_data):
return []
fields_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
for _ in range(fields_count):
if idx + 6 > len(class_data):
return []
idx += 6
attr_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
for _ in range(attr_count):
if idx + 6 > len(class_data):
return []
idx += 2
attr_len = struct.unpack(">I", class_data[idx:idx+4])[0]
idx += 4 + attr_len
if idx + 2 > len(class_data):
return []
methods_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
code_ranges = []
for _ in range(methods_count):
if idx + 6 > len(class_data):
break
idx += 6
attr_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
for _ in range(attr_count):
if idx + 6 > len(class_data):
break
idx += 2
attr_len = struct.unpack(">I", class_data[idx:idx+4])[0]
idx += 4
attr_start = idx
attr_end = idx + attr_len
if attr_end <= len(class_data) and attr_len >= 8:
code_len = struct.unpack(">I", class_data[attr_start+4:attr_start+8])[0]
code_start = attr_start + 8
code_end = code_start + code_len
if code_end <= attr_end:
code_ranges.append((code_start, code_end))
idx = attr_end
return code_ranges
def _get_method_code_info(class_data):
cp, idx = _parse_constant_pool(class_data)
if not idx:
return []
idx += 6
if idx + 2 > len(class_data):
return []
interfaces_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2 + interfaces_count * 2
if idx + 2 > len(class_data):
return []
fields_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
for _ in range(fields_count):
if idx + 6 > len(class_data):
return []
idx += 6
attr_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
for _ in range(attr_count):
if idx + 6 > len(class_data):
return []
idx += 2
attr_len = struct.unpack(">I", class_data[idx:idx+4])[0]
idx += 4 + attr_len
if idx + 2 > len(class_data):
return []
methods_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
methods = []
for _ in range(methods_count):
if idx + 6 > len(class_data):
break
name_idx = struct.unpack(">H", class_data[idx+2:idx+4])[0]
idx += 6
attr_count = struct.unpack(">H", class_data[idx:idx+2])[0]
idx += 2
name = ""
if 0 < name_idx < len(cp) and cp[name_idx] and cp[name_idx][0] == 1:
name = cp[name_idx][1]
for _ in range(attr_count):
if idx + 6 > len(class_data):
break
attr_name_idx = struct.unpack(">H", class_data[idx:idx+2])[0]
attr_len = struct.unpack(">I", class_data[idx+2:idx+6])[0]
idx += 6
attr_start = idx
attr_end = idx + attr_len
if 0 < attr_name_idx < len(cp) and cp[attr_name_idx] and cp[attr_name_idx][0] == 1:
attr_name = cp[attr_name_idx][1]
if attr_name == "Code" and attr_len >= 8:
code_len = struct.unpack(">I", class_data[attr_start+4:attr_start+8])[0]
code_start = attr_start + 8
code_end = code_start + code_len
if code_end <= attr_end:
methods.append((name, code_start, code_end))
idx = attr_end
return methods
def _find_fieldref_index(class_data, class_name_substr, field_name):
cp, _ = _parse_constant_pool(class_data)
if not cp:
return None
field_name_idx = None
for i, entry in enumerate(cp):
if entry and entry[0] == 1 and entry[1] == field_name:
field_name_idx = i
break
if not field_name_idx:
return None
nat_idx = None
for i, entry in enumerate(cp):
if entry and entry[0] == 12 and isinstance(entry[1], tuple) and entry[1][0] == field_name_idx:
nat_idx = i
break
if not nat_idx:
return None
class_idx = None
for i, entry in enumerate(cp):
if entry and entry[0] == 7 and isinstance(entry[1], int):
cn_entry = cp[entry[1]]
if cn_entry and cn_entry[0] == 1 and class_name_substr in cn_entry[1]:
class_idx = i
break
if not class_idx:
return None
for i, entry in enumerate(cp):
if entry and entry[0] == 9 and isinstance(entry[1], tuple) and entry[1][0] == class_idx and entry[1][1] == nat_idx:
return i
return None
# ═══════════════════════════════════════════════════════════════
# Bytecode patching
# ═══════════════════════════════════════════════════════════════
def _patch_method_to_return_true(class_data, class_name=""):
data = bytearray(class_data)
ranges = _get_method_code_ranges(class_data)
modified = False
for start, end in ranges:
i = start
while i < end - 1:
if data[i] == 0x03 and data[i + 1] == 0xac:
data[i] = 0x04
modified = True
i += 2
continue
if data[i] == 0x04 and data[i + 1] == 0xac:
i += 2
continue
i += 1
return modified, bytes(data)
def _patch_class_file(path):
with open(path, "rb") as fh:
data = fh.read()
modified, new_data = _patch_method_to_return_true(data, os.path.basename(path))
if modified:
with open(path, "wb") as fh:
fh.write(new_data)
return True
return False
def _is_license_class(filename):
name = filename.lower()
return any(w in name for w in [
"license", "licenseverifier", "licensechecker",
"dynamiclicense", "premiumenabled",
])
def _is_license_jar(filename):
name = filename.lower()
return "proprietary" in name or "license" in name or "ee" in name
def _patch_license_key_checker(path):
"""Patch getPremiumLicenseEnabledResult() β†’ ENTERPRISE robustly."""
with open(path, "rb") as fh:
data = bytearray(fh.read())
enterprise_idx = _find_fieldref_index(data, "License", "ENTERPRISE")
if enterprise_idx:
methods = _get_method_code_info(data)
for name, cstart, cend in methods:
if name == "getPremiumLicenseEnabledResult":
for i in range(cstart, cend - 4):
if data[i] == 0x2a and data[i+1] == 0xb4 and data[i+4] == 0xb0:
data[i] = 0xb2
data[i+1] = (enterprise_idx >> 8) & 0xff
data[i+2] = enterprise_idx & 0xff
data[i+3] = 0x00
data[i+4] = 0xb0
with open(path, "wb") as fh:
fh.write(bytes(data))
logger.info(f"Robust-patched {name} β†’ ENTERPRISE (idx={enterprise_idx})")
return True
logger.warning(f"Found {name} but no getfield+areturn pattern")
return False
logger.warning("ENTERPRISE not found in constant pool, trying hardcoded fallback...")
if len(data) > 5643:
expected = bytes([0xb4, 0x00, 0x0d, 0xb0])
if data[5638:5642] == expected:
data[5638:5643] = bytes([0xb2, 0x00, 0x4f, 0x00, 0xb0])
with open(path, "wb") as fh:
fh.write(bytes(data))
logger.info("Patched LicenseKeyChecker using hardcoded offset fallback")
return True
logger.error("Could not patch LicenseKeyChecker")
return False
def _extract_and_patch_license_classes_from_jar(jar_path):
patched_files = []
with zipfile.ZipFile(jar_path, "r") as zf:
for name in zf.namelist():
if not name.endswith(".class"):
continue
if not _is_license_class(os.path.basename(name)):
continue
data = zf.read(name)
mod1, d1 = _patch_method_to_return_true(data, os.path.basename(name))
if mod1:
dest_path = os.path.join(CLASSES_DIR, name)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
with open(dest_path, "wb") as fh:
fh.write(d1)
patched_files.append(name)
if patched_files:
logger.info(f"Extracted/patched from {os.path.basename(jar_path)}: {len(patched_files)} classes")
return len(patched_files)
def _patch_license_classes():
patched = 0
if os.path.exists(CLASSES_DIR):
for root, dirs, files in os.walk(CLASSES_DIR):
for f in files:
if not f.endswith(".class") or not _is_license_class(f):
continue
path = os.path.join(root, f)
try:
if _patch_class_file(path):
patched += 1
logger.info(f"Patched license class: {f}")
except Exception as e:
logger.warning(f"Could not patch {f}: {e}")
if os.path.exists(LIB_DIR):
for f in os.listdir(LIB_DIR):
if not f.endswith(".jar") or not _is_license_jar(f):
continue
try:
patched += _extract_and_patch_license_classes_from_jar(os.path.join(LIB_DIR, f))
except Exception as e:
logger.warning(f"Could not process {f}: {e}")
lck_path = os.path.join(
CLASSES_DIR,
"stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.class"
)
if not os.path.exists(lck_path) and os.path.exists(LIB_DIR):
for jf in os.listdir(LIB_DIR):
if "proprietary" not in jf.lower():
continue
with zipfile.ZipFile(os.path.join(LIB_DIR, jf)) as zf:
cp = "stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.class"
if cp in zf.namelist():
os.makedirs(os.path.dirname(lck_path), exist_ok=True)
with open(lck_path, "wb") as fh:
fh.write(zf.read(cp))
logger.info("Extracted LicenseKeyChecker.class for patching")
break
if os.path.exists(lck_path):
try:
if _patch_license_key_checker(lck_path):
patched += 1
except Exception as e:
logger.warning(f"Could not patch LicenseKeyChecker: {e}")
else:
logger.warning("LicenseKeyChecker.class not found")
if patched:
logger.info(f"Patched {patched} license verifier classes total.")
# ═══════════════════════════════════════════════════════════════
# HTML / JS / SVG / CSS patching
# ═══════════════════════════════════════════════════════════════
def _patch_html():
count = 0
if not os.path.exists(STATIC_DIR):
return
for root, dirs, files in os.walk(STATIC_DIR):
for f in files:
if not f.endswith(".html"):
continue
path = os.path.join(root, f)
with open(path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
content = _brand(content)
content = content.replace(
"The Free Adobe Acrobat alternative (10M+ Downloads)",
"Your private PDF powerhouse by HarshIt",
)
css_link = '<link rel="stylesheet" href="/harshitpdf-liquidglass.css">'
if "harshitpdf-liquidglass.css" not in content:
content = content.replace("</head>", css_link + "</head>")
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
count += 1
logger.info(f"Patched {count} HTML files.")
def _patch_js():
count = 0
if not os.path.exists(ASSETS_DIR):
return
for f in os.listdir(ASSETS_DIR):
if not f.endswith(".js"):
continue
path = os.path.join(ASSETS_DIR, f)
with open(path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
orig = content
content = _brand(content)
content = re.sub(
r'requiresPremium\s*[=:]=?\s*(?:!0|!1|true|false|null)',
'requiresPremium=!1',
content,
)
content = re.sub(
r'["\'](?:Premium feature[:\s].*?|Enterprise only[:\s].*?|Enterprise Seats|Upgrade to Server Plan)["\']',
'""', content,
)
content = re.sub(
r'premiumEnabled\s*[=:]=?\s*[se][?\w]+\.premiumEnabled\?\??[!\d]+',
'premiumEnabled:!0', content,
)
if content != orig:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
count += 1
logger.info(f"Patched {count} JS files.")
def _write_svgs():
LOGO_SVGS = [
(
"StirlingPDFLogoBlackText.svg",
'<svg width="140" height="26" viewBox="0 0 140 26" fill="none" '
'xmlns="http://www.w3.org/2000/svg">'
'<path d="M3 2h12l4 4v18a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" '
'fill="#6366f1" opacity="0.9"/>'
'<path d="M15 2l4 4h-4V2z" fill="#818cf8"/>'
'<text x="6" y="17" font-family="system-ui,sans-serif" font-size="7" '
'font-weight="700" fill="#fff">PDF</text>'
'<text x="24" y="18" font-family="system-ui,-apple-system,sans-serif" '
'font-size="14" font-weight="700" fill="#1a1a2e" letter-spacing="-0.3">'
'HarshIt<tspan fill="#6366f1">PDF</tspan></text></svg>',
),
(
"favicon.svg",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
'<stop offset="0" stop-color="#6366f1"/>'
'<stop offset="1" stop-color="#a855f7"/>'
"</linearGradient></defs>"
'<rect width="512" height="512" rx="100" fill="url(#g)"/>'
'<path d="M140 120h160l60 60v200a20 20 0 0 1-20 20H140a20 20 0 0 1-20-20'
'V140a20 20 0 0 1 20-20z" fill="#fff" opacity="0.95"/>'
'<path d="M300 120l60 60h-60V120z" fill="#ddd" opacity="0.8"/>'
'<text x="170" y="280" font-family="system-ui,sans-serif" font-size="80" '
'font-weight="800" fill="#6366f1">PDF</text></svg>',
),
]
logo_dir = os.path.join(STATIC_DIR, "modern-logo")
os.makedirs(logo_dir, exist_ok=True)
for name, svg in LOGO_SVGS:
with open(os.path.join(logo_dir, name), "w") as fh:
fh.write(svg)
logger.info("Wrote SVGs.")
def _write_manifests():
manifest = {
"short_name": "HarshItPDF",
"name": "HarshItPDF",
"icons": [],
"start_url": ".",
"display": "standalone",
"theme_color": "#0a0a1a",
"background_color": "#0a0a1a",
}
for name in ("manifest.json", "manifest-classic.json"):
path = os.path.join(STATIC_DIR, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
json.dump(manifest, fh, indent=2)
def _write_css():
candidates = glob.glob(os.path.join(SELF_DIR, "liquidglass*.css"))
css_src = candidates[0] if candidates else None
css_dst = os.path.join(STATIC_DIR, "harshitpdf-liquidglass.css")
if css_src and os.path.exists(css_src):
shutil.copy(css_src, css_dst)
logger.info(f"Copied CSS: {css_src} -> {css_dst}")
else:
with open(css_dst, "w") as fh:
fh.write("/* HarshItPDF Liquid Glass fallback */\n")
def _patch_settings_template():
template_path = os.path.join(CLASSES_DIR, "settings.yml.template")
if not os.path.exists(template_path):
logger.warning("settings.yml.template not found.")
return
with open(template_path, "r") as fh:
content = fh.read()
content = _brand(content)
with open(template_path, "w") as fh:
fh.write(content)
logger.info("Patched settings.yml.template.")
def _write_settings_yml():
os.makedirs(CONFIGS_DIR, exist_ok=True)
settings_path = os.path.join(CONFIGS_DIR, "settings.yml")
template_path = os.path.join(CLASSES_DIR, "settings.yml.template")
if os.path.exists(template_path):
with open(template_path, "r") as fh:
content = fh.read()
else:
content = """# HarshItPDF Settings
security:
enableLogin: true
initialLogin:
username: admin
password: changeme123
ui:
appName: HarshItPDF
appNavbarName: HarshItPDF
homeDescription: Your private PDF powerhouse by HarshIt
system:
showUpdate: false
rootUriPath: ""
defaultLocale: en-US
premium:
enabled: false
enterpriseEdition:
enabled: true
maxUsers: 999
endpoints:
groupsToRemove: []
"""
content = _brand(content)
with open(settings_path, "w") as fh:
fh.write(content)
logger.info(f"Wrote settings.yml ({len(content)} chars).")
def _patch_banner():
banner_path = os.path.join(CLASSES_DIR, "banner.txt")
os.makedirs(os.path.dirname(banner_path), exist_ok=True)
with open(banner_path, "w") as fh:
fh.write(r""" _ __ _ _ ___ ___ ___ ___
| |/ /__ _ _ __| |_ |_ _| _ \ _ _| __| _ \
| ' // _` | '_ \ ' \ | || _/| || _|| /
| . \ (_| | | | | || | | || | | |_| |_| _|
|_|\_\__,_|_| |_|
|__/
Powered by Spring Boot ${spring-boot.version}
""")
def _patch_meta():
for name in ("og-metadata.json",):
path = os.path.join(STATIC_DIR, name)
if os.path.exists(path):
with open(path, "r") as fh:
content = fh.read()
with open(path, "w") as fh:
fh.write(_brand(content))
def _fix_python_path():
venv_dir = "/opt/venv"
venv_bin = os.path.join(venv_dir, "bin")
venv_python = os.path.join(venv_bin, "python3")
if not os.path.exists(venv_dir):
os.makedirs(venv_bin, exist_ok=True)
system_python = shutil.which("python3")
if system_python and not os.path.exists(venv_python):
try:
os.symlink(system_python, venv_python)
except Exception as e:
logger.warning(f"Could not create python3 symlink: {e}")
def _install_opencv():
try:
import cv2
logger.info(f"OpenCV available: {cv2.__version__}")
except ImportError:
logger.info("OpenCV not found, installing...")
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "--quiet", "opencv-python-headless"],
check=False, timeout=60,
)
import cv2
logger.info(f"OpenCV installed: {cv2.__version__}")
except Exception as e:
logger.warning(f"Could not install OpenCV: {e}")
def _compile_license_override():
java_src = """package stirling.software.SPDF.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@Configuration
public class HarshItLicenseOverride {
@Bean
@ConditionalOnMissingBean(name = "runningProOrHigher")
@Qualifier("runningProOrHigher")
public boolean runningProOrHigher() {
return true;
}
}
"""
src_path = os.path.join(EXTRACT_DIR, "HarshItLicenseOverride.java")
with open(src_path, "w") as fh:
fh.write(java_src)
lib_dir = os.path.join(EXTRACT_DIR, "BOOT-INF", "lib")
if not os.path.exists(lib_dir):
logger.warning("BOOT-INF/lib not found, skipping license override compile")
return False
jars = [os.path.join(lib_dir, j) for j in os.listdir(lib_dir) if j.endswith(".jar")]
classpath = ":".join(jars)
dest_dir = os.path.join(EXTRACT_DIR, "BOOT-INF", "classes")
try:
result = subprocess.run(
["javac", "-cp", classpath, "-d", dest_dir, src_path],
capture_output=True, text=True, timeout=60
)
if result.returncode == 0:
logger.info("Compiled HarshItLicenseOverride.class")
return True
else:
logger.warning(f"javac failed: {result.stderr[:200]}")
return False
except Exception as e:
logger.warning(f"Could not compile override: {e}")
return False
def _patch_files():
if os.path.exists(PATCHED_FLAG):
return
logger.info("Applying HarshItPDF patches...")
_patch_html()
_patch_js()
_write_svgs()
_write_manifests()
_write_css()
_patch_settings_template()
_write_settings_yml()
_patch_banner()
_patch_meta()
_fix_python_path()
_install_opencv()
_patch_license_classes()
_compile_license_override()
with open(PATCHED_FLAG, "w") as fh:
fh.write(str(time.time()))
logger.info("All patches applied.")
# ═══════════════════════════════════════════════════════════════
# Backend lifecycle
# ═══════════════════════════════════════════════════════════════
_backend_process = None
_backend_ready = threading.Event()
def _stream_output(pipe):
try:
for line in pipe:
print(line, end="")
if "Started SPDFApplication" in line:
_backend_ready.set()
except Exception as e:
logger.warning(f"Backend stream error: {e}")
def _find_tessdata():
candidates = [
"/usr/share/tesseract-ocr/5/tessdata/",
"/usr/share/tesseract-ocr/4.00/tessdata/",
"/usr/share/tesseract-ocr/4/tessdata/",
"/usr/share/tessdata/",
]
for c in candidates:
if os.path.exists(c):
return c
return ""
def start_backend():
logger.info("Launching HarshItPDF...")
main_class = _get_main_class()
env = os.environ.copy()
env["SERVER_PORT"] = str(BACKEND_PORT)
env["SYSTEM_ROOTURIPATH"] = ""
env["SECURITY_ENABLELOGIN"] = "true"
env["SECURITY_INITIALLOGIN_USERNAME"] = "admin"
env["SECURITY_INITIALLOGIN_PASSWORD"] = "changeme123"
env["UI_APPNAME"] = "HarshItPDF"
env["UI_APPNAVBARNAME"] = "HarshItPDF"
env["UI_HOMEDESCRIPTION"] = "Your private PDF powerhouse by HarshIt"
env["SYSTEM_SHOWUPDATE"] = "false"
env["PREMIUM_ENABLED"] = "true"
env["PREMIUM_KEY"] = "harshitpdf-unlocked-0000-0000-0000-000000000001"
env["DISABLE_ADDITIONAL_FEATURES"] = "false"
env["PYTHON_PATH"] = shutil.which("python3") or "/usr/bin/python3"
tessdata = _find_tessdata()
if tessdata:
env["TESSDATA_PREFIX"] = tessdata
logger.info(f"Launching Java backend on internal port {BACKEND_PORT}...")
global _backend_process
_backend_process = subprocess.Popen(
[
"java",
"-Xmx2G",
"-XX:+UseSerialGC",
"-XX:MaxRAMPercentage=60.0",
"-cp", EXTRACT_DIR,
main_class,
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
threading.Thread(target=_stream_output, args=(_backend_process.stdout,), daemon=True).start()
time.sleep(2)
def check_backend():
try:
resp = urllib.request.urlopen(f"http://127.0.0.1:{BACKEND_PORT}/login", timeout=3)
return resp.status == 200
except Exception:
return False
# ═══════════════════════════════════════════════════════════════
# Service orchestrator (runs at module import on HF Spaces)
# ═══════════════════════════════════════════════════════════════
def _start_services():
logger.info("=" * 60)
logger.info("HarshItPDF Starting...")
logger.info("=" * 60)
_download_jar()
_extract_jar()
_patch_files()
logger.info(f"Starting mock license server on port {LICENCE_SERVER_PORT}...")
lic_thread = threading.Thread(target=start_license_server, daemon=True)
lic_thread.start()
time.sleep(0.5)
start_backend()
logger.info("Waiting for backend to be ready...")
_backend_ready.wait(timeout=120)
if check_backend():
logger.info(f"HarshItPDF backend is live on port {BACKEND_PORT}")
else:
logger.warning("Backend health check failed β€” app may still be starting")
# Keep-alive monitor
while True:
if _backend_process and _backend_process.poll() is not None:
logger.error("Java backend exited! Restarting...")
_backend_ready.clear()
start_backend()
_backend_ready.wait(timeout=120)
time.sleep(10)
# ═══════════════════════════════════════════════════════════════
# Gradio UI
# ═══════════════════════════════════════════════════════════════
import gradio as gr
# ZeroGPU guard (required on GPU-backed spaces, harmless on CPU)
try:
import spaces
@spaces.GPU(duration=1)
def _gpuguard():
pass
except ImportError:
pass
def build_gradio_ui():
proxy_url = f"/proxy/{BACKEND_PORT}/"
with gr.Blocks(title="HarshItPDF", theme=gr.themes.Base()) as demo:
gr.HTML(f"""
<script>
(function(){{
const check = () => {{
fetch("{proxy_url}login", {{mode: "no-cors"}})
.then(() => window.location.href = "{proxy_url}")
.catch(() => setTimeout(check, 3000));
}};
setTimeout(check, 2000);
}})();
</script>
<div style="text-align:center;padding-top:20vh;color:#e0e0ff;background:#0f0f23;min-height:100vh;font-family:sans-serif;">
<h1 style="font-size:2.5rem;background:linear-gradient(135deg,#6366f1,#a855f7);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">HarshItPDF</h1>
<p style="opacity:0.7;">Your private PDF powerhouse</p>
<div style="width:40px;height:40px;border:3px solid rgba(99,102,241,0.3);border-top-color:#6366f1;border-radius:50%;animation:spin 1s linear infinite;margin:1rem auto;"></div>
<p style="font-family:monospace;font-size:0.9rem;opacity:0.6;margin-top:1rem;">Starting backend... This may take 1–2 min on cold start.</p>
<p style="font-size:0.85rem;opacity:0.5;margin-top:0.5rem;">Default login: admin / changeme123</p>
<style>@keyframes spin{{to{{transform:rotate(360deg)}}}}</style>
</div>
""")
return demo
# Start services in background thread when module is imported
_svc_thread = threading.Thread(target=_start_services, daemon=True)
_svc_thread.start()
# HF Spaces Gradio SDK requires `demo` at module scope
demo = build_gradio_ui()