Setting the file. One moment.
Eyeball · Eyeball · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 404
def _img_to_bytes
— line 404
This file
Number 158.1
Position 1 of 1
Type Python
Size 28 KB
Lines 833 tools/ eyeball.py
Python · 833 lines · 28 KB
16 python3 eyeball.py setup-check
17
18 python3 eyeball.py convert --source <file.docx> --output <file.pdf>
19
20 python3 eyeball.py screenshot \
21 --source <file.pdf> \
22 --anchors '["term1", "term2"]' \
23 --page 5 \
24 --output screenshot.png
25 """
26
27 import argparse
28 import io
29 import json
30 import os
31 import platform
32 import shutil
33 import subprocess
34 import sys
35 import tempfile
36
37 try :
38 import fitz # PyMuPDF
39 except ImportError :
40 fitz = None
41
42 try :
43 from PIL import Image, ImageDraw
44 except ImportError :
45 Image = None
46 ImageDraw = None
47
48 try :
49 from docx import Document
50 from docx.shared import Inches, Pt, RGBColor
51 except ImportError :
52 Document = None
53 Inches = None
54 Pt = None
55 RGBColor = None
56
57
58 def _resolve_path (path_str):
59 """Expand ~ and environment variables in a user-provided path."""
60 return os.path.expandvars(os.path.expanduser(path_str))
61
62
63 def _check_core_deps ():
64 """Raise if core dependencies are missing."""
65 missing = []
66 if fitz is None :
67 missing.append( "pymupdf" )
68 if Image is None :
69 missing.append( "pillow" )
70 if Document is None :
71 missing.append( "python-docx" )
72 if missing:
73 print ( f "Missing dependencies: { ', ' .join(missing) } " , file = sys.stderr)
74 print ( f "Run setup.sh or: { sys.executable } -m pip install pymupdf pillow python-docx playwright" , file = sys.stderr)
75 sys.exit( 1 )
76
77
78 # ---------------------------------------------------------------------------
79 # Document conversion: source -> PDF
80 # ---------------------------------------------------------------------------
81
82 def convert_to_pdf (source_path, output_pdf_path):
83 """Convert a document to PDF. Supports .docx, .doc, .rtf, .html, .htm."""
84 if not os.path.isfile(source_path):
85 raise FileNotFoundError ( f "Source file not found: { source_path } " )
86
87 ext = os.path.splitext(source_path)[ 1 ].lower()
88
89 if ext == ".pdf" :
90 if os.path.abspath(source_path) != os.path.abspath(output_pdf_path):
91 shutil.copy2(source_path, output_pdf_path)
92 return True
93
94 system = platform.system()
95
96 # Try Microsoft Word first on the current platform
97 if system == "Darwin" and ext in ( ".docx" , ".doc" , ".rtf" ):
98 if os.path.exists( "/Applications/Microsoft Word.app" ):
99 if _convert_with_word_mac(source_path, output_pdf_path):
100 return True
101
102 if system == "Windows" and ext in ( ".docx" , ".doc" , ".rtf" ):
103 if _convert_with_word_windows(source_path, output_pdf_path):
104 return True
105
106 # Fall back to LibreOffice on any platform
107 soffice = shutil.which( "libreoffice" ) or shutil.which( "soffice" )
108 if not soffice and system == "Windows" :
109 soffice = _find_libreoffice_windows()
110 if soffice and ext in ( ".docx" , ".doc" , ".rtf" , ".odt" , ".html" , ".htm" ):
111 if _convert_with_libreoffice(soffice, source_path, output_pdf_path):
112 return True
113
114 raise RuntimeError (
115 f "Cannot convert { ext } to PDF. Install Microsoft Word (macOS/Windows) "
116 f "or LibreOffice (any platform)."
117 )
118
119
120 def _convert_with_word_mac (source_path, output_pdf_path):
121 """Convert using Microsoft Word on macOS via AppleScript."""
122 source_abs = os.path.abspath(source_path)
123 output_abs = os.path.abspath(output_pdf_path)
124 # Escape characters that break AppleScript string interpolation
125 source_safe = source_abs.replace( ' \\ ' , ' \\\\ ' ).replace( '"' , ' \\ "' )
126 output_safe = output_abs.replace( ' \\ ' , ' \\\\ ' ).replace( '"' , ' \\ "' )
127 script = f '''
128 tell application "Microsoft Word"
129 open POSIX file " { source_safe } "
130 delay 5
131 set theDoc to active document
132 save as theDoc file name POSIX file " { output_safe } " file format format PDF
133 close theDoc saving no
134 end tell
135 '''
136 try :
137 result = subprocess.run(
138 [ "osascript" , "-e" , script],
139 capture_output = True , text = True , timeout = 120
140 )
141 return result.returncode == 0 and os.path.exists(output_pdf_path)
142 except (subprocess.TimeoutExpired, FileNotFoundError ):
143 return False
144
145
146 def _convert_with_libreoffice (soffice_path, source_path, output_pdf_path):
147 """Convert using LibreOffice headless mode."""
148 with tempfile.TemporaryDirectory() as tmpdir:
149 try :
150 result = subprocess.run(
151 [soffice_path, "--headless" , "--convert-to" , "pdf" ,
152 "--outdir" , tmpdir, source_path],
153 capture_output = True , text = True , timeout = 120
154 )
155 if result.returncode != 0 :
156 return False
157 basename = os.path.splitext(os.path.basename(source_path))[ 0 ] + ".pdf"
158 tmp_pdf = os.path.join(tmpdir, basename)
159 if os.path.exists(tmp_pdf):
160 shutil.move(tmp_pdf, output_pdf_path)
161 return True
162 except (subprocess.TimeoutExpired, FileNotFoundError ):
163 pass
164 return False
165
166
167 def _find_libreoffice_windows ():
168 """Find LibreOffice in common Windows install locations."""
169 candidates = []
170 for env_var in ( "ProgramFiles" , "ProgramFiles(x86)" ):
171 base = os.environ.get(env_var)
172 if base:
173 candidates.append(os.path.join(base, "LibreOffice" , "program" , "soffice.exe" ))
174 for path in candidates:
175 if os.path.isfile(path):
176 return path
177 return None
178
179
180 def _convert_with_word_windows (source_path, output_pdf_path):
181 """Convert using Microsoft Word on Windows via win32com."""
182 word = None
183 doc = None
184 try :
185 import win32com.client
186 source_abs = os.path.abspath(source_path)
187 output_abs = os.path.abspath(output_pdf_path)
188 os.makedirs(os.path.dirname(output_abs), exist_ok = True )
189
190 # DispatchEx creates an isolated Word process; fall back to Dispatch
191 # if the DCOM class isn't registered
192 try :
193 word = win32com.client.DispatchEx( "Word.Application" )
194 except Exception :
195 word = win32com.client.Dispatch( "Word.Application" )
196
197 word.Visible = False
198 word.DisplayAlerts = 0
199 try :
200 word.AutomationSecurity = 3 # msoAutomationSecurityForceDisable
201 except Exception :
202 pass
203
204 doc = word.Documents.Open(
205 FileName = source_abs,
206 ConfirmConversions = False ,
207 ReadOnly = True ,
208 AddToRecentFiles = False ,
209 NoEncodingDialog = True ,
210 )
211 doc.ExportAsFixedFormat(
212 OutputFileName = output_abs,
213 ExportFormat = 17 , # wdExportFormatPDF
214 OpenAfterExport = False ,
215 )
216 return os.path.isfile(output_abs)
217 except Exception :
218 return False
219 finally :
220 if doc is not None :
221 try :
222 doc.Close( False )
223 except Exception :
224 pass
225 if word is not None :
226 try :
227 word.Quit()
228 except Exception :
229 pass
230
231
232 def render_url_to_pdf (url, output_pdf_path):
233 """Render a web page to PDF using Playwright."""
234 try :
235 from playwright.sync_api import sync_playwright
236 except ImportError :
237 raise RuntimeError (
238 "Playwright is required for web URL support. "
239 f "Run: { sys.executable } -m pip install playwright && "
240 f " { sys.executable } -m playwright install chromium"
241 )
242
243 with sync_playwright() as p:
244 browser = None
245 try :
246 browser = p.chromium.launch( headless = True )
247 page = browser.new_page()
248 page.goto(url, wait_until = "networkidle" , timeout = 30000 )
249
250 # Clean up navigation/footer elements for cleaner output
251 page.evaluate( """
252 document.querySelectorAll(
253 'header, footer, nav, [data-testid="header"], [data-testid="footer"], '
254 + '.site-header, .site-footer, #cookie-banner, .cookie-consent'
255 ).forEach(el => el.remove());
256 """ )
257
258 page.pdf(
259 path = output_pdf_path,
260 format = "Letter" ,
261 print_background = True ,
262 margin = { "top" : "0.5in" , "bottom" : "0.5in" ,
263 "left" : "0.75in" , "right" : "0.75in" }
264 )
265 finally :
266 if browser is not None :
267 browser.close()
268
269
270 # ---------------------------------------------------------------------------
271 # Screenshot generation
272 # ---------------------------------------------------------------------------
273
274 def screenshot_region (pdf_doc, anchors, target_page = None , target_pages = None ,
275 context_padding = 40 , dpi = 200 ):
276 """
277 Find anchor text in a PDF and capture the surrounding region as a highlighted image.
278
279 Args:
280 pdf_doc: An open fitz.Document.
281 anchors: List of search strings. The crop region expands to cover all of them.
282 target_page: Single 1-indexed page to search on.
283 target_pages: List of 1-indexed pages to search across (results stitched vertically).
284 context_padding: Extra padding in PDF points above/below the anchor region.
285 dpi: Render resolution.
286
287 Returns:
288 (image_bytes, page_label, (width, height)) or (None, None, None).
289 """
290 if isinstance (anchors, str ):
291 anchors = [anchors]
292
293 # Determine pages to search
294 if target_pages:
295 pages = [p - 1 for p in target_pages]
296 elif target_page is not None :
297 pages = [target_page - 1 ]
298 else :
299 pages = list ( range (pdf_doc.page_count))
300
301 # Collect hits across pages
302 page_hits = {}
303 for pg_idx in pages:
304 if pg_idx < 0 or pg_idx >= pdf_doc.page_count:
305 continue
306 page = pdf_doc[pg_idx]
307 hits_on_page = []
308 for anchor in anchors:
309 found = page.search_for(anchor)
310 if found:
311 hits_on_page.extend([(anchor, h) for h in found])
312 if hits_on_page:
313 page_hits[pg_idx] = hits_on_page
314
315 if not page_hits:
316 return None , None , None
317
318 zoom = dpi / 72
319
320 # If single page, render one region
321 if len (page_hits) == 1 :
322 pg_idx = list (page_hits.keys())[ 0 ]
323 img = _render_page_region(pdf_doc, pg_idx, page_hits[pg_idx],
324 context_padding, zoom)
325 img_bytes = _img_to_bytes(img)
326 return img_bytes, f "page { pg_idx + 1 } " , img.size
327
328 # Multi-page: stitch vertically
329 images = []
330 pages_used = sorted (page_hits.keys())
331 for pg_idx in pages_used:
332 img = _render_page_region(pdf_doc, pg_idx, page_hits[pg_idx],
333 context_padding, zoom)
334 images.append(img)
335
336 stitched = _stitch_vertical(images)
337 img_bytes = _img_to_bytes(stitched)
338
339 if len (pages_used) > 1 :
340 page_nums = ", " .join( str (p + 1 ) for p in pages_used)
341 page_label = f "pages { page_nums } "
342 else :
343 page_label = f "page { pages_used[ 0 ] + 1 } "
344
345 return img_bytes, page_label, stitched.size
346
347
348 def _render_page_region (pdf_doc, pg_idx, hits_with_anchors, context_padding, zoom):
349 """Render a cropped region of a PDF page with highlighted anchor text."""
350 page = pdf_doc[pg_idx]
351 page_rect = page.rect
352
353 all_rects = [h for _, h in hits_with_anchors]
354 min_y = min (r.y0 for r in all_rects)
355 max_y = max (r.y1 for r in all_rects)
356
357 crop_rect = fitz.Rect(
358 page_rect.x0 + 20 ,
359 max (page_rect.y0, min_y - context_padding),
360 page_rect.x1 - 20 ,
361 min (page_rect.y1, max_y + context_padding)
362 )
363
364 mat = fitz.Matrix(zoom, zoom)
365 pix = page.get_pixmap( matrix = mat, clip = crop_rect)
366 img = Image.frombytes( "RGB" , [pix.width, pix.height], pix.samples)
367
368 # Highlight each anchor hit
369 draw = ImageDraw.Draw(img, "RGBA" )
370 pad = max ( 2 , round ( 2 * zoom))
371 for anchor, rect in hits_with_anchors:
372 if rect.y0 >= crop_rect.y0 - 5 and rect.y1 <= crop_rect.y1 + 5 :
373 x0 = (rect.x0 - crop_rect.x0) * zoom
374 y0 = (rect.y0 - crop_rect.y0) * zoom
375 x1 = (rect.x1 - crop_rect.x0) * zoom
376 y1 = (rect.y1 - crop_rect.y0) * zoom
377 draw.rectangle([x0 - pad, y0 - pad, x1 + pad, y1 + pad], fill = ( 255 , 255 , 0 , 100 ))
378
379 # Border
380 ImageDraw.Draw(img).rectangle(
381 [ 0 , 0 , img.width - 1 , img.height - 1 ],
382 outline = ( 160 , 160 , 160 ), width = 2
383 )
384
385 return img
386
387
388 def _stitch_vertical (images, gap = 4 ):
389 """Stitch multiple images vertically with a small gap between them."""
390 total_height = sum (img.height for img in images) + gap * ( len (images) - 1 )
391 max_width = max (img.width for img in images)
392 stitched = Image.new( "RGB" , (max_width, total_height), ( 255 , 255 , 255 ))
393 y = 0
394 for img in images:
395 stitched.paste(img, ( 0 , y))
396 y += img.height + gap
397 ImageDraw.Draw(stitched).rectangle(
398 [ 0 , 0 , stitched.width - 1 , stitched.height - 1 ],
399 outline = ( 160 , 160 , 160 ), width = 2
400 )
401 return stitched
402
403
404 def _img_to_bytes (img):
405 """Convert PIL Image to a PNG BytesIO buffer (file-like object)."""
406 buf = io.BytesIO()
407 img.save(buf, format = "PNG" )
408 buf.seek( 0 )
409 return buf
410
411
412 # ---------------------------------------------------------------------------
413 # Output document assembly
414 # ---------------------------------------------------------------------------
415
416 def build_analysis_doc (pdf_doc, sections, output_path, title = None , subtitle = None ,
417 source_label = None , dpi = 200 ):
418 """
419 Build a Word document with analysis sections and inline source screenshots.
420
421 Args:
422 pdf_doc: An open fitz.Document (the source, already converted to PDF).
423 sections: List of dicts, each with:
424 - heading (str): Section heading
425 - analysis (str): Analysis text
426 - anchors (list[str]): Verbatim phrases from source to search and highlight
427 - target_page (int, optional): 1-indexed page to search on
428 - target_pages (list[int], optional): Multiple pages to search across
429 - context_padding (int, optional): Extra padding in PDF points (default 40)
430 output_path: Where to save the output .docx file.
431 title: Document title.
432 subtitle: Document subtitle.
433 source_label: Label for the source (e.g., filename or URL).
434 dpi: Screenshot resolution.
435 """
436 doc = Document()
437
438 # Style
439 style = doc.styles[ "Normal" ]
440 style.font.name = "Calibri"
441 style.font.size = Pt( 11 )
442
443 # Title
444 if title:
445 doc.add_heading(title, level = 1 )
446 if subtitle:
447 p = doc.add_paragraph()
448 run = p.add_run(subtitle)
449 run.font.size = Pt( 11 )
450 run.font.color.rgb = RGBColor( 100 , 100 , 100 )
451 doc.add_paragraph( "" )
452
453 # Sections
454 for i, section in enumerate (sections):
455 heading = section.get( "heading" , f "Section { i + 1 } " )
456 analysis = section.get( "analysis" , "" )
457 anchors = section.get( "anchors" , [])
458 target_page = section.get( "target_page" )
459 target_pages = section.get( "target_pages" )
460 padding = section.get( "context_padding" , 40 )
461
462 doc.add_heading(heading, level = 2 )
463 doc.add_paragraph(analysis)
464
465 if anchors:
466 img_bytes, page_label, size = screenshot_region(
467 pdf_doc, anchors,
468 target_page = target_page,
469 target_pages = target_pages,
470 context_padding = padding,
471 dpi = dpi
472 )
473
474 if img_bytes:
475 # Source label
476 p = doc.add_paragraph()
477 anchor_text = ", " .join( f '" { a } "' for a in anchors[: 3 ])
478 if len (anchors) > 3 :
479 anchor_text += f " (+ { len (anchors) - 3 } more)"
480 label = f "[Source: { source_label or 'document' } , { page_label } "
481 label += f " -- highlighted: { anchor_text } ]"
482 run = p.add_run(label)
483 run.font.size = Pt( 8 )
484 run.font.color.rgb = RGBColor( 120 , 120 , 120 )
485 run.font.italic = True
486 p.paragraph_format.space_before = Pt( 6 )
487 p.paragraph_format.space_after = Pt( 2 )
488
489 # Screenshot
490 doc.add_picture(img_bytes, width = Inches( 5.8 ))
491 doc.paragraphs[ - 1 ].paragraph_format.space_after = Pt( 12 )
492 else :
493 # Anchors not found
494 p = doc.add_paragraph()
495 run = p.add_run(
496 f "[Screenshot not available: could not find "
497 f " { ', ' .join( repr (a) for a in anchors) } in the source document]"
498 )
499 run.font.size = Pt( 9 )
500 run.font.italic = True
501 run.font.color.rgb = RGBColor( 180 , 50 , 50 )
502
503 # Footer note
504 doc.add_paragraph( "" )
505 note = doc.add_paragraph()
506 run = note.add_run(
507 "Generated by Eyeball. Each screenshot is captured from the source document "
508 "with cited text highlighted in yellow. Screenshots are dynamically sized to "
509 "cover the full range of text referenced in the analysis. Review the highlighted "
510 "source material to verify each assertion."
511 )
512 run.font.size = Pt( 9 )
513 run.font.italic = True
514 run.font.color.rgb = RGBColor( 130 , 130 , 130 )
515
516 doc.save(output_path)
517 return output_path
518
519
520 # ---------------------------------------------------------------------------
521 # CLI commands
522 # ---------------------------------------------------------------------------
523
524 def cmd_setup_check ():
525 """Check if all dependencies are available."""
526 checks = {
527 "PyMuPDF" : False ,
528 "Pillow" : False ,
529 "python-docx" : False ,
530 "Playwright" : False ,
531 "Chromium browser" : False ,
532 "Word (macOS)" : False ,
533 "Word (Windows)" : False ,
534 "LibreOffice" : False ,
535 }
536
537 try :
538 import fitz
539 checks[ "PyMuPDF" ] = True
540 except ImportError :
541 pass
542
543 try :
544 from PIL import Image
545 checks[ "Pillow" ] = True
546 except ImportError :
547 pass
548
549 try :
550 from docx import Document
551 checks[ "python-docx" ] = True
552 except ImportError :
553 pass
554
555 try :
556 from playwright.sync_api import sync_playwright
557 checks[ "Playwright" ] = True
558 except ImportError :
559 pass
560
561 # Check Chromium across all platforms
562 pw_cache_candidates = []
563 system = platform.system()
564 if system == "Darwin" :
565 pw_cache_candidates.append(os.path.expanduser( "~/Library/Caches/ms-playwright" ))
566 if system == "Windows" :
567 local_app_data = os.environ.get( "LOCALAPPDATA" , "" )
568 if local_app_data:
569 pw_cache_candidates.append(os.path.join(local_app_data, "ms-playwright" ))
570 pw_cache_candidates.append(os.path.expanduser( "~/.cache/ms-playwright" ))
571 # Respect PLAYWRIGHT_BROWSERS_PATH
572 custom_pw = os.environ.get( "PLAYWRIGHT_BROWSERS_PATH" )
573 if custom_pw and custom_pw != "0" :
574 pw_cache_candidates.insert( 0 , custom_pw)
575 for pw_cache in pw_cache_candidates:
576 if os.path.isdir(pw_cache) and any (
577 d.startswith( "chromium" ) for d in os.listdir(pw_cache)
578 ):
579 checks[ "Chromium browser" ] = True
580 break
581
582 # Check converters -- registry/filesystem only, never launch Word
583 if system == "Darwin" and os.path.exists( "/Applications/Microsoft Word.app" ):
584 checks[ "Word (macOS)" ] = True
585
586 if system == "Windows" :
587 try :
588 import winreg
589 word_reg_paths = [
590 (winreg. HKEY_LOCAL_MACHINE , r "SOFTWARE \M icrosoft \W indows \C urrentVersion \A pp Paths \W INWORD . EXE" ),
591 (winreg. HKEY_CURRENT_USER , r "SOFTWARE \M icrosoft \W indows \C urrentVersion \A pp Paths \W INWORD . EXE" ),
592 (winreg. HKEY_CLASSES_ROOT , r "Word . Application" ),
593 ]
594 for hive, subkey in word_reg_paths:
595 try :
596 winreg.OpenKey(hive, subkey)
597 checks[ "Word (Windows)" ] = True
598 break
599 except OSError :
600 pass
601 except ImportError :
602 pass
603 # Check if pywin32 is available for Word automation
604 if checks[ "Word (Windows)" ]:
605 try :
606 import win32com.client # noqa: F401
607 except ImportError :
608 checks[ "Word (Windows)" ] = False
609 print ( " Note: Microsoft Word found but pywin32 is not installed." )
610 print ( f " Run: { sys.executable } -m pip install pywin32" )
611
612 if shutil.which( "libreoffice" ) or shutil.which( "soffice" ):
613 checks[ "LibreOffice" ] = True
614 elif system == "Windows" :
615 if _find_libreoffice_windows():
616 checks[ "LibreOffice" ] = True
617
618 print ( "Eyeball dependency check:" )
619 all_core = True
620 for name, ok in checks.items():
621 status = "OK" if ok else "MISSING"
622 marker = "+" if ok else "-"
623 print ( f " [ { marker } ] { name } : { status } " )
624 if name in ( "PyMuPDF" , "Pillow" , "python-docx" ) and not ok:
625 all_core = False
626
627 has_converter = checks[ "Word (macOS)" ] or checks[ "Word (Windows)" ] or checks[ "LibreOffice" ]
628 has_web = checks[ "Playwright" ] and checks[ "Chromium browser" ]
629
630 print ( "" )
631 print ( "Source support:" )
632 print ( f " PDF files: { 'Ready' if all_core else 'Needs: pip3 install pymupdf pillow python-docx' } " )
633 print ( f " Word docs: { 'Ready' if has_converter else 'Needs: Microsoft Word or LibreOffice' } " )
634 print ( f " Web URLs: { 'Ready' if has_web else 'Needs: pip3 install playwright && python3 -m playwright install chromium' } " )
635
636 return 0 if all_core else 1
637
638
639 def cmd_convert (args):
640 """Convert a document to PDF."""
641 source = _resolve_path(args.source)
642 output = _resolve_path(args.output)
643
644 if source.startswith(( "http://" , "https://" )):
645 render_url_to_pdf(source, output)
646 else :
647 convert_to_pdf(source, output)
648
649 print ( f "Converted: { output } ( { os.path.getsize(output) } bytes)" )
650
651
652 def cmd_screenshot (args):
653 """Generate a single screenshot from a PDF."""
654 _check_core_deps()
655 source = _resolve_path(args.source)
656
657 if not os.path.isfile(source):
658 print ( f "Source file not found: { source } " , file = sys.stderr)
659 sys.exit( 1 )
660
661 ext = os.path.splitext(source)[ 1 ].lower()
662 if ext != ".pdf" :
663 print ( f "Source must be a PDF file (got { ext } ). "
664 f "Use 'convert' to convert other formats first." , file = sys.stderr)
665 sys.exit( 1 )
666
667 anchors = json.loads(args.anchors)
668 target_page = args.page
669 padding = args.padding
670 dpi = args.dpi
671
672 pdf_doc = fitz.open(source)
673 try :
674 img_bytes, page_label, size = screenshot_region(
675 pdf_doc, anchors,
676 target_page = target_page,
677 context_padding = padding,
678 dpi = dpi
679 )
680 finally :
681 pdf_doc.close()
682
683 if img_bytes:
684 output = _resolve_path(args.output)
685 with open (output, "wb" ) as f:
686 f.write(img_bytes.getvalue())
687 print ( f "Screenshot saved: { output } ( { size[ 0 ] } x { size[ 1 ] } px, { page_label } )" )
688 else :
689 print ( f "No matches found for: { anchors } " , file = sys.stderr)
690 sys.exit( 1 )
691
692
693 def cmd_build (args):
694 """Build a complete analysis document."""
695 _check_core_deps()
696 source = _resolve_path(args.source)
697 output = _resolve_path(args.output)
698 sections = json.loads(args.sections)
699 title = args.title
700 subtitle = args.subtitle
701 dpi = args.dpi
702
703 if not source.startswith(( "http://" , "https://" )) and not os.path.isfile(source):
704 print ( f "Source file not found: { source } " , file = sys.stderr)
705 sys.exit( 1 )
706
707 # Determine source type and convert to PDF
708 with tempfile.NamedTemporaryFile( suffix = ".pdf" , delete = False ) as tmp:
709 tmp_pdf = tmp.name
710
711 pdf_doc = None
712 try :
713 if source.startswith(( "http://" , "https://" )):
714 render_url_to_pdf(source, tmp_pdf)
715 source_label = source
716 elif source.lower().endswith( ".pdf" ):
717 shutil.copy2(source, tmp_pdf)
718 source_label = os.path.basename(source)
719 else :
720 convert_to_pdf(source, tmp_pdf)
721 source_label = os.path.basename(source)
722
723 pdf_doc = fitz.open(tmp_pdf)
724 build_analysis_doc(
725 pdf_doc, sections, output,
726 title = title, subtitle = subtitle,
727 source_label = source_label,
728 dpi = dpi
729 )
730
731 size_kb = os.path.getsize(output) / 1024
732 print ( f "Analysis saved: { output } ( { size_kb :.0f} KB)" )
733
734 finally :
735 if pdf_doc is not None :
736 pdf_doc.close()
737 if os.path.exists(tmp_pdf):
738 os.unlink(tmp_pdf)
739
740
741 def cmd_extract_text (args):
742 """Extract text from a source document (for the AI to read before writing analysis)."""
743 _check_core_deps()
744 source = _resolve_path(args.source)
745
746 if not source.startswith(( "http://" , "https://" )) and not os.path.isfile(source):
747 print ( f "Source file not found: { source } " , file = sys.stderr)
748 sys.exit( 1 )
749
750 with tempfile.NamedTemporaryFile( suffix = ".pdf" , delete = False ) as tmp:
751 tmp_pdf = tmp.name
752
753 pdf_doc = None
754 try :
755 if source.startswith(( "http://" , "https://" )):
756 render_url_to_pdf(source, tmp_pdf)
757 elif source.lower().endswith( ".pdf" ):
758 shutil.copy2(source, tmp_pdf)
759 else :
760 convert_to_pdf(source, tmp_pdf)
761
762 pdf_doc = fitz.open(tmp_pdf)
763 for i in range (pdf_doc.page_count):
764 text = pdf_doc[i].get_text()
765 print ( f " \n [PAGE { i + 1 } ]" )
766 print (text)
767
768 finally :
769 if pdf_doc is not None :
770 pdf_doc.close()
771 if os.path.exists(tmp_pdf):
772 os.unlink(tmp_pdf)
773
774
775 def main ():
776 parser = argparse.ArgumentParser(
777 description = "Eyeball: Document analysis with inline source screenshots"
778 )
779 sub = parser.add_subparsers( dest = "command" )
780
781 # setup-check
782 sub.add_parser( "setup-check" , help = "Check dependencies" )
783
784 # convert
785 p_conv = sub.add_parser( "convert" , help = "Convert a document to PDF" )
786 p_conv.add_argument( "--source" , required = True )
787 p_conv.add_argument( "--output" , required = True )
788
789 # screenshot
790 p_ss = sub.add_parser( "screenshot" , help = "Generate a screenshot from a PDF" )
791 p_ss.add_argument( "--source" , required = True , help = "PDF file path" )
792 p_ss.add_argument( "--anchors" , required = True , help = "JSON array of search terms" )
793 p_ss.add_argument( "--page" , type = int , help = "Target page (1-indexed)" )
794 p_ss.add_argument( "--padding" , type = int , default = 40 )
795 p_ss.add_argument( "--dpi" , type = int , default = 200 )
796 p_ss.add_argument( "--output" , required = True , help = "Output PNG path" )
797
798 # build
799 p_build = sub.add_parser( "build" , help = "Build analysis document" )
800 p_build.add_argument( "--source" , required = True ,
801 help = "Source document path or URL" )
802 p_build.add_argument( "--output" , required = True ,
803 help = "Output .docx path" )
804 p_build.add_argument( "--sections" , required = True ,
805 help = "JSON array of section objects" )
806 p_build.add_argument( "--title" , help = "Document title" )
807 p_build.add_argument( "--subtitle" , help = "Document subtitle" )
808 p_build.add_argument( "--dpi" , type = int , default = 200 )
809
810 # extract-text
811 p_text = sub.add_parser( "extract-text" ,
812 help = "Extract text from a document (for AI analysis)" )
813 p_text.add_argument( "--source" , required = True )
814
815 args = parser.parse_args()
816
817 if args.command == "setup-check" :
818 sys.exit(cmd_setup_check())
819 elif args.command == "convert" :
820 cmd_convert(args)
821 elif args.command == "screenshot" :
822 cmd_screenshot(args)
823 elif args.command == "build" :
824 cmd_build(args)
825 elif args.command == "extract-text" :
826 cmd_extract_text(args)
827 else :
828 parser.print_help()
829 sys.exit( 1 )
830
831
832 if __name__ == "__main__" :
833 main()