hoangthiencm commited on
Commit
86333ac
·
verified ·
1 Parent(s): e88455c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +841 -294
app.py CHANGED
@@ -1,88 +1,85 @@
1
  """
2
- Backend API cho HT_MATH_WEB - Phiên bản Firebase (Firestore) + BYOK (Bring Your Own Key)
3
- Chạy trên Hugging Face Spaces (Docker Version)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
7
  import os
8
  import io
9
- import time
10
- import asyncio
11
- import re
12
- import tempfile
13
- import hashlib
14
- import secrets
15
- import uuid
16
  import json
17
  import base64
18
- import random
 
 
 
 
19
  from typing import List, Optional
20
 
21
- from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request
22
  from fastapi.middleware.cors import CORSMiddleware
23
- from fastapi.responses import JSONResponse, FileResponse
24
- from fastapi.staticfiles import StaticFiles
25
- from PIL import Image
26
  import fitz # PyMuPDF
 
27
  import google.generativeai as genai
28
 
29
- # --- PANDOC IMPORT ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  try:
31
  import pypandoc
32
- print(f"INFO: Pandoc version detected: {pypandoc.get_pandoc_version()}")
 
33
  except ImportError:
34
- print("CRITICAL WARNING: pypandoc module not found.")
35
- except OSError:
36
- print("CRITICAL WARNING: pandoc binary not found in system path.")
 
 
 
37
 
38
- # --- TESSERACT IMPORT ---
39
  try:
40
- import pytesseract
41
- print("INFO: Tesseract OCR module loaded.")
42
  except ImportError:
43
- print("WARNING: pytesseract not found. Fallback OCR will not work.")
44
- pytesseract = None
 
45
 
46
- # --- FIREBASE ADMIN SDK ---
47
- import firebase_admin
48
- from firebase_admin import credentials, firestore
49
 
50
  # ===== CẤU HÌNH =====
 
51
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
52
- MAX_THREADS = int(os.getenv("MAX_THREADS", "5"))
53
-
54
- # --- KẾT NỐI FIREBASE ---
55
- db = None
56
-
57
- try:
58
- if not firebase_admin._apps:
59
- cred = None
60
- firebase_env = os.getenv("FIREBASE_CREDENTIALS")
61
-
62
- if firebase_env:
63
- try:
64
- json_info = json.loads(base64.b64decode(firebase_env))
65
- except:
66
- json_info = json.loads(firebase_env)
67
- cred = credentials.Certificate(json_info)
68
- print("INFO: Loaded Firebase credentials from Environment.")
69
- elif os.path.exists("firebase_key.json"):
70
- cred = credentials.Certificate("firebase_key.json")
71
- print("INFO: Loaded Firebase credentials from 'firebase_key.json'.")
72
-
73
- if cred:
74
- firebase_admin.initialize_app(cred)
75
- db = firestore.client()
76
- print("SUCCESS: Connected to Firebase Firestore.")
77
- else:
78
- print("WARNING: No Firebase credentials found. Database features will fail.")
79
- else:
80
- db = firestore.client()
81
- except Exception as e:
82
- print(f"ERROR: Firebase Init Failed: {e}")
83
-
84
 
85
- app = FastAPI(title="HT_MATH_WEB API (Firebase + BYOK)", version="10.2")
86
 
87
  app.add_middleware(
88
  CORSMiddleware,
@@ -92,46 +89,175 @@ app.add_middleware(
92
  allow_headers=["*"],
93
  )
94
 
95
- # --- SETUP STATIC FILES ---
96
- os.makedirs("uploads", exist_ok=True)
97
- app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
98
-
99
- # ===== KEY HELPER (BYOK) =====
100
- def get_random_key_from_request(api_keys_str: str) -> Optional[str]:
101
- """Lấy 1 key ngẫu nhiên từ chuỗi key user gửi lên"""
102
- if not api_keys_str:
103
- return None
104
- keys = [k.strip() for k in re.split(r'[,\n]', api_keys_str) if k.strip()]
105
- if not keys:
106
- return None
107
- return random.choice(keys)
108
-
109
  ip_rate_limits = {}
110
- RATE_LIMIT_DURATION = 2
111
 
112
  def check_rate_limit(request: Request):
 
 
 
113
  forwarded = request.headers.get("X-Forwarded-For")
114
- client_ip = forwarded.split(",")[0].strip() if forwarded else request.client.host
 
 
 
 
115
  now = time.time()
116
  if client_ip in ip_rate_limits:
117
  elapsed = now - ip_rate_limits[client_ip]
118
  if elapsed < RATE_LIMIT_DURATION:
119
  print(f"[RateLimit] IP {client_ip} requesting too fast.")
 
120
  ip_rate_limits[client_ip] = now
121
 
122
- # ===== PROMPTS =====
123
- DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""Đóng vai một CÔNG CỤ OCR CHUYÊN DỤNG cho văn bản hành chính Việt Nam.
124
- NHIỆM VỤ: Trích xuất nguyên văn (Verbatim) nội dung trong ảnh.
125
- QUY TẮC: KHÔNG thêm lời dẫn, KHÔNG nhận xét. Giữ nguyên văn cấu trúc."""
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- DIRECT_GEMINI_PROMPT_LATEX = r"""Đóng vai công cụ số hóa tài liệu Toán học chính xác tuyệt đối.
128
- NHIỆM VỤ: Chuyển đổi ảnh sang Markdown + LaTeX.
129
- QUY TẮC: Công thức toán nằm trong `$`. KHÔNG thêm lời dẫn. Chỉ trả về Markdown."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  # ===== HELPER FUNCTIONS =====
132
  def clean_latex_formulas(text: str) -> str:
133
- text = re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
134
- return text
135
 
136
  def hash_password(password: str) -> str:
137
  return hashlib.sha256(password.encode()).hexdigest()
@@ -139,277 +265,698 @@ def hash_password(password: str) -> str:
139
  def verify_password(password: str, hashed: str) -> bool:
140
  return hash_password(password) == hashed
141
 
142
- def safe_get_text(response) -> str:
143
- if not response.candidates: return ""
144
- candidate = response.candidates[0]
145
- if candidate.finish_reason == 4: return "[BLOCKED_BY_COPYRIGHT]"
146
- parts = candidate.content.parts
147
- texts = [p.text for p in parts if hasattr(p, "text")]
148
- return "\n".join(texts)
149
-
150
- def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
151
- if not text_a: return text_b
152
- if not text_b: return text_a
153
- a_lines = text_a.splitlines()
154
- b_lines = text_b.splitlines()
155
- scan_window = min(len(a_lines), len(b_lines), 30)
156
- best_overlap_idx = 0
157
- for i in range(scan_window, 0, -1):
158
- tail_a = "\n".join(a_lines[-i:]).strip()
159
- head_b = "\n".join(b_lines[:i]).strip()
160
- if len(tail_a) >= min_overlap_chars and tail_a == head_b:
161
- best_overlap_idx = i
162
- break
163
- if best_overlap_idx > 0:
164
- return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
165
- else:
166
- return text_a + "\n\n" + text_b
167
-
168
- async def fallback_ocr_tesseract(image: Image.Image) -> str:
169
- if pytesseract is None: return "**[Lỗi] Gemini từ chối (Bản quyền) & No Tesseract.**"
170
- try:
171
- loop = asyncio.get_running_loop()
172
- text = await loop.run_in_executor(None, lambda: pytesseract.image_to_string(image, lang='vie+eng'))
173
- return f"**[OCR Fallback]**\n\n{text}"
174
- except Exception as e: return "**[Lỗi OCR Fallback]**"
175
-
176
  # ===== API ENDPOINTS =====
177
 
178
  @app.get("/")
179
  @app.get("/health")
180
- @app.head("/") # FIX: Cho phép UptimeRobot ping HEAD
181
- @app.head("/health") # FIX: Cho phép UptimeRobot ping HEAD
182
  async def root():
183
  return {
184
  "status": "ok",
185
- "service": "HT_MATH_WEB (Firebase Backend + BYOK)",
186
- "database": "Firebase Firestore" if db else "Disconnected",
187
- "mode": "Bring Your Own Key"
188
  }
189
 
190
  @app.get("/api/models")
191
  async def get_models():
192
  return {"models": GEMINI_MODELS}
193
 
194
- # --- AUTH API (FIREBASE) ---
195
  @app.post("/api/register")
196
  async def register(email: str = Form(...), password: str = Form(...)):
197
- if not db: raise HTTPException(status_code=503, detail="Database chưa cấu hình")
 
 
198
 
199
- users_ref = db.collection('users')
200
- query = users_ref.where('email', '==', email).stream()
201
- if any(query):
202
- raise HTTPException(status_code=400, detail="Email này đã tồn tại.")
203
-
204
- new_user = {
205
  "email": email,
206
  "password": hash_password(password),
207
- "status": "pending",
208
- "role": "user",
209
- "created_at": firestore.SERVER_TIMESTAMP
210
  }
211
- db.collection('users').add(new_user)
212
- return {"success": True, "message": "Đăng ký thành công! Vui lòng chờ Admin duyệt."}
213
 
214
  @app.post("/api/login")
215
  async def login(request: Request, email: str = Form(...), password: str = Form(...)):
216
- if not db: raise HTTPException(status_code=503, detail="Database chưa cấu hình")
217
-
218
- users_ref = db.collection('users')
219
- query = users_ref.where('email', '==', email).limit(1).stream()
220
 
221
- user_doc = None
222
- for doc in query:
223
- user_doc = doc
224
- break
225
 
226
- if not user_doc:
227
- raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không đúng")
228
 
229
- user_data = user_doc.to_dict()
230
 
231
- if not verify_password(password, user_data.get("password", "")):
232
- raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không đúng")
233
 
234
- if user_data.get("status") != "active":
235
- raise HTTPException(status_code=403, detail="Tài khoản chưa được kích hoạt.")
236
-
237
- token = secrets.token_urlsafe(32)
 
 
 
 
 
238
 
239
- sessions_ref = db.collection('sessions')
240
- old_sessions = sessions_ref.where('email', '==', email).stream()
241
- for s in old_sessions:
242
- s.reference.delete()
243
-
244
- session_data = {
245
- "email": email,
246
- "token": token,
247
- "last_seen": firestore.SERVER_TIMESTAMP
248
- }
249
- db.collection('sessions').add(session_data)
250
-
251
  return {"success": True, "token": token, "email": email}
252
 
253
  @app.post("/api/check-session")
254
  async def check_session(email: str = Form(...), token: str = Form(...)):
255
- if not db: raise HTTPException(status_code=503, detail="Database Err")
256
- sessions_ref = db.collection('sessions')
257
- query = sessions_ref.where('email', '==', email).where('token', '==', token).limit(1).stream()
258
- valid = False
259
- for doc in query:
260
- valid = True
261
- doc.reference.update({"last_seen": firestore.SERVER_TIMESTAMP})
262
- break
263
- if not valid: raise HTTPException(status_code=401, detail="Session expired")
264
- return {"status": "valid"}
 
 
 
 
 
 
 
 
 
 
 
265
 
266
  @app.post("/api/logout")
267
  async def logout(request: Request):
268
- if not db: return {"status": "error"}
269
  try:
270
  data = await request.json()
271
  email = data.get("email")
272
- if email:
273
- sessions = db.collection('sessions').where('email', '==', email).stream()
274
- for s in sessions: s.reference.delete()
275
- except: pass
276
- return {"status": "success"}
277
-
278
- # --- ADMIN API ---
279
- @app.get("/admin/users")
280
- async def admin_get_users(request: Request):
281
- admin_key = request.headers.get("key")
282
- ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
283
- if admin_key != ADMIN_SECRET_KEY: raise HTTPException(status_code=401)
284
- if not db: return {"users": []}
285
- users = []
286
- docs = db.collection('users').stream()
287
- for doc in docs:
288
- u = doc.to_dict()
289
- created = u.get("created_at")
290
- if created: u["created_at"] = created.strftime("%Y-%m-%d %H:%M:%S")
291
- users.append(u)
292
- return {"users": users}
293
-
294
- @app.post("/admin/approve")
295
- async def admin_approve(request: Request):
296
- data = await request.json()
297
- email = data.get("email")
298
- admin_key = data.get("admin_key")
299
- ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
300
- if admin_key != ADMIN_SECRET_KEY: raise HTTPException(status_code=401)
301
- docs = db.collection('users').where('email', '==', email).stream()
302
- for doc in docs:
303
- doc.reference.update({"status": "active"})
304
- return {"success": True}
305
- raise HTTPException(status_code=404)
306
-
307
- @app.post("/admin/delete")
308
- async def admin_delete(request: Request):
309
- data = await request.json()
310
- email = data.get("email")
311
- admin_key = data.get("admin_key")
312
- ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
313
- if admin_key != ADMIN_SECRET_KEY: raise HTTPException(status_code=401)
314
- docs = db.collection('users').where('email', '==', email).stream()
315
- for doc in docs: doc.reference.delete()
316
- sessions = db.collection('sessions').where('email', '==', email).stream()
317
- for s in sessions: s.reference.delete()
318
- return {"success": True}
319
-
320
- @app.post("/api/upload-image")
321
- async def upload_image(file: UploadFile = File(...)):
322
- try:
323
- file_ext = os.path.splitext(file.filename)[1] or ".png"
324
- file_name = f"{uuid.uuid4().hex}{file_ext}"
325
- file_path = f"uploads/{file_name}"
326
- with open(file_path, "wb") as f: f.write(await file.read())
327
- return {"url": file_path}
328
- except Exception as e: raise HTTPException(status_code=500, detail=str(e))
329
-
330
- # --- CORE CONVERT LOGIC (BYOK) ---
331
-
332
- async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, user_api_keys: str) -> str:
333
- """Xử lý ảnh dùng key của user"""
334
- try:
335
- api_key = get_random_key_from_request(user_api_keys)
336
- if not api_key:
337
- return "**[Lỗi] Bạn chưa nhập API Key. Vui lòng nhập key trong phần cấu hình.**"
338
-
339
- genai.configure(api_key=api_key)
340
- model = genai.GenerativeModel(model_id)
341
- response = model.generate_content([prompt, image])
342
- text = safe_get_text(response)
343
-
344
- if text == "[BLOCKED_BY_COPYRIGHT]":
345
- return await fallback_ocr_tesseract(image)
346
 
347
- return text.strip() if text else ""
348
- except Exception as e:
349
- print(f"Gemini Error (User Key): {e}")
350
- if "403" in str(e) or "400" in str(e) or "API_KEY_INVALID" in str(e):
351
- return "**[Lỗi API Key] Key của bạn không hợp lệ hoặc đã hết hạn ngạch (Quota).**"
352
- if "429" in str(e):
353
- return "**[Lỗi Quota] Key của bạn đang bị giới hạn tốc độ (Rate Limit).**"
354
-
355
- return await fallback_ocr_tesseract(image)
356
-
357
- async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore, user_api_keys: str) -> str:
358
- width, height = image.size
359
- if height <= 2000:
360
- async with semaphore: return await process_image_with_gemini(image, model, prompt, user_api_keys)
361
-
362
- top = image.crop((0, 0, width, height // 2 + 200))
363
- bottom = image.crop((0, height // 2 - 200, width, height))
364
-
365
- async with semaphore:
366
- t1 = await process_image_with_gemini(top, model, prompt, user_api_keys)
367
- t2 = await process_image_with_gemini(bottom, model, prompt, user_api_keys)
368
- return stitch_text(t1, t2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
  @app.post("/api/convert")
371
  async def convert_file(
372
  request: Request,
373
  file: UploadFile = File(...),
374
  model: str = Form("gemini-2.5-flash"),
375
- mode: str = Form("latex"),
376
- api_keys: str = Form(...)
377
  ):
378
  check_rate_limit(request)
379
 
380
- if not api_keys or not api_keys.strip():
381
- raise HTTPException(status_code=400, detail="Vui lòng cung cấp API Key (User Key)")
382
-
383
  prompt = DIRECT_GEMINI_PROMPT_LATEX if mode == "latex" else DIRECT_GEMINI_PROMPT_TEXT_ONLY
384
 
385
  try:
386
  file_content = await file.read()
387
  file_ext = os.path.splitext(file.filename)[1].lower()
388
- sem = asyncio.Semaphore(MAX_THREADS)
389
-
390
  results = []
 
391
  if file_ext == ".pdf":
392
  doc = fitz.open(stream=file_content, filetype="pdf")
393
- for i in range(len(doc)):
394
- pix = doc[i].get_pixmap(dpi=200)
395
- img = Image.open(io.BytesIO(pix.tobytes("png")))
396
- results.append(await process_large_image(img, model, prompt, sem, api_keys))
397
- else:
398
- img = Image.open(io.BytesIO(file_content))
399
- results.append(await process_large_image(img, model, prompt, sem, api_keys))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
 
401
- return {"success": True, "result": clean_latex_formulas("\n\n".join(results))}
 
 
 
 
 
 
 
 
 
 
 
402
  except Exception as e:
403
  raise HTTPException(status_code=500, detail=str(e))
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  @app.post("/api/export-docx")
406
- async def export_docx(markdown_text: str = Form(...)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  try:
408
- with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
409
- pypandoc.convert_text(markdown_text, to='docx', format='markdown', outputfile=tmp.name, extra_args=['--standalone'])
410
- return FileResponse(tmp.name, filename="Result.docx")
411
- except: raise HTTPException(status_code=500, detail="Export Error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
413
  if __name__ == "__main__":
414
  import uvicorn
415
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  """
2
+ Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces
3
+ Phiên bản: 6.7 (Real Word Equation Support)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
7
  import os
