cstr commited on
Commit
1a97bea
Β·
verified Β·
1 Parent(s): 94bccfa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +326 -34
app.py CHANGED
@@ -41,7 +41,31 @@ def check_and_setup_environment():
41
  """
42
  status_messages = []
43
 
44
- # 1. Check CTranslate2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  try:
46
  import ctranslate2
47
  status_messages.append("βœ“ CTranslate2 installed")
@@ -53,16 +77,14 @@ def check_and_setup_environment():
53
  except Exception as e:
54
  status_messages.append(f"βœ— CTranslate2 installation failed: {e}")
55
 
56
- # 2. Check fast_align (optional, complex to build on HF Spaces)
57
  fast_align_path = shutil.which("fast_align")
58
  if fast_align_path:
59
  status_messages.append(f"βœ“ fast_align found at {fast_align_path}")
60
  else:
61
  status_messages.append("β„Ή fast_align not available (optional - will use other aligners)")
62
- # Note: Building fast_align on HF Spaces is challenging due to build dependencies
63
- # We'll rely on the Python-based aligners instead
64
 
65
- # 3. Check for API keys (optional)
66
  if os.getenv("OPENAI_API_KEY"):
67
  status_messages.append("βœ“ OpenAI API key detected")
68
  if os.getenv("ANTHROPIC_API_KEY"):
@@ -76,6 +98,70 @@ def check_and_setup_environment():
76
  SETUP_STATUS = check_and_setup_environment()
77
  logger.info(f"Setup complete:\n{SETUP_STATUS}")
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # ============================================================================
80
  # TRANSLATION FUNCTION
81
  # ============================================================================
@@ -92,7 +178,7 @@ async def translate_document_async(
92
  progress=gr.Progress()
93
  ) -> Tuple[Optional[str], str]:
94
  """
95
- Asynchronous document translation with progress tracking.
96
 
97
  Returns:
98
  Tuple of (output_file_path, log_messages)
