sourav520 commited on
Commit
f485a55
·
verified ·
1 Parent(s): 1c101cd

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +18 -35
  2. main.py +472 -713
  3. packages.txt +1 -4
  4. requirements.txt +10 -33
Dockerfile CHANGED
@@ -1,48 +1,31 @@
1
- FROM python:3.9-slim
2
 
3
- # Set working directory
4
- WORKDIR /app
5
-
6
- # Install system dependencies (minimal)
7
- RUN apt-get update && \
8
- apt-get install -y \
9
- default-jre \
10
  curl \
11
- libreoffice \
12
- libreoffice-writer \
13
- libreoffice-calc \
14
- libreoffice-java-common \
15
- libgl1 \
16
- libglib2.0-0 \
17
- && apt-get clean && \
18
- rm -rf /var/lib/apt/lists/*
19
 
20
- # Reduce cache issues
21
- ENV XDG_CACHE_HOME=/tmp/.cache
22
- ENV NUMBA_CACHE_DIR=/tmp/.numba_cache
 
 
23
 
24
- # Copy requirements
25
- COPY requirements.txt .
26
 
27
- # Install Python dependencies
28
- RUN pip install --no-cache-dir -r requirements.txt
 
29
 
30
- # Copy app
31
- COPY main.py .
32
 
33
- # Create folders
34
  RUN mkdir -p uploads outputs
35
 
36
- # Expose port
37
  EXPOSE 7860
38
 
39
- # Health check
40
- HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
41
- CMD curl -f http://localhost:7860/health || exit 1
42
-
43
- # Environment
44
- ENV FLASK_ENV=production
45
  ENV PYTHONUNBUFFERED=1
46
 
47
- # Run
48
- CMD ["python", "main.py"]
 
 
 
1
+ FROM python:3.10-slim
2
 
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
 
 
 
 
 
 
4
  curl \
5
+ && apt-get clean \
6
+ && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
7
 
8
+ # Non-root user required by Hugging Face Spaces
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ ENV HOME=/home/user \
12
+ PATH=/home/user/.local/bin:$PATH
13
 
14
+ WORKDIR /home/user/app
 
15
 
16
+ COPY --chown=user requirements.txt .
17
+ RUN pip install --no-cache-dir --upgrade pip && \
18
+ pip install --no-cache-dir -r requirements.txt
19
 
20
+ COPY --chown=user main.py .
 
21
 
 
22
  RUN mkdir -p uploads outputs
23
 
 
24
  EXPOSE 7860
25
 
 
 
 
 
 
 
26
  ENV PYTHONUNBUFFERED=1
27
 
28
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
29
+ CMD curl -f http://localhost:7860/health || exit 1
30
+
31
+ CMD ["python", "main.py"]
main.py CHANGED
@@ -1,829 +1,588 @@
1
-
2
  from flask import Flask, request, send_file, jsonify
3
  from flask_cors import CORS
4
- import fitz # PyMuPDF
5
  from docx import Document
6
- from docx.shared import Pt, RGBColor, Inches
7
  from docx.enum.text import WD_ALIGN_PARAGRAPH
8
- import tabula
 
9
  from openpyxl import Workbook
10
- from openpyxl.styles import Font, Alignment, PatternFill
11
  import os
12
- import tempfile
13
- import logging
14
- import threading, time, requests
15
- from dotenv import load_dotenv
16
- from werkzeug.utils import secure_filename
17
- import subprocess
18
- import shutil
19
-
20
-
21
- import numpy as np
22
- from PIL import Image
23
- import onnxruntime as ort
24
  import io
25
  import base64
26
-
27
-
28
- ############################################
29
- import numpy as np
30
- from PIL import Image
31
- import onnxruntime as ort
32
- import io
33
- import base64
34
-
35
- import urllib.parse
36
- import random
37
-
38
- import fitz
39
- from docx import Document
40
- from openpyxl import Workbook
41
- from pdf2docx import Converter
42
  import tempfile
43
- import os
44
- from io import BytesIO
45
- import math
46
- from PyPDF2 import PdfMerger
47
- from flask_cors import CORS
48
-
49
- import base64
50
- from google import genai
51
- from google.genai import types
52
- from PIL import Image
53
-
54
- import threading, time, requests
55
- import os
56
  from dotenv import load_dotenv
57
  from werkzeug.utils import secure_filename
58
 
59
- ############################################
60
-
61
-
 
 
 
 
62
 
63
  basedir = os.path.abspath(os.path.dirname(__file__))
64
  load_dotenv(os.path.join(basedir, '.env'))
65
 
66
- # Upload and output folders
67
  UPLOAD_FOLDER = os.path.join(basedir, 'uploads')
68
  OUTPUT_FOLDER = os.path.join(basedir, 'outputs')
69
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
70
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
71
 
72
- app = Flask(__name__, static_folder='static', template_folder='templates')
73
  CORS(app)
74
- session = ort.InferenceSession('u2net.onnx')
75
-
76
 
77
  logging.basicConfig(level=logging.INFO)
78
  logger = logging.getLogger(__name__)
79
 
80
- # Load ONNX model for background removal
81
- session = ort.InferenceSession('u2net.onnx')
82
- LIBREOFFICE_AVAILABLE = False
 
 
 
 
 
 
83
 
84
 
85
- # Check LibreOffice availability
86
- try:
87
- result = subprocess.run(['libreoffice', '--version'], capture_output=True, text=True, timeout=5)
88
- if result.returncode == 0:
89
- LIBREOFFICE_AVAILABLE = True
90
- logger.info(f"LibreOffice available: {result.stdout.strip()}")
91
- else:
92
- logger.warning("LibreOffice not available")
93
- except Exception as e:
94
- logger.warning(f"LibreOffice check failed: {e}")
95
-
96
-
97
-
98
-
99
- # Background task for keeping service alive
100
- def preprocess(image: Image.Image):
101
- image = image.convert('RGB').resize((320, 320))
102
- img_np = np.array(image).astype(np.float32) / 255.0
103
- img_np = np.transpose(img_np, (2, 0, 1))
104
- img_np = np.expand_dims(img_np, axis=0)
105
- return img_np
106
-
107
- def postprocess(mask, orig_size):
108
- mask = mask.squeeze()
109
- mask = (mask - mask.min()) / (mask.max() - mask.min())
110
- mask = Image.fromarray((mask * 255).astype(np.uint8)).resize(orig_size)
111
- return mask
112
-
113
- def remove_background(image):
114
- input_tensor = preprocess(image)
115
- result = session.run(None, {'input.1': input_tensor})[0]
116
- mask = postprocess(result[0], image.size)
117
-
118
- image = image.convert("RGBA")
119
- datas = image.getdata()
120
- mask_data = mask.getdata()
121
- newData = [(r, g, b, m) for (r, g, b, a), m in zip(datas, mask_data)]
122
- image.putdata(newData)
123
- return image
124
 
125
- @app.route('/remove', methods=['POST'])
126
- def remove():
127
- file = request.files['image']
128
- img = Image.open(file.stream)
129
- output = remove_background(img)
130
 
131
- buffer = io.BytesIO()
132
- output.save(buffer, format='PNG')
133
- base64_img = base64.b64encode(buffer.getvalue()).decode('utf-8')
 
 
 
 
 
 
 
 
 
 
 
134
 
135
- return jsonify({'image': f'data:image/png;base64,{base64_img}'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  @app.route('/')
138
  def index():
139
  return jsonify({
140
- 'service': 'AI One - Unified API',
141
- 'version': '1.0',
142
- 'endpoints': {
143
- '/health': 'GET - Health check',
144
- '/bg_remove': 'GET - Background removal info',
145
- '/remove': 'POST - Remove background from image',
146
- '/pdf-to-word': 'POST - Convert PDF to Word (DOCX)',
147
- '/pdf-to-excel': 'POST - Convert PDF to Excel (XLSX)',
148
- '/compress': 'POST - Compress PDF file'
149
- },
150
- 'features': {
151
- 'background_removal': 'Remove background from images using U2-Net',
152
- 'pdf_to_word': 'Convert PDF with formatting preservation',
153
- 'pdf_to_excel': 'Extract tables from PDF to Excel',
154
- 'pdf_compress': 'Compress PDF files with quality presets'
155
- }
156
  })
157
 
 
158
  @app.route('/health')
159
  def health():
160
  return jsonify({
161
  'status': 'healthy',
162
- 'service': 'ai-one-unified-api',
163
- 'endpoints_available': ['pdf-to-word', 'pdf-to-excel', 'compress'] + (['bg_remove', 'remove'] if BACKGROUND_REMOVAL_AVAILABLE and session else []),
164
- 'features': {
165
- 'pdf_to_word': True,
166
- 'pdf_to_excel': True,
167
- 'pdf_compress': True,
168
- 'background_removal': BACKGROUND_REMOVAL_AVAILABLE and session is not None,
169
- 'libreoffice_conversion': LIBREOFFICE_AVAILABLE
170
- }
171
  })
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  @app.route('/pdf-to-word', methods=['POST'])
175
  def pdf_to_word():
176
  temp_pdf = None
177
  temp_docx = None
178
-
179
  try:
180
  if 'pdf' not in request.files:
181
  return jsonify({'error': 'No PDF file provided'}), 400
182
-
183
- pdf_file = request.files['pdf']
184
-
185
- if pdf_file.filename == '':
186
- return jsonify({'error': 'Empty filename'}), 400
187
-
188
- if not pdf_file.filename.lower().endswith('.pdf'):
189
  return jsonify({'error': 'File must be a PDF'}), 400
190
-
191
- # Save uploaded PDF to temp file
192
  temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
193
- pdf_file.save(temp_pdf.name)
194
  temp_pdf.close()
195
-
196
- logger.info(f"Processing PDF to Word: {pdf_file.filename}")
197
-
198
- # Try LibreOffice conversion first (best quality)
199
- if LIBREOFFICE_AVAILABLE:
200
- try:
201
- # Create output directory
202
- output_dir = tempfile.mkdtemp()
203
-
204
- # Use LibreOffice headless mode for conversion
205
- cmd = [
206
- 'libreoffice',
207
- '--headless',
208
- '--convert-to', 'docx',
209
- '--outdir', output_dir,
210
- temp_pdf.name
211
- ]
212
-
213
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
214
-
215
- # LibreOffice creates file with original name + .docx
216
- pdf_basename = os.path.basename(temp_pdf.name)
217
- converted_file = os.path.join(output_dir, os.path.splitext(pdf_basename)[0] + '.docx')
218
-
219
- if os.path.exists(converted_file) and os.path.getsize(converted_file) > 0:
220
- logger.info(f"LibreOffice conversion successful: {converted_file}")
221
-
222
- response = send_file(
223
- converted_file,
224
- mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
225
- as_attachment=True,
226
- download_name='converted.docx'
227
- )
228
-
229
- # Cleanup
230
- try:
231
- shutil.rmtree(output_dir)
232
- except:
233
- pass
234
-
235
- return response
236
- else:
237
- logger.warning(f"LibreOffice conversion failed: {result.stderr}")
238
- # Cleanup
239
- try:
240
- shutil.rmtree(output_dir)
241
- except:
242
- pass
243
- except Exception as lo_error:
244
- logger.warning(f"LibreOffice conversion error: {lo_error}")
245
-
246
- # Fallback to PyMuPDF method
247
- logger.info("Using PyMuPDF fallback conversion")
248
-
249
- # Open PDF with PyMuPDF
250
  doc = fitz.open(temp_pdf.name)
251
  word_doc = Document()
252
-
253
- # Set document margins to match PDF
254
- sections = word_doc.sections
255
- for section in sections:
 
 
 
 
 
 
 
 
 
 
 
256
  section.top_margin = Inches(0.5)
257
  section.bottom_margin = Inches(0.5)
258
- section.left_margin = Inches(0.75)
259
- section.right_margin = Inches(0.75)
260
-
261
- # Process each page
262
- for page_num in range(len(doc)):
263
- page = doc[page_num]
264
-
265
- # Get page dimensions
266
  page_width = page.rect.width
267
- page_height = page.rect.height
268
-
269
- # Extract text blocks with formatting (sorted by position)
270
- blocks = page.get_text("dict")["blocks"]
271
-
272
- # Sort blocks by vertical position (top to bottom)
273
- blocks = sorted(blocks, key=lambda b: (b.get("bbox", [0, 0, 0, 0])[1], b.get("bbox", [0, 0, 0, 0])[0]))
274
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  for block in blocks:
276
- if block["type"] == 0: # Text block
277
- # Check if block looks like a table (multiple columns)
278
- lines = block.get("lines", [])
279
-
280
- for line in lines:
281
- # Get line position for alignment
282
- bbox = line.get("bbox", [0, 0, 0, 0])
283
- line_left = bbox[0]
284
- line_right = bbox[2]
285
- line_center = (line_left + line_right) / 2
286
-
287
- paragraph = word_doc.add_paragraph()
288
-
289
- # Determine alignment based on position
290
- if line_left < page_width * 0.15: # Left aligned
291
- paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
292
- elif line_right > page_width * 0.85: # Right aligned
293
- paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
294
- elif abs(line_center - page_width / 2) < page_width * 0.1: # Center
295
- paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
 
 
 
 
 
296
  else:
297
- paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
298
-
299
- # Process each span (text with same formatting)
300
  for span in line.get("spans", []):
301
  text = span.get("text", "")
302
- if not text.strip():
303
  continue
304
-
305
- run = paragraph.add_run(text)
306
-
307
- # Font properties
308
- font_name = span.get("font", "Calibri")
309
- font_size = span.get("size", 11)
310
-
311
- # Clean font name (remove subset prefix)
312
- if '+' in font_name:
313
- font_name = font_name.split('+')[1]
314
-
 
 
 
 
 
 
 
 
315
  try:
316
- run.font.name = font_name
317
- except:
318
- run.font.name = "Calibri"
319
-
320
- run.font.size = Pt(font_size)
321
-
322
- # Font style flags
 
 
323
  flags = span.get("flags", 0)
324
- if flags & 2**4: # Bold (bit 4)
325
- run.font.bold = True
326
- if flags & 2**1: # Italic (bit 1)
327
- run.font.italic = True
328
- if flags & 2**0: # Superscript (bit 0)
329
- run.font.underline = True
330
-
331
- # Font color
332
- color = span.get("color", 0)
333
- if color != 0:
334
- r = (color >> 16) & 0xFF
335
- g = (color >> 8) & 0xFF
336
- b = color & 0xFF
337
- try:
338
- run.font.color.rgb = RGBColor(r, g, b)
339
- except:
340
- pass
341
-
342
- # Add spacing between paragraphs
343
- paragraph.paragraph_format.space_after = Pt(0)
344
- paragraph.paragraph_format.space_before = Pt(0)
345
-
346
- elif block["type"] == 1: # Image block
347
- try:
348
- # Get image reference
349
- image_info = block.get("image")
350
- if image_info:
351
- xref = image_info
352
- else:
353
- xref = block.get("xref", 0)
354
-
355
- if xref and xref > 0:
356
- try:
357
- # Extract image from PDF
358
- base_image = doc.extract_image(xref)
359
- image_bytes = base_image["image"]
360
- image_ext = base_image.get("ext", "png")
361
-
362
- # Get image dimensions from block bbox
363
- bbox = block.get("bbox", [0, 0, 0, 0])
364
- img_width = bbox[2] - bbox[0]
365
- img_height = bbox[3] - bbox[1]
366
-
367
- # Save image to temp file
368
- temp_img = tempfile.NamedTemporaryFile(delete=False, suffix=f".{image_ext}")
369
- temp_img.write(image_bytes)
370
- temp_img.close()
371
-
372
- # Add image to Word document
373
- try:
374
- # Convert PDF points to inches (72 points = 1 inch)
375
- width_inches = img_width / 72
376
- height_inches = img_height / 72
377
-
378
- # Limit max width to 6.5 inches (standard page width with margins)
379
- if width_inches > 6.5:
380
- ratio = 6.5 / width_inches
381
- width_inches = 6.5
382
- height_inches = height_inches * ratio
383
-
384
- word_doc.add_picture(temp_img.name, width=Inches(width_inches))
385
- logger.info(f"Added image: {width_inches:.2f}x{height_inches:.2f} inches")
386
-
387
- # Add spacing after image
388
- word_doc.add_paragraph()
389
- except Exception as img_add_error:
390
- logger.error(f"Failed to add image to document: {img_add_error}")
391
-
392
- # Clean up temp image
393
  try:
394
- os.unlink(temp_img.name)
395
- except:
396
  pass
397
- except Exception as extract_error:
398
- logger.error(f"Failed to extract image xref {xref}: {extract_error}")
399
- else:
400
- logger.warning(f"Invalid image xref: {xref}")
401
- except Exception as img_error:
402
- logger.error(f"Image block processing error: {img_error}")
403
-
404
- # Add page break (except for last page)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  if page_num < len(doc) - 1:
406
  word_doc.add_page_break()
407
-
408
  doc.close()
409
-
410
- # Save Word document
411
  temp_docx = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
412
  word_doc.save(temp_docx.name)
413
  temp_docx.close()
414
-
415
- logger.info(f"Word document created successfully: {temp_docx.name}")
416
-
417
- # Send file
418
- response = send_file(
419
  temp_docx.name,
420
  mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
421
  as_attachment=True,
422
  download_name='converted.docx'
423
  )
424
-
425
- return response
426
-
427
  except Exception as e:
428
- logger.error(f"Error converting PDF to Word: {str(e)}")
429
- import traceback
430
- logger.error(traceback.format_exc())
431
- return jsonify({'error': f'Conversion failed: {str(e)}'}), 500
432
-
433
  finally:
434
- # Cleanup temp files after response is sent
 
 
 
 
435
  try:
436
  if temp_pdf and os.path.exists(temp_pdf.name):
437
  os.unlink(temp_pdf.name)
438
- except Exception as cleanup_error:
439
- logger.warning(f"Cleanup error: {cleanup_error}")
 
440
 
441
  @app.route('/pdf-to-excel', methods=['POST'])
442
  def pdf_to_excel():
443
  temp_pdf = None
444
- temp_excel = None
445
-
446
  try:
447
  if 'pdf' not in request.files:
448
  return jsonify({'error': 'No PDF file provided'}), 400
449
-
450
- pdf_file = request.files['pdf']
451
-
452
- if pdf_file.filename == '':
453
- return jsonify({'error': 'Empty filename'}), 400
454
-
455
- if not pdf_file.filename.lower().endswith('.pdf'):
456
  return jsonify({'error': 'File must be a PDF'}), 400
457
-
458
- # Save uploaded PDF to temp file
459
  temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
460
- pdf_file.save(temp_pdf.name)
461
  temp_pdf.close()
462
-
463
- logger.info(f"Processing PDF to Excel: {pdf_file.filename}")
464
-
465
- # Try LibreOffice conversion first (best quality)
466
- if LIBREOFFICE_AVAILABLE:
467
- try:
468
- # Create output directory
469
- output_dir = tempfile.mkdtemp()
470
-
471
- # Use LibreOffice headless mode for conversion
472
- cmd = [
473
- 'libreoffice',
474
- '--headless',
475
- '--convert-to', 'xlsx',
476
- '--outdir', output_dir,
477
- temp_pdf.name
478
- ]
479
-
480
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
481
-
482
- # LibreOffice creates file with original name + .xlsx
483
- pdf_basename = os.path.basename(temp_pdf.name)
484
- converted_file = os.path.join(output_dir, os.path.splitext(pdf_basename)[0] + '.xlsx')
485
-
486
- if os.path.exists(converted_file) and os.path.getsize(converted_file) > 0:
487
- logger.info(f"LibreOffice conversion successful: {converted_file}")
488
-
489
- response = send_file(
490
- converted_file,
491
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
492
- as_attachment=True,
493
- download_name='converted.xlsx'
494
- )
495
-
496
- # Cleanup
497
- try:
498
- shutil.rmtree(output_dir)
499
- except:
500
- pass
501
-
502
- return response
503
- else:
504
- logger.warning(f"LibreOffice conversion failed: {result.stderr}")
505
- # Cleanup
506
- try:
507
- shutil.rmtree(output_dir)
508
- except:
509
- pass
510
- except Exception as lo_error:
511
- logger.warning(f"LibreOffice conversion error: {lo_error}")
512
-
513
- # Fallback to tabula + PyMuPDF method
514
- logger.info("Using tabula/PyMuPDF fallback conversion")
515
-
516
- # Create Excel workbook
517
  wb = Workbook()
518
- wb.remove(wb.active) # Remove default sheet
519
-
520
- # Try to extract tables using tabula with better settings
521
- tables_extracted = False
522
- try:
523
- # Use multiple strategies for better table detection
524
- tables = tabula.read_pdf(
525
- temp_pdf.name,
526
- pages='all',
527
- multiple_tables=True,
528
- lattice=True, # Better for tables with borders
529
- guess=True, # Auto-detect table areas
530
- pandas_options={'header': None} # Don't assume first row is header
531
- )
532
-
533
- if tables and len(tables) > 0:
534
- logger.info(f"Found {len(tables)} tables using lattice mode")
535
-
536
- for idx, df in enumerate(tables):
537
- if df.empty:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  continue
539
-
540
- sheet_name = f"Table_{idx + 1}"
541
- ws = wb.create_sheet(title=sheet_name)
542
-
543
- # Write data with proper formatting
544
- for row_idx, row in enumerate(df.values, start=1):
545
- for col_idx, value in enumerate(row, start=1):
546
- cell = ws.cell(row=row_idx, column=col_idx)
547
-
548
- # Handle different data types
549
- if value is not None and str(value).strip():
550
- try:
551
- # Try to convert to number
552
- if isinstance(value, str) and value.replace('.', '', 1).replace('-', '', 1).isdigit():
553
- cell.value = float(value)
554
- else:
555
- cell.value = str(value)
556
- except:
557
- cell.value = str(value)
558
-
559
- # Format first row as header
560
- if row_idx == 1:
561
- cell.font = Font(bold=True, size=11)
562
- cell.fill = PatternFill(start_color="E0E0E0", end_color="E0E0E0", fill_type="solid")
563
- cell.alignment = Alignment(horizontal='center', vertical='center')
564
- else:
565
- cell.alignment = Alignment(horizontal='left', vertical='top', wrap_text=True)
566
-
567
- # Auto-adjust column widths
568
- for column in ws.columns:
569
- max_length = 0
570
- column_letter = column[0].column_letter
571
- for cell in column:
572
- try:
573
- if cell.value:
574
- max_length = max(max_length, len(str(cell.value)))
575
- except:
576
- pass
577
- adjusted_width = min(max(max_length + 2, 10), 50)
578
- ws.column_dimensions[column_letter].width = adjusted_width
579
-
580
- # Add borders to all cells
581
- from openpyxl.styles import Border, Side
582
- thin_border = Border(
583
- left=Side(style='thin'),
584
- right=Side(style='thin'),
585
- top=Side(style='thin'),
586
- bottom=Side(style='thin')
587
- )
588
- for row in ws.iter_rows():
589
- for cell in row:
590
- cell.border = thin_border
591
-
592
- tables_extracted = True
593
- logger.info(f"Successfully extracted {len(tables)} tables")
594
- except Exception as table_error:
595
- logger.warning(f"Lattice table extraction failed: {table_error}")
596
-
597
- # Try stream mode if lattice failed
598
- if not tables_extracted:
599
- try:
600
- tables = tabula.read_pdf(
601
- temp_pdf.name,
602
- pages='all',
603
- multiple_tables=True,
604
- stream=True, # Better for tables without borders
605
- guess=True
606
- )
607
-
608
- if tables and len(tables) > 0:
609
- logger.info(f"Found {len(tables)} tables using stream mode")
610
-
611
- for idx, df in enumerate(tables):
612
- if df.empty:
613
- continue
614
-
615
- sheet_name = f"Table_{idx + 1}"
616
- ws = wb.create_sheet(title=sheet_name)
617
-
618
- # Write data
619
- for row_idx, row in enumerate(df.values, start=1):
620
- for col_idx, value in enumerate(row, start=1):
621
- cell = ws.cell(row=row_idx, column=col_idx)
622
- if value is not None and str(value).strip():
623
- cell.value = str(value)
624
-
625
- if row_idx == 1:
626
- cell.font = Font(bold=True, size=11)
627
- cell.fill = PatternFill(start_color="E0E0E0", end_color="E0E0E0", fill_type="solid")
628
- cell.alignment = Alignment(horizontal='center', vertical='center')
629
- else:
630
- cell.alignment = Alignment(horizontal='left', vertical='top', wrap_text=True)
631
-
632
- # Auto-adjust columns
633
- for column in ws.columns:
634
- max_length = 0
635
- column_letter = column[0].column_letter
636
- for cell in column:
637
- try:
638
- if cell.value:
639
- max_length = max(max_length, len(str(cell.value)))
640
- except:
641
- pass
642
- adjusted_width = min(max(max_length + 2, 10), 50)
643
- ws.column_dimensions[column_letter].width = adjusted_width
644
-
645
- tables_extracted = True
646
- logger.info(f"Successfully extracted {len(tables)} tables with stream mode")
647
- except Exception as stream_error:
648
- logger.warning(f"Stream table extraction failed: {stream_error}")
649
-
650
- # Fallback: Extract text using PyMuPDF if no tables found
651
- if not tables_extracted:
652
- logger.info("No tables found, extracting text content")
653
-
654
- doc = fitz.open(temp_pdf.name)
655
-
656
- for page_num in range(len(doc)):
657
- page = doc[page_num]
658
- text = page.get_text()
659
-
660
- if not text.strip():
661
- continue
662
-
663
- sheet_name = f"Page_{page_num + 1}"
664
- ws = wb.create_sheet(title=sheet_name)
665
-
666
- # Split text into lines and write to Excel
667
- lines = text.split('\n')
668
- for row_idx, line in enumerate(lines, start=1):
669
- if line.strip():
670
- # Try to detect columns by splitting on multiple spaces
671
- columns = [col.strip() for col in line.split(' ') if col.strip()]
672
-
673
- if len(columns) > 1:
674
- # Multiple columns detected
675
- for col_idx, col_text in enumerate(columns, start=1):
676
- cell = ws.cell(row=row_idx, column=col_idx, value=col_text)
677
- cell.alignment = Alignment(horizontal='left', vertical='top', wrap_text=True)
678
- else:
679
- # Single column
680
- cell = ws.cell(row=row_idx, column=1, value=line.strip())
681
- cell.alignment = Alignment(horizontal='left', vertical='top', wrap_text=True)
682
-
683
- # Auto-adjust column widths
684
- for column in ws.columns:
685
- max_length = 0
686
- column_letter = column[0].column_letter
687
- for cell in column:
688
- try:
689
- if cell.value:
690
- max_length = max(max_length, len(str(cell.value)))
691
- except:
692
- pass
693
- adjusted_width = min(max(max_length + 2, 10), 100)
694
- ws.column_dimensions[column_letter].width = adjusted_width
695
-
696
- doc.close()
697
- logger.info(f"Extracted text from {len(doc)} pages")
698
-
699
- # If no sheets were created, add a default message
700
- if len(wb.sheetnames) == 0:
701
- ws = wb.create_sheet(title="Data")
702
- ws['A1'] = "No data could be extracted from the PDF"
703
  ws['A1'].font = Font(bold=True)
704
-
705
- # Save Excel file
706
- temp_excel = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
707
- wb.save(temp_excel.name)
708
- temp_excel.close()
709
-
710
- logger.info(f"Excel file created successfully: {temp_excel.name}")
711
-
712
- # Send file
713
- response = send_file(
714
- temp_excel.name,
715
  mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
716
  as_attachment=True,
717
  download_name='converted.xlsx'
718
  )
719
-
720
- return response
721
-
722
  except Exception as e:
723
- logger.error(f"Error converting PDF to Excel: {str(e)}")
724
- import traceback
725
- logger.error(traceback.format_exc())
726
- return jsonify({'error': f'Conversion failed: {str(e)}'}), 500
727
-
728
  finally:
729
- # Cleanup temp files after response is sent
730
  try:
731
  if temp_pdf and os.path.exists(temp_pdf.name):
732
  os.unlink(temp_pdf.name)
733
- except Exception as cleanup_error:
734
- logger.warning(f"Cleanup error: {cleanup_error}")
735
-
736
- def compress_pdf(input_path, output_path, preset="medium", target_kb=None):
737
- doc = fitz.open(input_path)
738
-
739
- preset_options = {
740
- "low": {"garbage": 3, "deflate": True, "clean": True},
741
- "medium": {"garbage": 2, "deflate": True, "clean": True},
742
- "high": {"garbage": 1, "deflate": True, "clean": True},
743
- }
744
- opts = preset_options.get(preset, preset_options["medium"])
745
-
746
- if target_kb:
747
- step = 10
748
- zoom = 1.0
749
- while True:
750
- new_doc = fitz.open()
751
- for page in doc:
752
- pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
753
- rect = fitz.Rect(0, 0, pix.width, pix.height)
754
- new_page = new_doc.new_page(width=pix.width, height=pix.height)
755
- new_page.insert_image(rect, pixmap=pix)
756
-
757
- new_doc.save(output_path, **opts)
758
- size_kb = os.path.getsize(output_path) / 1024
759
- new_doc.close()
760
-
761
- if size_kb <= target_kb or zoom <= 0.3:
762
- break
763
- zoom -= 0.1
764
- else:
765
- doc.save(output_path, **opts)
766
-
767
- doc.close()
768
 
769
  @app.route('/compress', methods=['POST', 'GET'])
770
  def compress():
771
- if request.method == 'POST':
772
- try:
773
- if "pdf" not in request.files:
774
- return jsonify({"success": False, "error": "No file uploaded"}), 400
775
-
776
- file = request.files["pdf"]
777
- if file.filename == '':
778
- return jsonify({"success": False, "error": "Empty filename"}), 400
779
-
780
- preset = request.form.get("preset", "medium")
781
- target_kb = request.form.get("target_kb")
782
-
783
- if target_kb:
784
- try:
785
- target_kb = int(target_kb)
786
- except:
787
- target_kb = None
788
-
789
- filename = secure_filename(file.filename)
790
- input_path = os.path.join(UPLOAD_FOLDER, filename)
791
- output_path = os.path.join(OUTPUT_FOLDER, "compressed_" + filename)
792
- file.save(input_path)
793
-
794
- compress_pdf(input_path, output_path, preset, target_kb)
795
-
796
- return send_file(output_path, as_attachment=True, download_name="compressed_" + filename)
797
- except Exception as e:
798
- logger.error(f"Compression error: {str(e)}")
799
- return jsonify({"success": False, "error": str(e)}), 500
800
-
801
- return jsonify({
802
- 'endpoint': '/compress',
803
- 'method': 'POST',
804
- 'description': 'Compress PDF file',
805
- 'parameters': {
806
- 'pdf': 'PDF file (multipart/form-data)',
807
- 'preset': 'Compression preset: low, medium, high (optional)',
808
- 'target_kb': 'Target file size in KB (optional)'
809
- }
810
- })
 
 
 
 
 
 
 
 
811
 
812
- @app.route("/download/<filename>")
813
  def download(filename):
814
  try:
815
- safe_filename = secure_filename(filename)
816
- return send_file(os.path.join(OUTPUT_FOLDER, safe_filename), as_attachment=True)
817
- except Exception as e:
818
- logger.error(f"Download error: {str(e)}")
819
- return jsonify({"error": "File not found"}), 404
820
 
821
  if __name__ == '__main__':
822
- # Start background task in separate thread
823
- thread = threading.Thread(target=background_task, daemon=True)
824
- thread.start()
825
-
826
  port = int(os.environ.get('PORT', 7860))
827
- logger.info(f"Starting server on port {port}")
828
  app.run(host='0.0.0.0', port=port, debug=False)
829
-
 
 
1
  from flask import Flask, request, send_file, jsonify
2
  from flask_cors import CORS
3
+ import fitz
4
  from docx import Document
5
+ from docx.shared import Pt, RGBColor, Inches, Emu
6
  from docx.enum.text import WD_ALIGN_PARAGRAPH
7
+ from docx.oxml.ns import qn
8
+ from docx.oxml import OxmlElement
9
  from openpyxl import Workbook
10
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
11
  import os
 
 
 
 
 
 
 
 
 
 
 
 
12
  import io
13
  import base64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  import tempfile
15
+ import logging
16
+ import threading
17
+ import time
18
+ import requests
 
 
 
 
 
 
 
 
 
19
  from dotenv import load_dotenv
20
  from werkzeug.utils import secure_filename
21
 
22
+ try:
23
+ import numpy as np
24
+ from PIL import Image
25
+ import onnxruntime as ort
26
+ BG_AVAILABLE = True
27
+ except ImportError:
28
+ BG_AVAILABLE = False
29
 
30
  basedir = os.path.abspath(os.path.dirname(__file__))
31
  load_dotenv(os.path.join(basedir, '.env'))
32
 
 
33
  UPLOAD_FOLDER = os.path.join(basedir, 'uploads')
34
  OUTPUT_FOLDER = os.path.join(basedir, 'outputs')
35
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
36
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
37
 
38
+ app = Flask(__name__)
39
  CORS(app)
 
 
40
 
41
  logging.basicConfig(level=logging.INFO)
42
  logger = logging.getLogger(__name__)
43
 
44
+ session = None
45
+ if BG_AVAILABLE:
46
+ try:
47
+ model_path = os.path.join(basedir, 'u2net.onnx')
48
+ if os.path.exists(model_path):
49
+ session = ort.InferenceSession(model_path)
50
+ logger.info("ONNX model loaded")
51
+ except Exception as e:
52
+ logger.warning(f"ONNX not loaded: {e}")
53
 
54
 
55
+ def background_ping():
56
+ while True:
57
+ try:
58
+ url = os.environ.get('url')
59
+ if url:
60
+ requests.get(url, timeout=10)
61
+ except Exception:
62
+ pass
63
+ time.sleep(300)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
 
 
 
 
 
65
 
66
+ # ── helpers ──────────────────────────────────────────────────────────────────
67
+
68
+ def set_cell_border(cell):
69
+ tc = cell._tc
70
+ tcPr = tc.get_or_add_tcPr()
71
+ tcBorders = OxmlElement('w:tcBorders')
72
+ for edge in ('top', 'left', 'bottom', 'right'):
73
+ el = OxmlElement(f'w:{edge}')
74
+ el.set(qn('w:val'), 'single')
75
+ el.set(qn('w:sz'), '4')
76
+ el.set(qn('w:color'), '000000')
77
+ tcBorders.append(el)
78
+ tcPr.append(tcBorders)
79
+
80
 
81
+ def pt_to_emu(pt):
82
+ return int(pt * 12700)
83
+
84
+
85
+ def detect_tables(page):
86
+ """Detect table regions using line analysis."""
87
+ tables = []
88
+ try:
89
+ paths = page.get_drawings()
90
+ rects = [p['rect'] for p in paths if p.get('rect') and p['rect'].width > 10 and p['rect'].height > 10]
91
+ if len(rects) > 3:
92
+ # Cluster overlapping rects into table regions
93
+ merged = []
94
+ for r in rects:
95
+ found = False
96
+ for m in merged:
97
+ if abs(r.x0 - m.x0) < 50 and abs(r.y0 - m.y0) < 100:
98
+ m.x0 = min(m.x0, r.x0)
99
+ m.y0 = min(m.y0, r.y0)
100
+ m.x1 = max(m.x1, r.x1)
101
+ m.y1 = max(m.y1, r.y1)
102
+ found = True
103
+ break
104
+ if not found:
105
+ merged.append(fitz.Rect(r))
106
+ tables = [r for r in merged if r.width > 50 and r.height > 20]
107
+ except Exception:
108
+ pass
109
+ return tables
110
+
111
+
112
+ def extract_table_data(page, table_rect):
113
+ """Extract text from table region grouped by rows/cols."""
114
+ words = page.get_text("words", clip=table_rect)
115
+ if not words:
116
+ return []
117
+ words.sort(key=lambda w: (round(w[1] / 5) * 5, w[0]))
118
+ rows = {}
119
+ for w in words:
120
+ row_key = round(w[1] / 8) * 8
121
+ rows.setdefault(row_key, []).append(w)
122
+ return [sorted(r, key=lambda w: w[0]) for r in sorted(rows.values(), key=lambda r: r[0][1])]
123
+
124
+
125
+ # ── routes ────────────────────────────────────────────────────────────────────
126
 
127
  @app.route('/')
128
  def index():
129
  return jsonify({
130
+ 'service': 'AI One API',
131
+ 'version': '3.0',
132
+ 'endpoints': ['/health', '/pdf-to-word', '/pdf-to-excel', '/compress', '/remove']
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  })
134
 
135
+
136
  @app.route('/health')
137
  def health():
138
  return jsonify({
139
  'status': 'healthy',
140
+ 'background_removal': BG_AVAILABLE and session is not None
 
 
 
 
 
 
 
 
141
  })
142
 
143
 
144
+ @app.route('/remove', methods=['POST'])
145
+ def remove():
146
+ if not BG_AVAILABLE or session is None:
147
+ return jsonify({'error': 'Background removal not available'}), 503
148
+ try:
149
+ if 'image' not in request.files:
150
+ return jsonify({'error': 'No image provided'}), 400
151
+ img = Image.open(request.files['image'].stream)
152
+ orig_size = img.size
153
+ inp = img.convert('RGB').resize((320, 320))
154
+ arr = np.expand_dims(np.transpose(np.array(inp).astype(np.float32) / 255.0, (2, 0, 1)), 0)
155
+ mask = session.run(None, {'input.1': arr})[0][0].squeeze()
156
+ mask = (mask - mask.min()) / (mask.max() - mask.min())
157
+ mask = Image.fromarray((mask * 255).astype(np.uint8)).resize(orig_size)
158
+ out = img.convert('RGBA')
159
+ out.putdata([(r, g, b, m) for (r, g, b, a), m in zip(out.getdata(), mask.getdata())])
160
+ buf = io.BytesIO()
161
+ out.save(buf, format='PNG')
162
+ return jsonify({'image': 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode()})
163
+ except Exception as e:
164
+ return jsonify({'error': str(e)}), 500
165
+
166
+
167
  @app.route('/pdf-to-word', methods=['POST'])
168
  def pdf_to_word():
169
  temp_pdf = None
170
  temp_docx = None
171
+ img_temps = []
172
  try:
173
  if 'pdf' not in request.files:
174
  return jsonify({'error': 'No PDF file provided'}), 400
175
+ f = request.files['pdf']
176
+ if not f.filename.lower().endswith('.pdf'):
 
 
 
 
 
177
  return jsonify({'error': 'File must be a PDF'}), 400
178
+
 
179
  temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
180
+ f.save(temp_pdf.name)
181
  temp_pdf.close()
182
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  doc = fitz.open(temp_pdf.name)
184
  word_doc = Document()
185
+
186
+ # Page setup matching PDF
187
+ first_page = doc[0]
188
+ pdf_w = first_page.rect.width
189
+ pdf_h = first_page.rect.height
190
+ is_landscape = pdf_w > pdf_h
191
+
192
+ for section in word_doc.sections:
193
+ if is_landscape:
194
+ section.page_width = Inches(pdf_h / 72)
195
+ section.page_height = Inches(pdf_w / 72)
196
+ section.orientation = 1 # landscape
197
+ else:
198
+ section.page_width = Inches(pdf_w / 72)
199
+ section.page_height = Inches(pdf_h / 72)
200
  section.top_margin = Inches(0.5)
201
  section.bottom_margin = Inches(0.5)
202
+ section.left_margin = Inches(0.5)
203
+ section.right_margin = Inches(0.5)
204
+
205
+ usable_width = (pdf_w / 72) - 1.0 # inches after margins
206
+
207
+ for page_num, page in enumerate(doc):
 
 
208
  page_width = page.rect.width
209
+
210
+ # Collect all blocks sorted top-to-bottom, left-to-right
211
+ blocks = sorted(
212
+ page.get_text("dict")["blocks"],
213
+ key=lambda b: (round(b["bbox"][1] / 5) * 5, b["bbox"][0])
214
+ )
215
+
216
+ # Detect table regions on this page
217
+ table_rects = detect_tables(page)
218
+ table_bboxes = [list(r) for r in table_rects]
219
+
220
+ def in_table(bbox):
221
+ for tr in table_bboxes:
222
+ if bbox[0] >= tr[0] - 5 and bbox[1] >= tr[1] - 5 and bbox[2] <= tr[2] + 5 and bbox[3] <= tr[3] + 5:
223
+ return True
224
+ return False
225
+
226
+ # Render tables first
227
+ rendered_tables = set()
228
+ for ti, tr in enumerate(table_rects):
229
+ rows = extract_table_data(page, tr)
230
+ if not rows or ti in rendered_tables:
231
+ continue
232
+ rendered_tables.add(ti)
233
+ num_cols = max(len(r) for r in rows)
234
+ if num_cols == 0:
235
+ continue
236
+ tbl = word_doc.add_table(rows=len(rows), cols=num_cols)
237
+ tbl.style = 'Table Grid'
238
+ for ri, row in enumerate(rows):
239
+ for ci, word_item in enumerate(row):
240
+ if ci < num_cols:
241
+ cell = tbl.cell(ri, ci)
242
+ cell.text = word_item[4]
243
+ cell.paragraphs[0].runs[0].font.size = Pt(9) if cell.paragraphs[0].runs else None
244
+ set_cell_border(cell)
245
+ word_doc.add_paragraph()
246
+
247
+ # Process text and image blocks
248
  for block in blocks:
249
+ if block["type"] == 0:
250
+ if in_table(block["bbox"]):
251
+ continue
252
+
253
+ for line in block.get("lines", []):
254
+ bbox = line["bbox"]
255
+ cx = (bbox[0] + bbox[2]) / 2
256
+ line_width = bbox[2] - bbox[0]
257
+
258
+ para = word_doc.add_paragraph()
259
+ pf = para.paragraph_format
260
+ pf.space_before = Pt(0)
261
+ pf.space_after = Pt(1)
262
+
263
+ # Left indent
264
+ left_indent = bbox[0] / 72
265
+ if left_indent > 0.1:
266
+ pf.left_indent = Inches(min(left_indent, usable_width * 0.5))
267
+
268
+ # Alignment
269
+ center_dist = abs(cx - page_width / 2)
270
+ if center_dist < page_width * 0.08 and line_width < page_width * 0.6:
271
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
272
+ elif bbox[0] > page_width * 0.55:
273
+ para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
274
  else:
275
+ para.alignment = WD_ALIGN_PARAGRAPH.LEFT
276
+
 
277
  for span in line.get("spans", []):
278
  text = span.get("text", "")
279
+ if not text:
280
  continue
281
+ run = para.add_run(text)
282
+
283
+ # Font name
284
+ fname = span.get("font", "Calibri")
285
+ if '+' in fname:
286
+ fname = fname.split('+', 1)[1]
287
+ # Map common PDF fonts to Word fonts
288
+ font_map = {
289
+ 'TimesNewRoman': 'Times New Roman',
290
+ 'Arial': 'Arial',
291
+ 'Helvetica': 'Arial',
292
+ 'Courier': 'Courier New',
293
+ 'Georgia': 'Georgia',
294
+ 'Verdana': 'Verdana',
295
+ }
296
+ for k, v in font_map.items():
297
+ if k.lower() in fname.lower():
298
+ fname = v
299
+ break
300
  try:
301
+ run.font.name = fname
302
+ except Exception:
303
+ run.font.name = 'Calibri'
304
+
305
+ # Size
306
+ size = span.get("size", 11)
307
+ run.font.size = Pt(round(size * 10) / 10)
308
+
309
+ # Style flags
310
  flags = span.get("flags", 0)
311
+ run.font.bold = bool(flags & 16)
312
+ run.font.italic = bool(flags & 2)
313
+ run.font.underline = bool(flags & 4)
314
+
315
+ # Color
316
+ c = span.get("color", 0)
317
+ if c and c != 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  try:
319
+ run.font.color.rgb = RGBColor((c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF)
320
+ except Exception:
321
  pass
322
+
323
+ elif block["type"] == 1:
324
+ xref = block.get("xref", 0)
325
+ if xref > 0:
326
+ try:
327
+ img_data = doc.extract_image(xref)
328
+ bbox = block["bbox"]
329
+ img_w_pt = bbox[2] - bbox[0]
330
+ img_h_pt = bbox[3] - bbox[1]
331
+ w_in = min(img_w_pt / 72, usable_width)
332
+ h_in = (img_h_pt / img_w_pt) * w_in if img_w_pt > 0 else w_in
333
+
334
+ ext = img_data.get('ext', 'png')
335
+ if ext == 'jpx':
336
+ ext = 'png'
337
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}')
338
+ tmp.write(img_data["image"])
339
+ tmp.close()
340
+ img_temps.append(tmp.name)
341
+
342
+ para = word_doc.add_paragraph()
343
+ # Center image if it spans most of page width
344
+ if w_in > usable_width * 0.5:
345
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
346
+ run = para.add_run()
347
+ run.add_picture(tmp.name, width=Inches(w_in))
348
+ word_doc.add_paragraph().paragraph_format.space_after = Pt(2)
349
+ except Exception as e:
350
+ logger.warning(f"Image skip xref={xref}: {e}")
351
+
352
  if page_num < len(doc) - 1:
353
  word_doc.add_page_break()
354
+
355
  doc.close()
356
+
 
357
  temp_docx = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
358
  word_doc.save(temp_docx.name)
359
  temp_docx.close()
360
+
361
+ return send_file(
 
 
 
362
  temp_docx.name,
363
  mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
364
  as_attachment=True,
365
  download_name='converted.docx'
366
  )
367
+
 
 
368
  except Exception as e:
369
+ logger.error(f"PDF to Word error: {e}", exc_info=True)
370
+ return jsonify({'error': str(e)}), 500
 
 
 
371
  finally:
372
+ for t in img_temps:
373
+ try:
374
+ os.unlink(t)
375
+ except Exception:
376
+ pass
377
  try:
378
  if temp_pdf and os.path.exists(temp_pdf.name):
379
  os.unlink(temp_pdf.name)
380
+ except Exception:
381
+ pass
382
+
383
 
384
  @app.route('/pdf-to-excel', methods=['POST'])
385
  def pdf_to_excel():
386
  temp_pdf = None
387
+ temp_xlsx = None
 
388
  try:
389
  if 'pdf' not in request.files:
390
  return jsonify({'error': 'No PDF file provided'}), 400
391
+ f = request.files['pdf']
392
+ if not f.filename.lower().endswith('.pdf'):
 
 
 
 
 
393
  return jsonify({'error': 'File must be a PDF'}), 400
394
+
 
395
  temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
396
+ f.save(temp_pdf.name)
397
  temp_pdf.close()
398
+
399
+ doc = fitz.open(temp_pdf.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  wb = Workbook()
401
+ wb.remove(wb.active)
402
+
403
+ thin = Border(
404
+ left=Side(style='thin'), right=Side(style='thin'),
405
+ top=Side(style='thin'), bottom=Side(style='thin')
406
+ )
407
+ header_fill = PatternFill("solid", fgColor="D9D9D9")
408
+ header_font = Font(bold=True, size=11)
409
+
410
+ for page_num, page in enumerate(doc):
411
+ page_width = page.rect.width
412
+
413
+ # Extract words with position info
414
+ words = page.get_text("words")
415
+ if not words:
416
+ continue
417
+
418
+ # Group words into rows by Y position (tolerance 3pt)
419
+ rows = {}
420
+ for w in words:
421
+ y_key = round(w[1] / 3) * 3
422
+ rows.setdefault(y_key, []).append(w)
423
+
424
+ sorted_rows = [sorted(r, key=lambda w: w[0]) for r in sorted(rows.values(), key=lambda r: r[0][1])]
425
+
426
+ if not sorted_rows:
427
+ continue
428
+
429
+ # Detect columns by X position clustering
430
+ all_x = sorted(set(round(w[0] / 10) * 10 for row in sorted_rows for w in row))
431
+ col_positions = []
432
+ for x in all_x:
433
+ if not col_positions or x - col_positions[-1] > 20:
434
+ col_positions.append(x)
435
+
436
+ def get_col_idx(x):
437
+ best = 0
438
+ best_dist = abs(x - col_positions[0])
439
+ for i, cx in enumerate(col_positions):
440
+ d = abs(x - cx)
441
+ if d < best_dist:
442
+ best_dist = d
443
+ best = i
444
+ return best
445
+
446
+ ws = wb.create_sheet(f"Page_{page_num + 1}")
447
+ num_cols = max(len(col_positions), 1)
448
+
449
+ # Detect if first row is a header (larger font or bold)
450
+ first_row_blocks = page.get_text("dict")["blocks"]
451
+ header_y = sorted_rows[0][0][1] if sorted_rows else 0
452
+ is_header_row = False
453
+ for blk in first_row_blocks:
454
+ if blk["type"] == 0:
455
+ for line in blk.get("lines", []):
456
+ if abs(line["bbox"][1] - header_y) < 5:
457
+ for span in line.get("spans", []):
458
+ if span.get("flags", 0) & 16 or span.get("size", 0) > 12:
459
+ is_header_row = True
460
+
461
+ for ri, row in enumerate(sorted_rows, 1):
462
+ # Merge words that are close together into cells
463
+ cells = {}
464
+ for w in row:
465
+ ci = get_col_idx(w[0])
466
+ cells[ci] = (cells.get(ci, '') + ' ' + w[4]).strip()
467
+
468
+ for ci, text in cells.items():
469
+ if ci >= num_cols:
470
  continue
471
+ cell = ws.cell(ri, ci + 1)
472
+ # Try numeric
473
+ clean = text.replace(',', '').strip()
474
+ try:
475
+ cell.value = int(clean) if clean.lstrip('-').isdigit() else float(clean)
476
+ except Exception:
477
+ cell.value = text
478
+
479
+ if ri == 1 and is_header_row:
480
+ cell.font = header_font
481
+ cell.fill = header_fill
482
+ cell.alignment = Alignment(horizontal='center', vertical='center')
483
+ else:
484
+ cell.alignment = Alignment(horizontal='left', vertical='top', wrap_text=True)
485
+ cell.border = thin
486
+
487
+ # Auto column widths
488
+ for col in ws.columns:
489
+ w = max((len(str(c.value)) for c in col if c.value is not None), default=8)
490
+ ws.column_dimensions[col[0].column_letter].width = min(max(w + 2, 8), 50)
491
+
492
+ # Freeze header row
493
+ if is_header_row:
494
+ ws.freeze_panes = 'A2'
495
+
496
+ doc.close()
497
+
498
+ if not wb.sheetnames:
499
+ ws = wb.create_sheet("Data")
500
+ ws['A1'] = "No data extracted"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  ws['A1'].font = Font(bold=True)
502
+
503
+ temp_xlsx = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
504
+ wb.save(temp_xlsx.name)
505
+ temp_xlsx.close()
506
+
507
+ return send_file(
508
+ temp_xlsx.name,
 
 
 
 
509
  mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
510
  as_attachment=True,
511
  download_name='converted.xlsx'
512
  )
513
+
 
 
514
  except Exception as e:
515
+ logger.error(f"PDF to Excel error: {e}", exc_info=True)
516
+ return jsonify({'error': str(e)}), 500
 
 
 
517
  finally:
 
518
  try:
519
  if temp_pdf and os.path.exists(temp_pdf.name):
520
  os.unlink(temp_pdf.name)
521
+ except Exception:
522
+ pass
523
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
525
  @app.route('/compress', methods=['POST', 'GET'])
526
  def compress():
527
+ if request.method == 'GET':
528
+ return jsonify({'endpoint': '/compress', 'method': 'POST', 'params': ['pdf', 'preset', 'target_kb']})
529
+ try:
530
+ if 'pdf' not in request.files:
531
+ return jsonify({'error': 'No file uploaded'}), 400
532
+ file = request.files['pdf']
533
+ preset = request.form.get('preset', 'medium')
534
+ target_kb = request.form.get('target_kb')
535
+ if target_kb:
536
+ try:
537
+ target_kb = int(target_kb)
538
+ except Exception:
539
+ target_kb = None
540
+
541
+ filename = secure_filename(file.filename)
542
+ input_path = os.path.join(UPLOAD_FOLDER, filename)
543
+ output_path = os.path.join(OUTPUT_FOLDER, 'compressed_' + filename)
544
+ file.save(input_path)
545
+
546
+ opts = {
547
+ 'low': {'garbage': 3, 'deflate': True, 'clean': True},
548
+ 'medium': {'garbage': 2, 'deflate': True, 'clean': True},
549
+ 'high': {'garbage': 1, 'deflate': True, 'clean': True},
550
+ }.get(preset, {'garbage': 2, 'deflate': True, 'clean': True})
551
+
552
+ doc = fitz.open(input_path)
553
+ if target_kb:
554
+ zoom = 1.0
555
+ while zoom > 0.3:
556
+ new_doc = fitz.open()
557
+ for page in doc:
558
+ pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
559
+ np_ = new_doc.new_page(width=pix.width, height=pix.height)
560
+ np_.insert_image(fitz.Rect(0, 0, pix.width, pix.height), pixmap=pix)
561
+ new_doc.save(output_path, **opts)
562
+ new_doc.close()
563
+ if os.path.getsize(output_path) / 1024 <= target_kb:
564
+ break
565
+ zoom -= 0.1
566
+ else:
567
+ doc.save(output_path, **opts)
568
+ doc.close()
569
+
570
+ return send_file(output_path, as_attachment=True, download_name='compressed_' + filename)
571
+ except Exception as e:
572
+ logger.error(f"Compress error: {e}")
573
+ return jsonify({'error': str(e)}), 500
574
+
575
 
576
+ @app.route('/download/<filename>')
577
  def download(filename):
578
  try:
579
+ return send_file(os.path.join(OUTPUT_FOLDER, secure_filename(filename)), as_attachment=True)
580
+ except Exception:
581
+ return jsonify({'error': 'File not found'}), 404
582
+
 
583
 
584
  if __name__ == '__main__':
585
+ threading.Thread(target=background_ping, daemon=True).start()
 
 
 
586
  port = int(os.environ.get('PORT', 7860))
587
+ logger.info(f"Starting on port {port}")
588
  app.run(host='0.0.0.0', port=port, debug=False)
 
packages.txt CHANGED
@@ -1,4 +1 @@
1
- default-jre
2
- libreoffice
3
- libreoffice-writer
4
- libreoffice-calc
 
1
+ curl
 
 
 
requirements.txt CHANGED
@@ -1,33 +1,10 @@
1
- # Core dependencies
2
- flask==3.0.0
3
- flask-cors==4.0.0
4
- gunicorn==21.2.0
5
- requests==2.31.0
6
- python-dotenv==1.0.0
7
-
8
- # PDF processing
9
- PyMuPDF==1.23.8
10
- python-docx==1.1.0
11
- tabula-py==2.9.0
12
- openpyxl==3.1.2
13
- pandas==2.1.4
14
-
15
- onnxruntime
16
- pillow
17
- numpy
18
-
19
- ####################
20
- pdf2docx
21
- pdfplumber
22
- PyMuPDF
23
- pandas
24
- openpyxl
25
- pymupdf
26
- PyPDF2
27
- flask-cors
28
-
29
- google-genai
30
-
31
-
32
- dotenv
33
- requests
 
1
+ flask==3.0.3
2
+ flask-cors==4.0.1
3
+ gunicorn==22.0.0
4
+ requests==2.32.3
5
+ python-dotenv==1.0.1
6
+ werkzeug==3.0.3
7
+ PyMuPDF==1.24.5
8
+ python-docx==1.1.2
9
+ openpyxl==3.1.5
10
+ lxml==5.2.2