8
  import io
 
 
 
 
 
 
 
9
  import json
10
  import base64
11
+ import tempfile
12
+ import time
13
+ import re
14
+ import asyncio
15
+ import xml.etree.ElementTree as ET
16
  from typing import List, Optional
17
 
18
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Form, Request
19
  from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
21
+ from pydantic import BaseModel
22
+
23
  import fitz # PyMuPDF
24
+ from PIL import Image
25
  import google.generativeai as genai
26
 
27
+ # Thư viện Word & Math
28
+ from docx import Document
29
+ from docx.shared import Cm, Pt, RGBColor
30
+ from docx.enum.text import WD_LINE_SPACING
31
+ from docx.oxml import OxmlElement
32
+ from docx.oxml.ns import qn
33
+
34
+ # Thư viện Excel
35
+ from openpyxl import Workbook
36
+ from openpyxl.styles import Font, Alignment, Border, Side
37
+
38
+ # Import thư viện chuyển đổi LaTeX sang MathML
39
+ # Ưu tiên pypandoc (mạnh hơn), fallback về latex2mathml
40
+ latex_to_mathml = None
41
+ pypandoc_available = False
42
+
43
  try:
44
  import pypandoc
45
+ pypandoc_available = True
46
+ print("Info: pypandoc available - using for LaTeX to MathML conversion")
47
  except ImportError:
48
+ try:
49
+ from latex2mathml.converter import convert as latex_to_mathml
50
+ print("Info: latex2mathml available - using for LaTeX to MathML conversion")
51
+ except ImportError:
52
+ print("Warning: Neither pypandoc nor latex2mathml found. Word export might fail for equations.")
53
+ latex_to_mathml = None
54
 
 
55
  try:
56
+ from supabase import create_client, Client
57
+ SUPABASE_AVAILABLE = True
58
  except ImportError:
59
+ SUPABASE_AVAILABLE = False
60
+ Client = None
61
+ create_client = None
62
 
63
+ import hashlib
64
+ import secrets
 
65
 
66
  # ===== CẤU HÌNH =====
67
+ GEMINI_API_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
68
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
69
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
70
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
71
+ MAX_THREADS = int(os.getenv("MAX_THREADS", "3"))
72
+ ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
73
+
74
+ # Setup Supabase
75
+ supabase = None
76
+ if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
77
+ try:
78
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
79
+ except Exception as e:
80
+ print(f"Warning: Không thể kết nối Supabase: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
+ app = FastAPI(title="HT_MATH_WEB API", version="6.7")
83
 
84
  app.add_middleware(
85
  CORSMiddleware,
 
89
  allow_headers=["*"],
90
  )
91
 
92
+ @app.exception_handler(404)
93
+ async def not_found_handler(request, exc):
94
+ return JSONResponse(
95
+ status_code=404,
96
+ content={
97
+ "detail": f"Route not found: {request.url.path}",
98
+ "available_routes": ["/", "/api/models", "/api/convert", "/api/export-docx", "/api/login", "/api/check-session"]
99
+ }
100
+ )
101
+
102
+ # ===== RATE LIMITING (Backend) =====
 
 
 
103
  ip_rate_limits = {}
104
+ RATE_LIMIT_DURATION = 7 # giây
105
 
106
  def check_rate_limit(request: Request):
107
+ # Nếu muốn tắt rate limit để tránh lỗi trên Cloud
108
+ # return
109
+
110
  forwarded = request.headers.get("X-Forwarded-For")
111
+ if forwarded:
112
+ client_ip = forwarded.split(",")[0].strip()
113
+ else:
114
+ client_ip = request.client.host
115
+
116
  now = time.time()
117
  if client_ip in ip_rate_limits:
118
  elapsed = now - ip_rate_limits[client_ip]
119
  if elapsed < RATE_LIMIT_DURATION:
120
  print(f"[RateLimit] IP {client_ip} requesting too fast.")
121
+
122
  ip_rate_limits[client_ip] = now
123
 
124
+ # ===== PROMPTS (Strict Mode) =====
125
+ DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""**TRÍCH XUẤT VĂN BẢN THUẦN TÚY**
126
+ Bạn một chuyên gia trong việc trích xuất nội dung văn bản từ ảnh và PDF.
127
+ NHIỆM VỤ CỦA BẠN:
128
+ 1. **Trích xuất toàn bộ văn bản** từ hình ảnh/PDF được cung cấp.
129
+ 2. **Giữ nguyên định dạng văn bản gốc**, bao gồm các ký tự toán học, mà **KHÔNG** chuyển đổi chúng sang LaTeX. Ví dụ, biểu thức `x^2 + y^2 = r^2` phải được giữ nguyên, không đổi thành `$x^2 + y^2 = r^2$`.
130
+ 3. **Chuyển đổi sang định dạng Markdown** cơ bản cho tiêu đề và bảng biểu.
131
+ 4. Các đề mục, tiêu đề, Định nghĩa, Ví dụ, Bài tập, trắc nghiệm, Lưu ý đều in đậm (dùng markdown `**text**`).
132
+ 5. Không trích xuất Header, Footer, hoặc số trang.
133
+ 6. Giữ nguyên cấu trúc đoạn văn, bảng biểu, và danh sách so với file gốc.
134
+ QUY TẮC QUAN TRỌNG:
135
+ - **KHÔNG SỬ DỤNG LATEX**. Mọi công thức toán học phải được giữ ở dạng văn bản thuần túy như trong tài liệu gốc.
136
+ PHẢN HỒI:
137
+ - Chỉ trả về văn bản Markdown đã được trích xuất.
138
+ - KHÔNG đưa ra bất kỳ giải thích nào hoặc tự ý thêm vào nội dung.
139
+ """
140
 
141
+ DIRECT_GEMINI_PROMPT_LATEX = r"""Bạn công cụ trích xuất văn bản từ ảnh/PDF. NHIỆM VỤ: Chuyển đổi nội dung trong ảnh sang Markdown với công thức LaTeX.
142
+
143
+ ⚠️ QUY TẮC BẮT BUỘC - TUÂN THỦ 100%:
144
+
145
+ 1. ĐẦU RA: CHỈ là Markdown thuần túy - KHÔNG có giải thích, comment, hoặc text thừa. KHÔNG thêm "Wait, Câu X:" hay bất kỳ comment nào.
146
+
147
+ 2. CÔNG THỨC TOÁN HỌC - TẤT CẢ phải bọc trong $...$ (KHÔNG có space trong $...$):
148
+ ✅ ĐÚNG: $f(x)=2x-13$, $M(x)=0$, $a=2$, $b=-13$, $x=0$
149
+ ❌ SAI: f(x)=2x-13, Cho M(x)=0, a=2; b=-13, với x=0
150
+
151
+ - Biến đơn: $x$, $y$, $z$, $a$, $b$, $c$
152
+ - Số: $2$, $3$, $-13$, $0$
153
+ - Phương trình: $f(x)=2x-13$, $M(x)=0$
154
+ - Biểu thức: $a=2$, $b=a-15$, $x=0$
155
+
156
+ 3. KHOẢNG TRẮNG: Luôn có space TRƯỚC $ mở (trừ đầu dòng):
157
+ ✅ ĐÚNG: Vậy $f(x)=2x-13$. Cho $M(x)=0$. Thay $b=a-15$ vào. Đa thức $M(x)$ có nghiệm $x=0$.
158
+ ❌ SAI: Vậy$f(x)=2x-13$. Cho$M(x)=0$. Thay$b=a-15$vào.
159
+
160
+ 4. CÔNG THỨC ĐẶC BIỆT - QUY TẮC CHUẨN:
161
+ - Phân số: $\frac{a}{b}$, $\frac{1}{2}$, $\frac{x+1}{x-1}$
162
+ - Số mũ: $x^{2}$, $a^{n}$, $2^{3}$, $x^{n+1}$
163
+ - Căn bậc 2: $\sqrt{x}$, $\sqrt{2}$, $\sqrt{a+b}$
164
+ - Căn bậc n: $\sqrt[n]{x}$, $\sqrt[3]{8}$
165
+ - Tích phân: $\int_{0}^{2} f(x)dx$, $\int\limits_{0}^{2} y^{2}dx$
166
+ - Tổng: $\sum_{i=1}^{n} a_i$
167
+ - Giới hạn: $\lim_{x \to 0} f(x)$
168
+ - Logarit: $\log x$, $\ln x$, $\log_{2} x$
169
+ - Lượng giác: $\sin x$, $\cos x$, $\tan x$
170
+
171
+ 5. HÌNH HỌC - QUY TẮC:
172
+ - Điểm: $A$, $B$, $C$, $M$, $N$
173
+ - Đoạn thẳng: $AB$, $CD$, $MN$, $BC$
174
+ - Tam giác: $\Delta ABC$, $\Delta$ (không dùng $\Triangle$)
175
+ - Góc: $\widehat{ABC}$, $\angle ABC$
176
+ - Song song: $AB // CD$ (không dùng $AB \parallel CD$)
177
+ - Vuông góc: $AB \perp CD$
178
+ - Bằng nhau: $AB = CD$ (không dùng $\cong$)
179
+
180
+ 6. ĐƠN VỊ - QUY TẮC:
181
+ - Diện tích: $cm^{2}$, $m^{2}$, $km^{2}$
182
+ - Thể tích: $cm^{3}$, $m^{3}$
183
+ - Độ: $90^{0}$, $45^{0}$, $180^{0}$
184
+ - Phần trăm: $50\%$, $100\%$
185
+
186
+ 7. SỐ THẬP PHÂN - QUY TẮC:
187
+ - Dùng dấu phẩy: $1,3$, $2,5$, $0,75$
188
+ - KHÔNG dùng dấu chấm: không viết $1.3$
189
+
190
+ 8. TẬP HỢP - QUY TẮC:
191
+ - Tập hợp: $A = \{1, 2, 3\}$, $B = \{x \in N | x > 5\}$
192
+ - Thuộc: $x \in A$, $2 \in A$
193
+ - Không thuộc: $x \notin A$, $5 \notin A$
194
+ - Tập con: $A \subset B$
195
+
196
+ 9. BẤT ĐẲNG THỨC - QUY TẮC:
197
+ - Lớn hơn: $a > b$, $x \geq 5$
198
+ - Nhỏ hơn: $a < b$, $x \leq 10$
199
+ - Khoảng: $[a; b]$, $(a; b)$, $[a; b)$, $(a; b]$
200
+
201
+ 10. GIỮ NGUYÊN CẤU TRÚC:
202
+ - Giữ nguyên bố cục, ngắt dòng như ảnh gốc
203
+ - Tiêu đề in đậm: **Bài 7:**, **Dạng 4:**, **PHẦN I.**
204
+ - Bảng: giữ nguyên số hàng/cột, dùng | để phân cách
205
+
206
+ 11. BẢNG - QUY TẮC QUAN TRỌNG ‼️:
207
+ ⚠️ ĐẾM CHÍNH XÁC SỐ CỘT VÀ HÀNG:
208
+ - TRƯỚC KHI VIẾT BẢNG: Đếm kỹ số cột từ trái sang phải trong ảnh gốc
209
+ - KIỂM TRA KỸ: Đảm bảo TẤT CẢ các cột đều được nhận diện, KHÔNG BỎ SÓT
210
+ - Nếu ảnh có 11 cột thì bảng Markdown PHẢI có 11 cột
211
+ - Nếu ảnh có 20 hàng thì bảng Markdown PHẢI có 20 hàng
212
+ - KHÔNG gộp hoặc bỏ qua cột/hàng nào
213
+ - KHÔNG chuyển hàng thành cột hoặc ngược lại
214
+
215
+ 📋 FORMAT BẢNG CHUẨN:
216
+ | Cột 1 | Cột 2 | Cột 3 | ... | Cột N |
217
+ |-------|-------|-------|-----|-------|
218
+ | Data1 | Data2 | Data3 | ... | DataN |
219
+
220
+ ✅ VÍ DỤ ĐÚNG (11 cột):
221
+ | Col1 | Col2 | Col3 | Col4 | Col5 | Col6 | Col7 | Col8 | Col9 | Col10 | Col11 |
222
+ |------|------|------|------|------|------|------|------|------|-------|-------|
223
+ | A | B | C | D | E | F | G | H | I | J | K |
224
+
225
+ ❌ SAI (thiếu cột):
226
+ | Col1 | Col2 | Col3 | Col4 | Col5 | Col6 | Col7 |
227
+ |------|------|------|------|------|------|------|
228
+ | A | B | C | D | E | F | G |
229
+
230
+ ⚠️ LƯU Ý CUỐI CÙNG:
231
+ - KHÔNG thêm comment, giải thích, hoặc text thừa
232
+ - KHÔNG tự ý thay đổi nội dung
233
+ - CHỈ trả về Markdown thuần túy
234
+ - Bỏ qua Header/Footer/số trang
235
+ - ĐẾM KỸ số cột/hàng trong bảng - KHÔNG ĐƯỢC BỎ SÓT
236
+
237
+ BẮT ĐẦU TRÍCH XUẤT NGAY - CHỈ TRẢ VỀ MARKDOWN, KHÔNG CÓ GIẢI THÍCH.
238
+ """
239
+
240
+ # ===== KEY MANAGER =====
241
+ class ApiKeyManager:
242
+ def __init__(self, keys: List[str]):
243
+ self.api_keys = [k.strip() for k in keys if k.strip()]
244
+ self.current_index = 0
245
+
246
+ def get_next_key(self) -> Optional[str]:
247
+ if not self.api_keys: return None
248
+ key = self.api_keys[self.current_index]
249
+ self.current_index = (self.current_index + 1) % len(self.api_keys)
250
+ return key
251
+
252
+ def get_key_count(self) -> int:
253
+ return len(self.api_keys)
254
+
255
+ key_manager = ApiKeyManager(GEMINI_API_KEYS)
256
 
257
  # ===== HELPER FUNCTIONS =====
258
  def clean_latex_formulas(text: str) -> str:
259
+ # Xóa các khoảng trắng thừa quanh dấu $
260
+ return re.sub(r'\$(.*?)\$', lambda m: f'${m.group(1).strip()}$', text)
261
 
262
  def hash_password(password: str) -> str:
263
  return hashlib.sha256(password.encode()).hexdigest()
 
265
  def verify_password(password: str, hashed: str) -> bool:
266
  return hash_password(password) == hashed
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  # ===== API ENDPOINTS =====
269
 
270
  @app.get("/")
271
  @app.get("/health")
 
 
272
  async def root():
273
  return {
274
  "status": "ok",
275
+ "service": "HT_MATH_WEB API v6.7",
276
+ "keys_loaded": key_manager.get_key_count(),
277
+ "models_available": GEMINI_MODELS
278
  }
279
 
280
  @app.get("/api/models")
281
  async def get_models():
282
  return {"models": GEMINI_MODELS}
283
 
284
+ # --- AUTH API (Token Only) ---
285
  @app.post("/api/register")
286
  async def register(email: str = Form(...), password: str = Form(...)):
287
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
288
+ res = supabase.table("users").select("email").eq("email", email).execute()
289
+ if res.data: raise HTTPException(status_code=400, detail="Email tồn tại")
290
 
291
+ user_data = {
 
 
 
 
 
292
  "email": email,
293
  "password": hash_password(password),
294
+ "status": "pending",
295
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S")
 
296
  }
297
+ supabase.table("users").insert(user_data).execute()
298
+ return {"success": True, "message": "Đăng ký thành công, chờ duyệt."}
299
 
300
  @app.post("/api/login")
301
  async def login(request: Request, email: str = Form(...), password: str = Form(...)):
302
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
303
+ res = supabase.table("users").select("*").eq("email", email).execute()
304
+ if not res.data: raise HTTPException(status_code=401, detail="Sai email/pass")
 
305
 
306
+ user = res.data[0]
307
+ if not verify_password(password, user["password"]):
308
+ raise HTTPException(status_code=401, detail="Sai email/pass")
 
309
 
310
+ if user.get("status") != "active":
311
+ raise HTTPException(status_code=403, detail="Tài khoản chưa kích hoạt")
312
 
313
+ token = secrets.token_urlsafe(32)
314
 
315
+ try: supabase.table("sessions").delete().eq("email", email).execute()
316
+ except Exception as e: print(f"Lỗi xóa session cũ: {e}")
317
 
318
+ try:
319
+ supabase.table("sessions").insert({
320
+ "email": email,
321
+ "token": token,
322
+ "last_seen": time.strftime("%Y-%m-%d %H:%M:%S")
323
+ }).execute()
324
+ except Exception as e:
325
+ print(f"Lỗi tạo session: {e}")
326
+ raise HTTPException(status_code=500, detail="Lỗi tạo phiên làm việc")
327
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  return {"success": True, "token": token, "email": email}
329
 
330
  @app.post("/api/check-session")
331
  async def check_session(email: str = Form(...), token: str = Form(...)):
332
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
333
+ try:
334
+ res = supabase.table("sessions").select("token").eq("email", email).execute()
335
+ if not res.data:
336
+ raise HTTPException(status_code=401, detail="Session expired")
337
+
338
+ server_token = res.data[0]['token']
339
+ if token != server_token:
340
+ print(f"[AUTH] Token mismatch for {email}. Old session invalid.")
341
+ raise HTTPException(status_code=401, detail="Logged in elsewhere")
342
+
343
+ supabase.table("sessions").update({
344
+ "last_seen": time.strftime("%Y-%m-%d %H:%M:%S")
345
+ }).eq("email", email).execute()
346
+
347
+ return {"status": "valid"}
348
+ except HTTPException:
349
+ raise
350
+ except Exception as e:
351
+ print(f"Error checking session: {e}")
352
+ raise HTTPException(status_code=500, detail=str(e))
353
 
354
  @app.post("/api/logout")
355
  async def logout(request: Request):
 
356
  try:
357
  data = await request.json()
358
  email = data.get("email")
359
+ if email and supabase:
360
+ supabase.table("sessions").delete().eq("email", email).execute()
361
+ return {"status": "success"}
362
+ except:
363
+ return {"status": "success"}
364
+
365
+ # --- CONVERT API ---
366
+
367
+ async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, max_retries: int = 3) -> str:
368
+ for attempt in range(max_retries):
369
+ try:
370
+ api_key = key_manager.get_next_key()
371
+ if not api_key: raise ValueError("No API Key")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
+ genai.configure(api_key=api_key)
374
+ generation_config = {
375
+ "temperature": 0.0,
376
+ "top_p": 1.0,
377
+ "max_output_tokens": 8192,
378
+ }
379
+ safety_settings = [
380
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
381
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
382
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
383
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
384
+ ]
385
+
386
+ model = genai.GenerativeModel(model_name=model_id, generation_config=generation_config, safety_settings=safety_settings)
387
+ response = model.generate_content([prompt, image])
388
+
389
+ if response.text:
390
+ text = response.text.strip()
391
+ # Loại bỏ các comment/giải thích không cần thiết từ AI
392
+ lines = text.split('\n')
393
+ cleaned_lines = []
394
+ for line in lines:
395
+ # Bỏ qua các dòng comment như "Wait, Câu X:" hoặc giải thích
396
+ stripped = line.strip()
397
+ if stripped.startswith('*Wait') or (stripped.startswith('Wait,') and ':' in stripped):
398
+ continue
399
+ # Bỏ qua dòng chỉ có comment markdown
400
+ if stripped in ['*', '**', '***', '****']:
401
+ continue
402
+ cleaned_lines.append(line)
403
+
404
+ cleaned_text = '\n'.join(cleaned_lines).strip()
405
+ # Loại bỏ các pattern comment thường gặp
406
+ cleaned_text = re.sub(r'\*Wait,.*?:\*', '', cleaned_text, flags=re.IGNORECASE | re.MULTILINE)
407
+ cleaned_text = re.sub(r'Wait,.*?:', '', cleaned_text, flags=re.IGNORECASE | re.MULTILINE)
408
+ # Loại bỏ các dòng trống thừa
409
+ cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
410
+
411
+ return cleaned_text
412
+ else:
413
+ raise ValueError("Empty response")
414
+
415
+ except Exception as e:
416
+ if "429" in str(e) or "Quota" in str(e):
417
+ if attempt < max_retries - 1:
418
+ time.sleep(2)
419
+ continue
420
+ print(f"Gemini Error (Attempt {attempt}): {e}")
421
+ raise HTTPException(status_code=500, detail=str(e))
422
+ raise HTTPException(status_code=500, detail="Failed after retries")
423
 
424
  @app.post("/api/convert")
425
  async def convert_file(
426
  request: Request,
427
  file: UploadFile = File(...),
428
  model: str = Form("gemini-2.5-flash"),
429
+ mode: str = Form("latex")
 
430
  ):
431
  check_rate_limit(request)
432
 
433
+ if key_manager.get_key_count() == 0:
434
+ raise HTTPException(status_code=500, detail="Chưa cấu hình API Key")
435
+
436
  prompt = DIRECT_GEMINI_PROMPT_LATEX if mode == "latex" else DIRECT_GEMINI_PROMPT_TEXT_ONLY
437
 
438
  try:
439
  file_content = await file.read()
440
  file_ext = os.path.splitext(file.filename)[1].lower()
 
 
441
  results = []
442
+
443
  if file_ext == ".pdf":
444
  doc = fitz.open(stream=file_content, filetype="pdf")
445
+ page_count = len(doc)
446
+
447
+ # Tạo semaphore để giới hạn số lượng concurrent requests
448
+ semaphore = asyncio.Semaphore(min(MAX_THREADS, page_count))
449
+
450
+ async def process_page_async(page, page_num, model, prompt):
451
+ """Xử một trang PDF với semaphore để giới hạn concurrent requests"""
452
+ async with semaphore:
453
+ pix = page.get_pixmap(dpi=300)
454
+ img = Image.open(io.BytesIO(pix.tobytes("png")))
455
+ text = await process_image_with_gemini(img, model, prompt)
456
+ return page_num, text
457
+
458
+ # Tạo tasks cho tất cả các trang
459
+ tasks = []
460
+ for page_num in range(page_count):
461
+ page = doc[page_num]
462
+ tasks.append(process_page_async(page, page_num, model, prompt))
463
+
464
+ # Chạy tất cả tasks song song (giới hạn bởi semaphore)
465
+ page_results = await asyncio.gather(*tasks)
466
+
467
+ # Sắp xếp kết quả theo thứ tự trang
468
+ page_results.sort(key=lambda x: x[0])
469
+ results = [text for _, text in page_results]
470
 
471
+ doc.close()
472
+
473
+ elif file_ext in [".png", ".jpg", ".jpeg", ".bmp"]:
474
+ img = Image.open(io.BytesIO(file_content))
475
+ text = await process_image_with_gemini(img, model, prompt)
476
+ results.append(text)
477
+ else:
478
+ raise HTTPException(status_code=400, detail="Định dạng file không hỗ trợ")
479
+
480
+ final_text = "\n\n".join(results)
481
+ return {"success": True, "result": clean_latex_formulas(final_text)}
482
+
483
  except Exception as e:
484
  raise HTTPException(status_code=500, detail=str(e))
485
 
486
+ # --- WORD EXPORT API (REAL EQUATION SUPPORT) ---
487
+
488
+ def convert_latex_to_mathml(latex):
489
+ """Convert LaTeX to MathML using best available method."""
490
+ if not latex or not latex.strip():
491
+ return None
492
+
493
+ # Ưu tiên pypandoc (mạnh hơn, hỗ trợ nhiều LaTeX phức tạp)
494
+ if pypandoc_available:
495
+ try:
496
+ # pypandoc convert LaTeX sang MathML
497
+ mathml = pypandoc.convert_text(
498
+ f"$${latex}$$",
499
+ "mathml",
500
+ format="latex",
501
+ extra_args=["--mathml"]
502
+ )
503
+ if mathml and mathml.strip():
504
+ return mathml.strip()
505
+ except Exception as e:
506
+ print(f"pypandoc conversion error: {e}, falling back to latex2mathml")
507
+
508
+ # Fallback về latex2mathml
509
+ if latex_to_mathml:
510
+ try:
511
+ mathml = latex_to_mathml(latex)
512
+ if mathml and mathml.strip():
513
+ return mathml.strip()
514
+ except Exception as e:
515
+ print(f"latex2mathml conversion error: {e}")
516
+
517
+ return None
518
+
519
+ def insert_equation(paragraph, latex):
520
+ """Chèn phương trình LaTeX vào Word dưới dạng OMath (Equation thật)."""
521
+ if not latex or not latex.strip():
522
+ return
523
+
524
+ try:
525
+ # Convert LaTeX -> MathML (dùng pypandoc nếu có, fallback latex2mathml)
526
+ mathml = convert_latex_to_mathml(latex)
527
+ if not mathml or not mathml.strip():
528
+ raise ValueError("MathML is empty")
529
+
530
+ # Parse MathML - xử lý namespace
531
+ try:
532
+ root = ET.fromstring(mathml)
533
+ except ET.ParseError as e:
534
+ print(f"MathML parse error: {e}, mathml: {mathml[:100]}")
535
+ raise
536
+
537
+ # Create <m:oMathPara> - QUAN TRỌNG: Word cần oMathPara wrapper
538
+ omath_para = OxmlElement("m:oMathPara")
539
+ omath_para.set(qn('xmlns:m'), 'http://schemas.openxmlformats.org/officeDocument/2006/math')
540
+ omath = OxmlElement("m:oMath")
541
+ omath_para.append(omath)
542
+
543
+ # Helper: convert MathML to OMath
544
+ def convert(elem):
545
+ if elem is None:
546
+ return None
547
+
548
+ # Xử lý namespace
549
+ if elem.tag.startswith("{"):
550
+ tag = elem.tag.split("}")[-1]
551
+ namespace = elem.tag.split("}")[0] + "}"
552
+ else:
553
+ tag = elem.tag
554
+ namespace = None
555
+
556
+ # Variables, numbers, operators
557
+ if tag in ("mi", "mn", "mo", "mtext"):
558
+ r = OxmlElement("m:r")
559
+ t = OxmlElement("m:t")
560
+ text_content = (elem.text or "").strip()
561
+ t.text = text_content
562
+ if text_content:
563
+ t.set(qn("xml:space"), "preserve")
564
+ r.append(t)
565
+ return r
566
+
567
+ # Group / row
568
+ if tag == "mrow":
569
+ r = OxmlElement("m:r")
570
+ for child in elem:
571
+ converted = convert(child)
572
+ if converted is not None:
573
+ r.append(converted)
574
+ return r if len(r) > 0 else None
575
+
576
+ # Fraction
577
+ if tag == "mfrac":
578
+ f = OxmlElement("m:f")
579
+ num = OxmlElement("m:num")
580
+ den = OxmlElement("m:den")
581
+ if len(elem) >= 1:
582
+ num_child = convert(elem[0])
583
+ if num_child:
584
+ num.append(num_child)
585
+ if len(elem) >= 2:
586
+ den_child = convert(elem[1])
587
+ if den_child:
588
+ den.append(den_child)
589
+ f.append(num)
590
+ f.append(den)
591
+ return f
592
+
593
+ # Superscript
594
+ if tag == "msup":
595
+ sup = OxmlElement("m:sSup")
596
+ e = OxmlElement("m:e")
597
+ supchild = OxmlElement("m:sup")
598
+ if len(elem) >= 1:
599
+ base = convert(elem[0])
600
+ if base:
601
+ e.append(base)
602
+ if len(elem) >= 2:
603
+ sup_base = convert(elem[1])
604
+ if sup_base:
605
+ supchild.append(sup_base)
606
+ sup.append(e)
607
+ sup.append(supchild)
608
+ return sup
609
+
610
+ # Subscript
611
+ if tag == "msub":
612
+ sub = OxmlElement("m:sSub")
613
+ e = OxmlElement("m:e")
614
+ subchild = OxmlElement("m:sub")
615
+ if len(elem) >= 1:
616
+ base = convert(elem[0])
617
+ if base:
618
+ e.append(base)
619
+ if len(elem) >= 2:
620
+ sub_base = convert(elem[1])
621
+ if sub_base:
622
+ subchild.append(sub_base)
623
+ sub.append(e)
624
+ sub.append(subchild)
625
+ return sub
626
+
627
+ # Square root
628
+ if tag == "msqrt":
629
+ rad = OxmlElement("m:rad")
630
+ rad_pr = OxmlElement("m:radPr")
631
+ e = OxmlElement("m:e")
632
+ rad.append(rad_pr)
633
+ if len(elem) >= 1:
634
+ rad_child = convert(elem[0])
635
+ if rad_child:
636
+ e.append(rad_child)
637
+ rad.append(e)
638
+ return rad
639
+
640
+ # Fallback: wrap in run
641
+ r = OxmlElement("m:r")
642
+ has_content = False
643
+ for child in elem:
644
+ converted = convert(child)
645
+ if converted is not None:
646
+ r.append(converted)
647
+ has_content = True
648
+ return r if has_content else None
649
+
650
+ # Convert children of <math> tag
651
+ math_tag = root.tag
652
+ if math_tag.endswith('math') or (isinstance(math_tag, str) and 'math' in math_tag):
653
+ has_children = False
654
+ for child in root:
655
+ converted = convert(child)
656
+ if converted is not None:
657
+ omath.append(converted)
658
+ has_children = True
659
+ if not has_children:
660
+ # Nếu không có children hợp lệ, fallback
661
+ raise ValueError("No valid MathML children")
662
+ else:
663
+ converted = convert(root)
664
+ if converted is not None:
665
+ omath.append(converted)
666
+ else:
667
+ raise ValueError("MathML conversion failed")
668
+
669
+ # Insert into run
670
+ run = paragraph.add_run()
671
+ run._r.append(omath_para)
672
+
673
+ except Exception as e:
674
+ print(f"Equation fallback for '{latex[:50]}...': {e}")
675
+ import traceback
676
+ traceback.print_exc()
677
+ # Fallback về text (không phải equation thật, nhưng vẫn hiển thị được)
678
+ run = paragraph.add_run("$" + latex + "$")
679
+ run.font.name = "Cambria Math"
680
+ run.font.size = Pt(14)
681
+ run.italic = True # In nghiêng để phân biệt với text thường
682
+
683
  @app.post("/api/export-docx")
684
+ async def export_docx(
685
+ markdown_text: str = Form(...)
686
+ ):
687
+ try:
688
+ doc = Document()
689
+
690
+ # 1. Page Setup
691
+ section = doc.sections[0]
692
+ section.page_height = Cm(29.7)
693
+ section.page_width = Cm(21.0)
694
+ section.left_margin = Cm(1.5)
695
+ section.right_margin = Cm(1.5)
696
+ section.top_margin = Cm(1.5)
697
+ section.bottom_margin = Cm(1.5)
698
+
699
+ # 2. Font & Style Default
700
+ style = doc.styles['Normal']
701
+ font = style.font
702
+ font.name = 'Times New Roman'
703
+ font.size = Pt(14)
704
+ font.color.rgb = RGBColor(0, 0, 0)
705
+ doc.styles['Normal']._element.rPr.rFonts.set(qn('w:eastAsia'), 'Times New Roman')
706
+
707
+ # 3. Process Content
708
+ lines = markdown_text.split("\n")
709
+
710
+ # Biến để theo dõi bảng đang xử lý
711
+ current_table = None
712
+
713
+ i = 0
714
+ while i < len(lines):
715
+ line = lines[i]
716
+ stripped_line = line.strip()
717
+
718
+ # Table Handling - Xử lý bảng với header row
719
+ if stripped_line.startswith("|"):
720
+ cells = [c.strip() for c in stripped_line.split("|") if c.strip()]
721
+
722
+ # Bỏ qua dòng separator (---)
723
+ if "---" in stripped_line:
724
+ i += 1
725
+ continue
726
+
727
+ if len(cells) > 0:
728
+ # Nếu chưa có bảng, tạo bảng mới với header row
729
+ if current_table is None:
730
+ current_table = doc.add_table(rows=1, cols=len(cells))
731
+ current_table.style = 'Table Grid'
732
+ # Header row
733
+ hdr_cells = current_table.rows[0].cells
734
+ for j, cell_text in enumerate(cells):
735
+ if j < len(hdr_cells):
736
+ hdr_cells[j].text = cell_text
737
+ # Format header
738
+ for paragraph in hdr_cells[j].paragraphs:
739
+ paragraph.paragraph_format.line_spacing = 1.0
740
+ for run in paragraph.runs:
741
+ run.font.name = 'Times New Roman'
742
+ run.font.size = Pt(14)
743
+ run.bold = True # Header in đậm
744
+ else:
745
+ # Thêm row mới vào bảng hiện tại
746
+ row = current_table.add_row().cells
747
+ for j, cell_text in enumerate(cells):
748
+ if j < len(row):
749
+ row[j].text = cell_text
750
+ for paragraph in row[j].paragraphs:
751
+ paragraph.paragraph_format.line_spacing = 1.0
752
+ for run in paragraph.runs:
753
+ run.font.name = 'Times New Roman'
754
+ run.font.size = Pt(14)
755
+
756
+ i += 1
757
+ continue
758
+
759
+ # Reset bảng khi gặp dòng trống hoặc không phải bảng
760
+ if current_table is not None:
761
+ current_table = None
762
+
763
+ # Bỏ qua dòng trắng
764
+ if not stripped_line:
765
+ i += 1
766
+ continue
767
+
768
+ # Paragraph Handling
769
+ p = doc.add_paragraph()
770
+ p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE # Giãn dòng đơn
771
+ p.paragraph_format.line_spacing = 1.0 # Đảm bảo 1.0
772
+ p.paragraph_format.space_after = Pt(0)
773
+
774
+ parts = re.split(r'(\$.*?\$)', line)
775
+
776
+ for part in parts:
777
+ if not part: continue
778
+
779
+ # --- XỬ LÝ EQUATION (QUAN TRỌNG) ---
780
+ if part.startswith("$") and part.endswith("$"):
781
+ latex = part.strip("$") # Lấy nội dung LaTeX bỏ dấu $
782
+ insert_equation(p, latex)
783
+ else:
784
+ # Text thường
785
+ sub_parts = re.split(r'(\*\*.*?\*\*)', part)
786
+ for sub in sub_parts:
787
+ clean_sub = sub.replace("**", "")
788
+ if not clean_sub: continue
789
+ run = p.add_run(clean_sub)
790
+ run.font.name = 'Times New Roman'
791
+ run.font.size = Pt(14)
792
+ if sub.startswith("**") and sub.endswith("**"):
793
+ run.bold = True
794
+
795
+ i += 1
796
+
797
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".docx")
798
+ doc.save(tmp.name)
799
+ tmp.close()
800
+
801
+ return FileResponse(
802
+ tmp.name,
803
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
804
+ filename="ket_qua_HT_MATH.docx"
805
+ )
806
+
807
+ except Exception as e:
808
+ import traceback
809
+ error_detail = str(e)
810
+ traceback.print_exc()
811
+ print(f"Error Export Docx: {error_detail}")
812
+ raise HTTPException(status_code=500, detail=f"Lỗi xuất Word: {error_detail}")
813
+
814
+ # --- EXCEL EXPORT API ---
815
+
816
+ @app.post("/api/export-excel")
817
+ async def export_excel(
818
+ markdown_text: str = Form(...)
819
+ ):
820
+ """Xuất nội dung Markdown sang file Excel (.xlsx)
821
+ - Chuyển đổi bảng Markdown sang Excel tables
822
+ - Giữ định dạng bold cho text in đậm
823
+ - Gỡ bỏ hoàn toàn công thức LaTeX ($...$)
824
+ """
825
  try:
826
+ # Tạo workbook mới
827
+ wb = Workbook()
828
+ ws = wb.active
829
+ ws.title = "HT MATH WEB"
830
+
831
+ # Gỡ bỏ tất cả công thức LaTeX khỏi markdown
832
+ cleaned_text = re.sub(r'\$[^$]+\$', '', markdown_text)
833
+ # Loại bỏ khoảng trắng thừa
834
+ cleaned_text = re.sub(r'\s+', ' ', cleaned_text)
835
+ cleaned_text = re.sub(r'\n\s*\n', '\n', cleaned_text)
836
+
837
+ lines = cleaned_text.split("\n")
838
+
839
+ current_row = 1
840
+ current_table = None
841
+ table_start_row = None
842
+
843
+ # Định nghĩa borders cho bảng
844
+ thin_border = Border(
845
+ left=Side(style='thin'),
846
+ right=Side(style='thin'),
847
+ top=Side(style='thin'),
848
+ bottom=Side(style='thin')
849
+ )
850
+
851
+ i = 0
852
+ while i < len(lines):
853
+ line = lines[i]
854
+ stripped_line = line.strip()
855
+
856
+ # Xử lý bảng Markdown
857
+ if stripped_line.startswith("|"):
858
+ cells = [c.strip() for c in stripped_line.split("|") if c.strip()]
859
+
860
+ # Bỏ qua dòng separator (---)
861
+ if "---" in stripped_line:
862
+ i += 1
863
+ continue
864
+
865
+ if len(cells) > 0:
866
+ # Nếu là bảng mới
867
+ if current_table is None:
868
+ current_table = True
869
+ table_start_row = current_row
870
+
871
+ # Thêm cells vào Excel
872
+ for col_idx, cell_text in enumerate(cells, start=1):
873
+ cell = ws.cell(row=current_row, column=col_idx)
874
+ cell.value = cell_text
875
+ cell.border = thin_border
876
+ cell.alignment = Alignment(horizontal='left', vertical='center')
877
+
878
+ # Header row (row đầu tiên của bảng) - in đậm
879
+ if current_row == table_start_row:
880
+ cell.font = Font(name='Times New Roman', size=12, bold=True)
881
+ else:
882
+ cell.font = Font(name='Times New Roman', size=12)
883
+
884
+ current_row += 1
885
+
886
+ i += 1
887
+ continue
888
+
889
+ # Reset bảng khi gặp dòng không phải bảng
890
+ if current_table is not None:
891
+ current_table = None
892
+ table_start_row = None
893
+
894
+ # Bỏ qua dòng trống
895
+ if not stripped_line:
896
+ i += 1
897
+ continue
898
+
899
+ # Xử lý text thường và bold
900
+ # Tách bold text (**text**)
901
+ parts = re.split(r'(\*\*.*?\*\*)', stripped_line)
902
+
903
+ combined_text = ""
904
+ has_bold = False
905
+
906
+ for part in parts:
907
+ if part.startswith("**") and part.endswith("**"):
908
+ # Text in đậm
909
+ has_bold = True
910
+ combined_text += part.replace("**", "")
911
+ else:
912
+ combined_text += part
913
+
914
+ if combined_text.strip():
915
+ cell = ws.cell(row=current_row, column=1)
916
+ cell.value = combined_text.strip()
917
+
918
+ if has_bold or stripped_line.startswith("**"):
919
+ cell.font = Font(name='Times New Roman', size=12, bold=True)
920
+ else:
921
+ cell.font = Font(name='Times New Roman', size=12)
922
+
923
+ cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True)
924
+ current_row += 1
925
+
926
+ i += 1
927
+
928
+ # Tự động điều chỉnh độ rộng cột
929
+ for col in ws.columns:
930
+ max_length = 0
931
+ column = col[0].column_letter
932
+ for cell in col:
933
+ try:
934
+ if len(str(cell.value)) > max_length:
935
+ max_length = len(str(cell.value))
936
+ except:
937
+ pass
938
+ adjusted_width = min(max_length + 2, 50) # Giới hạn max width
939
+ ws.column_dimensions[column].width = adjusted_width
940
+
941
+ # Lưu file Excel
942
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
943
+ wb.save(tmp.name)
944
+ tmp.close()
945
+
946
+ return FileResponse(
947
+ tmp.name,
948
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
949
+ filename="Ket_qua_HT_MATH.xlsx"
950
+ )
951
+
952
+ except Exception as e:
953
+ import traceback
954
+ error_detail = str(e)
955
+ traceback.print_exc()
956
+ print(f"Error Export Excel: {error_detail}")
957
+ raise HTTPException(status_code=500, detail=f"Lỗi xuất Excel: {error_detail}")
958
+
959
 
960
  if __name__ == "__main__":
961
  import uvicorn
962
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")), log_level="info")