@@ -107,12 +193,20 @@ async def translate_document_async(
107
  try:
108
  # Setup paths
109
  input_path = Path(input_file.name)
110
- output_filename = f"{input_path.stem}_translated_{source_lang}_{target_lang}.docx"
 
 
 
 
 
 
 
 
 
111
  output_path = temp_dir / output_filename
112
 
113
- # Validate file type
114
- if not input_path.suffix.lower() == '.docx':
115
- return None, "❌ Error: Only .docx files are supported"
116
 
117
  # Map UI selections to enums
118
  mode_map = {
@@ -133,7 +227,7 @@ async def translate_document_async(
133
  log_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
134
  logging.getLogger().addHandler(log_handler)
135
 
136
- progress(0.1, desc="Initializing translator...")
137
 
138
  # Initialize translator
139
  translator = UltimateDocumentTranslator(
@@ -146,21 +240,29 @@ async def translate_document_async(
146
  nllb_model_size=nllb_size
147
  )
148
 
149
- progress(0.2, desc="Processing document...")
 
 
 
 
150
 
151
- # Translate
152
- await translator.translate_document(input_path, output_path)
153
 
154
  progress(1.0, desc="Translation complete!")
155
 
156
  # Cleanup log handler
157
  logging.getLogger().removeHandler(log_handler)
158
 
 
 
 
159
  # Format log output
160
  log_output = "\n".join(log_messages[-50:]) # Last 50 messages
161
  success_msg = f"""
162
  βœ… Translation Complete!
163
 
 
164
  πŸ“„ Input: {input_path.name}
165
  πŸ“„ Output: {output_filename}
166
  🌍 Direction: {source_lang.upper()} β†’ {target_lang.upper()}
@@ -173,6 +275,15 @@ Recent Logs:
173
 
174
  return str(output_path), success_msg
175
 
 
 
 
 
 
 
 
 
 
176
  except Exception as e:
177
  error_msg = f"❌ Translation Error:\n{str(e)}\n\nPlease check your settings and try again."
178
  logger.error(f"Translation failed: {e}", exc_info=True)
@@ -242,9 +353,11 @@ def translate_document_sync(*args, **kwargs):
242
  # ============================================================================
243
 
244
  def create_interface():
245
- """Create the Gradio interface"""
 
 
246
 
247
- # Language options (common pairs)
248
  languages = {
249
  "English": "en",
250
  "German": "de",
@@ -266,11 +379,15 @@ def create_interface():
266
  "Vietnamese": "vi"
267
  }
268
 
269
- with gr.Blocks(title="Document Translator") as demo: # REMOVED theme parameter
 
270
  gr.Markdown("""
271
  # 🌍 Document Translator
272
 
273
- Translate Word documents while preserving formatting, footnotes, and styling.
 
 
 
274
  """)
275
 
276
  with gr.Row():
@@ -278,8 +395,8 @@ def create_interface():
278
  gr.Markdown("### πŸ“€ Input")
279
 
280
  input_file = gr.File(
281
- label="Upload Document (.docx)",
282
- file_types=[".docx"],
283
  type="filepath"
284
  )
285
 
@@ -353,35 +470,86 @@ def create_interface():
353
  max_lines=30,
354
  interactive=False
355
  )
 
 
 
 
 
 
 
 
356
 
357
  gr.Markdown(f"### System Status\n```\n{SETUP_STATUS}\n```")
358
 
359
  gr.Markdown("""
360
- **Features:**
361
- - Multiple neural translation backends (NLLB, Madlad, Opus-MT, WMT21)
362
- - Word-level alignment for format preservation
363
- - Support for footnotes, tables, headers/footers
 
 
 
 
 
 
 
 
 
 
 
364
  - Optional LLM enhancement (OpenAI/Anthropic)
 
365
 
366
  **Recommended Settings:**
367
  - Mode: Hybrid (best quality)
368
  - Backend: NLLB (fastest, good quality)
369
- - Size: 600M (good balance)
370
 
371
- ### πŸ“– Tips
 
 
 
 
 
372
 
373
- - **For best quality**: Use "Hybrid" mode with NLLB backend
374
- - **For speed**: Use "NMT Only" with NLLB 600M
375
- - **For academic texts**: Try Madlad backend
376
- - **For specific language pairs**: Opus-MT (if available)
377
- - **LLM modes**: Require API keys set as environment variables
 
 
 
 
378
 
379
  ### ⚠️ Limitations
380
 
381
- - Only .docx format supported (not .doc)
382
- - Large documents may take several minutes
383
- - Complex formatting may require manual review
384
  - LLM modes are slower and require API access
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  """)
386
 
387
  def handle_translate(input_f, src_lang_name, tgt_lang_name, mode, nmt, nllb_sz, algn, llm):
@@ -406,6 +574,130 @@ def create_interface():
406
 
407
  return demo
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  # ============================================================================
410
  # MAIN
411
  # ============================================================================
 
41
  """
42
  status_messages = []
43
 
44
+ # 1. Check python-docx
45
+ try:
46
+ from docx import Document
47
+ status_messages.append("βœ“ python-docx installed")
48
+ except ImportError:
49
+ status_messages.append("⚠ python-docx not found - installing...")
50
+ try:
51
+ subprocess.run([sys.executable, "-m", "pip", "install", "python-docx"], check=True)
52
+ status_messages.append("βœ“ python-docx installed successfully")
53
+ except Exception as e:
54
+ status_messages.append(f"βœ— python-docx installation failed: {e}")
55
+
56
+ # 2. Check python-pptx
57
+ try:
58
+ from pptx import Presentation
59
+ status_messages.append("βœ“ python-pptx installed")
60
+ except ImportError:
61
+ status_messages.append("⚠ python-pptx not found - installing...")
62
+ try:
63
+ subprocess.run([sys.executable, "-m", "pip", "install", "python-pptx"], check=True)
64
+ status_messages.append("βœ“ python-pptx installed successfully")
65
+ except Exception as e:
66
+ status_messages.append(f"βœ— python-pptx installation failed: {e}")
67
+
68
+ # 3. Check CTranslate2
69
  try:
70
  import ctranslate2
71
  status_messages.append("βœ“ CTranslate2 installed")
 
77
  except Exception as e:
78
  status_messages.append(f"βœ— CTranslate2 installation failed: {e}")
79
 
80
+ # 4. Check fast_align (optional, complex to build on HF Spaces)
81
  fast_align_path = shutil.which("fast_align")
82
  if fast_align_path:
83
  status_messages.append(f"βœ“ fast_align found at {fast_align_path}")
84
  else:
85
  status_messages.append("β„Ή fast_align not available (optional - will use other aligners)")
 
 
86
 
87
+ # 5. Check for API keys (optional)
88
  if os.getenv("OPENAI_API_KEY"):
89
  status_messages.append("βœ“ OpenAI API key detected")
90
  if os.getenv("ANTHROPIC_API_KEY"):
 
98
  SETUP_STATUS = check_and_setup_environment()
99
  logger.info(f"Setup complete:\n{SETUP_STATUS}")
100
 
101
+ # ============================================================================
102
+ # FILE TYPE DETECTION & VALIDATION
103
+ # ============================================================================
104
+
105
+ def detect_and_validate_file(file_path: Path) -> Tuple[bool, str, str]:
106
+ """
107
+ Detect file type and validate it.
108
+
109
+ Returns:
110
+ Tuple of (is_valid, file_type, error_message)
111
+ file_type: 'docx', 'pptx', or 'unknown'
112
+ """
113
+ if not file_path.exists():
114
+ return False, 'unknown', f"File not found: {file_path}"
115
+
116
+ suffix = file_path.suffix.lower()
117
+
118
+ # Quick check by extension
119
+ if suffix == '.docx':
120
+ try:
121
+ from docx import Document
122
+ doc = Document(str(file_path))
123
+ return True, 'docx', ""
124
+ except Exception as e:
125
+ return False, 'docx', f"Invalid Word document: {str(e)}"
126
+
127
+ elif suffix == '.pptx':
128
+ try:
129
+ from pptx import Presentation
130
+ prs = Presentation(str(file_path))
131
+ return True, 'pptx', ""
132
+ except Exception as e:
133
+ return False, 'pptx', f"Invalid PowerPoint presentation: {str(e)}"
134
+
135
+ else:
136
+ return False, 'unknown', "Unsupported format. Only .docx and .pptx files are supported."
137
+
138
+
139
+ def get_file_info(file_path: Path, file_type: str) -> str:
140
+ """
141
+ Get basic info about the uploaded file.
142
+
143
+ Returns formatted string with file statistics.
144
+ """
145
+ try:
146
+ if file_type == 'docx':
147
+ from docx import Document
148
+ doc = Document(str(file_path))
149
+ para_count = len([p for p in doc.paragraphs if p.text.strip()])
150
+ table_count = len(doc.tables)
151
+ return f"πŸ“„ Word Document | {para_count} paragraphs | {table_count} tables"
152
+
153
+ elif file_type == 'pptx':
154
+ from pptx import Presentation
155
+ prs = Presentation(str(file_path))
156
+ slide_count = len(prs.slides)
157
+ shape_count = sum(len(slide.shapes) for slide in prs.slides)
158
+ return f"πŸ“Š PowerPoint Presentation | {slide_count} slides | ~{shape_count} shapes"
159
+
160
+ except Exception as e:
161
+ return f"πŸ“Ž {file_type.upper()} file"
162
+
163
+ return "πŸ“Ž Document"
164
+
165
  # ============================================================================
166
  # TRANSLATION FUNCTION
167
  # ============================================================================
 
178
  progress=gr.Progress()
179
  ) -> Tuple[Optional[str], str]:
180
  """
181
+ Asynchronous document translation with progress tracking. Handles both .docx and .pptx files
182
 
183
  Returns:
184
  Tuple of (output_file_path, log_messages)
 
193
  try:
194
  # Setup paths
195
  input_path = Path(input_file.name)
196
+
197
+ # Detect file type and validate
198
+ is_valid, file_type, error_msg = detect_and_validate_file(input_path)
199
+
200
+ if not is_valid:
201
+ return None, f"❌ Error: {error_msg}"
202
+
203
+ # Dynamic output filename based on detected type
204
+ output_extension = input_path.suffix # Preserve original extension
205
+ output_filename = f"{input_path.stem}_translated_{source_lang}_{target_lang}{output_extension}"
206
  output_path = temp_dir / output_filename
207
 
208
+ # Get file info for display
209
+ file_info = get_file_info(input_path, file_type)
 
210
 
211
  # Map UI selections to enums
212
  mode_map = {
 
227
  log_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
228
  logging.getLogger().addHandler(log_handler)
229
 
230
+ progress(0.1, desc=f"Initializing translator for {file_type.upper()}...")
231
 
232
  # Initialize translator
233
  translator = UltimateDocumentTranslator(
 
240
  nllb_model_size=nllb_size
241
  )
242
 
243
+ # Progress message based on file type
244
+ if file_type == 'docx':
245
+ progress(0.2, desc="Processing Word document...")
246
+ else:
247
+ progress(0.2, desc="Processing PowerPoint slides...")
248
 
249
+ # Use unified translate_file() method instead of translate_document()
250
+ await translator.translate_file(input_path, output_path)
251
 
252
  progress(1.0, desc="Translation complete!")
253
 
254
  # Cleanup log handler
255
  logging.getLogger().removeHandler(log_handler)
256
 
257
+ # Format-aware success message
258
+ format_name = "Word Document" if file_type == 'docx' else "PowerPoint Presentation"
259
+
260
  # Format log output
261
  log_output = "\n".join(log_messages[-50:]) # Last 50 messages
262
  success_msg = f"""
263
  βœ… Translation Complete!
264
 
265
+ {file_info}
266
  πŸ“„ Input: {input_path.name}
267
  πŸ“„ Output: {output_filename}
268
  🌍 Direction: {source_lang.upper()} β†’ {target_lang.upper()}
 
275
 
276
  return str(output_path), success_msg
277
 
278
+ except ImportError as e:
279
+ error_msg = f"❌ Missing Library Error:\n{str(e)}\n\n"
280
+ if 'docx' in str(e):
281
+ error_msg += "Install with: pip install python-docx"
282
+ elif 'pptx' in str(e):
283
+ error_msg += "Install with: pip install python-pptx"
284
+ logger.error(f"Import error: {e}", exc_info=True)
285
+ return None, error_msg
286
+
287
  except Exception as e:
288
  error_msg = f"❌ Translation Error:\n{str(e)}\n\nPlease check your settings and try again."
289
  logger.error(f"Translation failed: {e}", exc_info=True)
 
353
  # ============================================================================
354
 
355
  def create_interface():
356
+ """
357
+ Supports both .docx and .pptx files
358
+ """
359
 
360
+ # Language options (unchanged)
361
  languages = {
362
  "English": "en",
363
  "German": "de",
 
379
  "Vietnamese": "vi"
380
  }
381
 
382
+ with gr.Blocks(title="Document Translator") as demo:
383
+
384
  gr.Markdown("""
385
  # 🌍 Document Translator
386
 
387
+ Translate Word documents and PowerPoint presentations while preserving formatting,
388
+ footnotes, styling, and layout.
389
+
390
+ **Supported formats:** `.docx` (Word) and `.pptx` (PowerPoint)
391
  """)
392
 
393
  with gr.Row():
 
395
  gr.Markdown("### πŸ“€ Input")
396
 
397
  input_file = gr.File(
398
+ label="Upload Document (.docx or .pptx)",
399
+ file_types=[".docx", ".pptx"],
400
  type="filepath"
401
  )
402
 
 
470
  max_lines=30,
471
  interactive=False
472
  )
473
+
474
+
475
+ file_preview = gr.Markdown(label="File Preview")
476
+ input_file.change(
477
+ fn=preview_uploaded_file,
478
+ inputs=[input_file],
479
+ outputs=[file_preview]
480
+ )
481
 
482
  gr.Markdown(f"### System Status\n```\n{SETUP_STATUS}\n```")
483
 
484
  gr.Markdown("""
485
+ ### ✨ Features
486
+
487
+ **Word Documents (.docx):**
488
+ - Preserves formatting, footnotes, headers/footers
489
+ - Maintains tables, styles, and paragraph formatting
490
+ - Word-level alignment for accurate format transfer
491
+
492
+ **PowerPoint Presentations (.pptx):**
493
+ - Translates all text boxes and placeholders
494
+ - Preserves slide layouts and positioning
495
+ - Maintains tables, speaker notes, and formatting
496
+ - Handles grouped shapes and complex layouts
497
+
498
+ **Translation Engines:**
499
+ - Multiple neural backends (NLLB, Madlad, Opus-MT, WMT21)
500
  - Optional LLM enhancement (OpenAI/Anthropic)
501
+ - Word-level alignment for format preservation
502
 
503
  **Recommended Settings:**
504
  - Mode: Hybrid (best quality)
505
  - Backend: NLLB (fastest, good quality)
506
+ - Size: 600M (good balance for Spaces)
507
 
508
+ ### πŸ“– Usage Tips
509
+
510
+ **For Word Documents:**
511
+ - Best for: Academic papers, reports, articles
512
+ - Use "Hybrid" mode for complex formatting
513
+ - Madlad backend excels with technical content
514
 
515
+ **For PowerPoint:**
516
+ - Best for: Business presentations, slides
517
+ - Use "NMT Only" for speed
518
+ - Check speaker notes in output file
519
+
520
+ **General:**
521
+ - Larger NLLB models (1.3B, 3.3B) improve quality but use more RAM
522
+ - LLM modes require API keys and are slower
523
+ - First translation may be slower (model download)
524
 
525
  ### ⚠️ Limitations
526
 
527
+ - Only modern Office formats (.docx, .pptx) - not legacy .doc/.ppt
528
+ - Large files may take several minutes to process
529
+ - Complex formatting may require manual review after translation
530
  - LLM modes are slower and require API access
531
+ - Embedded images and charts are not translated (text only)
532
+ - On Hugging Face Spaces: Limited to 600M model due to RAM constraints
533
+
534
+ ### πŸ”§ Advanced Settings Guide
535
+
536
+ **Translation Modes:**
537
+ - **NMT Only**: Pure neural translation, fastest, most reliable
538
+ - **Hybrid**: Combines NMT with selective LLM enhancement (recommended)
539
+ - **LLM with Alignment**: Uses LLM + word alignment for formatting
540
+ - **LLM without Alignment**: LLM-only, best for natural output
541
+
542
+ **Backends:**
543
+ - **NLLB**: 200+ languages, fast, balanced quality
544
+ - **Madlad**: Google's 3B model, excellent for academic/formal text
545
+ - **Opus**: Specialized bilingual pairs, very fast for supported pairs
546
+ - **CT2**: Dense models, best German/European quality
547
+
548
+ **Aligners:**
549
+ - **Awesome**: BERT-based, high precision (recommended for Mac/M1)
550
+ - **SimAlign**: Heavy PyTorch BERT, good quality but slower
551
+ - **Lindat**: Cloud API, no local resources needed
552
+ - **Heuristic**: Simple fallback, fast but basic
553
  """)
554
 
555
  def handle_translate(input_f, src_lang_name, tgt_lang_name, mode, nmt, nllb_sz, algn, llm):
 
574
 
575
  return demo
576
 
577
+ # ============================================================================
578
+ # FILE PREVIEW
579
+ # ============================================================================
580
+
581
+ def preview_uploaded_file(file_path) -> str:
582
+ """
583
+ Generate a preview of the uploaded file content.
584
+ Simplified version with better error handling.
585
+ """
586
+ if file_path is None:
587
+ return "No file uploaded"
588
+
589
+ try:
590
+ input_path = Path(file_path.name)
591
+ is_valid, file_type, error = detect_and_validate_file(input_path)
592
+
593
+ if not is_valid:
594
+ return f"❌ {error}"
595
+
596
+ if file_type == 'docx':
597
+ from docx import Document
598
+ doc = Document(str(input_path))
599
+
600
+ para_count = len([p for p in doc.paragraphs if p.text.strip()])
601
+ table_count = len(doc.tables)
602
+
603
+ preview_lines = [
604
+ "πŸ“„ **Word Document Preview**\n",
605
+ f"πŸ“Š Statistics:",
606
+ f" β€’ {para_count} paragraphs with text",
607
+ f" β€’ {table_count} tables",
608
+ "\n**First paragraphs:**\n"
609
+ ]
610
+
611
+ count = 0
612
+ for para in doc.paragraphs:
613
+ if para.text.strip() and count < 3:
614
+ text = para.text.strip().replace('\n', ' ')
615
+ preview_lines.append(f"{count+1}. {text[:100]}{'...' if len(text) > 100 else ''}")
616
+ count += 1
617
+
618
+ if count == 0:
619
+ preview_lines.append("(No text content found)")
620
+
621
+ return "\n".join(preview_lines)
622
+
623
+ elif file_type == 'pptx':
624
+ from pptx import Presentation
625
+
626
+ try:
627
+ prs = Presentation(str(input_path))
628
+ slide_count = len(prs.slides)
629
+
630
+ preview_lines = [
631
+ "πŸ“Š **PowerPoint Preview**\n",
632
+ f"πŸ“ˆ Statistics:",
633
+ f" β€’ {slide_count} slide{'s' if slide_count != 1 else ''}",
634
+ ]
635
+
636
+ # Count shapes across all slides
637
+ total_shapes = 0
638
+ text_shapes = 0
639
+
640
+ for slide in prs.slides:
641
+ for shape in slide.shapes:
642
+ total_shapes += 1
643
+ try:
644
+ if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
645
+ if shape.text_frame.text.strip():
646
+ text_shapes += 1
647
+ except:
648
+ pass
649
+
650
+ preview_lines.append(f" β€’ {total_shapes} total shapes")
651
+ preview_lines.append(f" β€’ {text_shapes} text boxes/placeholders")
652
+ preview_lines.append("\n**Sample content from first slides:**\n")
653
+
654
+ # Preview first 3 slides
655
+ previewed = 0
656
+ for idx in range(min(3, slide_count)):
657
+ try:
658
+ slide = prs.slides[idx]
659
+ preview_lines.append(f"**Slide {idx+1}:**")
660
+
661
+ texts_found = 0
662
+ for shape in slide.shapes:
663
+ try:
664
+ if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
665
+ text = shape.text_frame.text.strip()
666
+ if text:
667
+ text = text.replace('\n', ' ')
668
+ preview_lines.append(f" β€’ {text[:80]}{'...' if len(text) > 80 else ''}")
669
+ texts_found += 1
670
+ if texts_found >= 2: # Max 2 texts per slide
671
+ break
672
+ except Exception as shape_err:
673
+ # Skip problematic shapes silently
674
+ continue
675
+
676
+ if texts_found == 0:
677
+ preview_lines.append(" (No text content)")
678
+
679
+ preview_lines.append("")
680
+ previewed += 1
681
+
682
+ except Exception as slide_err:
683
+ logger.debug(f"Could not preview slide {idx+1}: {slide_err}")
684
+ preview_lines.append(f"**Slide {idx+1}:** (Could not access)\n")
685
+ continue
686
+
687
+ if previewed == 0:
688
+ preview_lines.append("(Could not access slide content)")
689
+
690
+ return "\n".join(preview_lines)
691
+
692
+ except Exception as prs_error:
693
+ return f"πŸ“Š PowerPoint file detected\n\n❌ Preview unavailable: {str(prs_error)}\n\nFile appears valid and should translate successfully."
694
+
695
+ except Exception as e:
696
+ logger.error(f"Preview generation failed: {e}", exc_info=True)
697
+ return f"❌ Preview error: {str(e)}"
698
+
699
+ return "Could not generate preview"
700
+
701
  # ============================================================================
702
  # MAIN
703
  # ============================================================================