tomo2chin2 commited on
Commit
2a69a40
·
verified ·
1 Parent(s): 38f7de3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +254 -417
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import gradio as gr
2
  from fastapi import FastAPI, HTTPException, Body
3
  from fastapi.responses import StreamingResponse
@@ -9,6 +10,8 @@ from selenium.webdriver.chrome.options import Options
9
  from selenium.webdriver.common.by import By
10
  from selenium.webdriver.support.ui import WebDriverWait
11
  from selenium.webdriver.support import expected_conditions as EC
 
 
12
  from PIL import Image
13
  from io import BytesIO
14
  import tempfile
@@ -49,40 +52,40 @@ def enhance_font_awesome_layout(html_code):
49
  margin-right: 8px !important;
50
  vertical-align: middle !important;
51
  }
52
-
53
  /* テキスト内のアイコン位置調整 */
54
- h1 [class*="fa-"], h2 [class*="fa-"], h3 [class*="fa-"],
55
  h4 [class*="fa-"], h5 [class*="fa-"], h6 [class*="fa-"] {
56
  vertical-align: middle !important;
57
  margin-right: 10px !important;
58
  }
59
-
60
  /* 特定パターンの修正 */
61
  .fa + span, .fas + span, .far + span, .fab + span,
62
  span + .fa, span + .fas, span + .far, span + .fab {
63
  display: inline-block !important;
64
  margin-left: 5px !important;
65
  }
66
-
67
  /* カード内アイコン修正 */
68
  .card [class*="fa-"], .card-body [class*="fa-"] {
69
  float: none !important;
70
  clear: none !important;
71
  position: relative !important;
72
  }
73
-
74
  /* アイコンと文字が重なる場合の調整 */
75
  li [class*="fa-"], p [class*="fa-"] {
76
  margin-right: 10px !important;
77
  }
78
-
79
  /* インラインアイコンのスペーシング */
80
  .inline-icon {
81
  display: inline-flex !important;
82
  align-items: center !important;
83
  justify-content: flex-start !important;
84
  }
85
-
86
  /* アイコン後のテキスト */
87
  [class*="fa-"] + span {
88
  display: inline-block !important;
@@ -90,7 +93,7 @@ def enhance_font_awesome_layout(html_code):
90
  }
91
  </style>
92
  """
93
-
94
  # headタグがある場合はその中に追加
95
  if '<head>' in html_code:
96
  return html_code.replace('</head>', f'{fa_fix_css}</head>')
@@ -103,7 +106,7 @@ def enhance_font_awesome_layout(html_code):
103
  body_start = html_code.find('<body')
104
  if body_start > 0:
105
  return html_code[:body_start] + f'<head>{fa_fix_css}</head>' + html_code[body_start:]
106
-
107
  # どちらもない場合は先頭に追加
108
  return f'<html><head>{fa_fix_css}</head>' + html_code + '</html>'
109
 
@@ -115,15 +118,15 @@ def generate_html_from_text(text, temperature=0.3):
115
  if not api_key:
116
  logger.error("GEMINI_API_KEY 環境変数が設定されていません")
117
  raise ValueError("GEMINI_API_KEY 環境変数が設定されていません")
118
-
119
  # モデル名の取得(環境変数から、なければデフォルト値)
120
  model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
121
  logger.info(f"使用するGeminiモデル: {model_name}")
122
-
123
  # Gemini APIの設定
124
  genai.configure(api_key=api_key)
125
-
126
- # システムプロンプト(リクエスト例と同じものを使用)
127
  system_instruction = """# グラフィックレコーディング風インフォグラフィック変換プロンプト V2
128
  ## 目的
129
  以下の内容を、超一流デザイナーが作成したような、日本語で完璧なグラフィックレコーディング風のHTMLインフォグラフィックに変換してください。情報設計とビジュアルデザインの両面で最高水準を目指します。
@@ -231,156 +234,102 @@ def generate_html_from_text(text, temperature=0.3):
231
  - フッターに出典情報と関連するFont Awesomeアイコン(fa-book, fa-citation等)を明記
232
  ## 変換する文章/記事
233
  ーーー<ユーザーが入力(または添付)>ーーー"""
234
-
235
  # モデルを初期化して処理
236
  logger.info(f"Gemini APIにリクエストを送信: テキスト長さ = {len(text)}, 温度 = {temperature}")
237
-
238
  # モデル初期化とフォールバック処理
239
  try:
240
  model = genai.GenerativeModel(model_name)
241
  except Exception as e:
242
- # 指定されたモデルが使用できない場合はフォールバック
243
  fallback_model = "gemini-pro"
244
  logger.warning(f"{model_name}が利用できません: {e}, フォールバックモデル{fallback_model}を使用します")
245
  model = genai.GenerativeModel(fallback_model)
246
-
247
- # 生成設定 - ばらつきを減らすために設定を調整
248
  generation_config = {
249
- "temperature": temperature, # より低い温度を設定
250
- "top_p": 0.7, # 0.95から0.7に下げて出力の多様性を制限
251
- "top_k": 20, # 64から20に下げて候補を絞る
252
  "max_output_tokens": 8192,
253
- "candidate_count": 1 # 候補は1つだけ生成
254
  }
255
-
256
- # 安全設定 - デフォルトの安全設定を使用
257
  safety_settings = [
258
- {
259
- "category": "HARM_CATEGORY_HARASSMENT",
260
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
261
- },
262
- {
263
- "category": "HARM_CATEGORY_HATE_SPEECH",
264
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
265
- },
266
- {
267
- "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
268
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
269
- },
270
- {
271
- "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
272
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
273
- }
274
  ]
275
-
276
  # プロンプト構築
277
  prompt = f"{system_instruction}\n\n{text}"
278
-
279
  # コンテンツ生成
280
  response = model.generate_content(
281
  prompt,
282
  generation_config=generation_config,
283
  safety_settings=safety_settings
284
  )
285
-
286
- # レスポンスからHTMLを抽出
287
  raw_response = response.text
288
-
289
- # HTMLタグ部分だけを抽出(```html と ``` の間)
290
  html_start = raw_response.find("```html")
291
  html_end = raw_response.rfind("```")
292
-
293
  if html_start != -1 and html_end != -1 and html_start < html_end:
294
- html_start += 7 # "```html" の長さ分進める
295
  html_code = raw_response[html_start:html_end].strip()
296
  logger.info(f"HTMLの生成に成功: 長さ = {len(html_code)}")
297
-
298
- # Font Awesomeのレイアウト改善
299
  html_code = enhance_font_awesome_layout(html_code)
300
  logger.info("Font Awesomeレイアウトの最適化を適用しました")
301
-
302
  return html_code
303
  else:
304
- # HTMLタグが見つからない場合、レスポンス全体を返す
305
  logger.warning("レスポンスから```html```タグが見つかりませんでした。全テキストを返します。")
306
  return raw_response
307
-
308
  except Exception as e:
309
  logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
310
  raise Exception(f"Gemini APIでのHTML生成に失敗しました: {e}")
311
 
312
- # 画像から余分な空白領域をトリミングする関数
313
  def trim_image_whitespace(image, threshold=250, padding=10):
314
- """
315
- 画像から余分な白い空白をトリミングする
316
-
317
- Args:
318
- image: PIL.Image - 入力画像
319
- threshold: int - どの明るさ以上を空白と判断するか (0-255)
320
- padding: int - トリミング後に残す余白のピクセル数
321
-
322
- Returns:
323
- トリミングされたPIL.Image
324
- """
325
- # グレースケールに変換
326
  gray = image.convert('L')
327
-
328
- # ピクセルデータを配列として取得
329
  data = gray.getdata()
330
  width, height = gray.size
331
-
332
- # 有効範囲を見つける
333
  min_x, min_y = width, height
334
  max_x = max_y = 0
335
-
336
- # ピクセルデータを2次元配列に変換して処理
337
  pixels = list(data)
338
  pixels = [pixels[i * width:(i + 1) * width] for i in range(height)]
339
-
340
- # 各行をスキャンして非空白ピクセルを見つける
341
  for y in range(height):
342
  for x in range(width):
343
- if pixels[y][x] < threshold: # 非空白ピクセル
344
  min_x = min(min_x, x)
345
  min_y = min(min_y, y)
346
  max_x = max(max_x, x)
347
  max_y = max(max_y, y)
348
-
349
- # 境界外のトリミングの場合はエラー
350
  if min_x > max_x or min_y > max_y:
351
  logger.warning("トリミング領域が見つかりません。元の画像を返します。")
352
  return image
353
-
354
- # パディングを追加
355
  min_x = max(0, min_x - padding)
356
  min_y = max(0, min_y - padding)
357
  max_x = min(width - 1, max_x + padding)
358
  max_y = min(height - 1, max_y + padding)
359
-
360
- # 画像をトリミング
361
  trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
362
-
363
  logger.info(f"画像をトリミングしました: 元サイズ {width}x{height} → トリミング後 {trimmed.width}x{trimmed.height}")
364
  return trimmed
365
 
366
  # --- Core Screenshot Logic ---
367
- def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
368
  trim_whitespace: bool = True) -> Image.Image:
369
  """
370
  Renders HTML code to a full-page screenshot using Selenium.
371
-
372
- Args:
373
- html_code: The HTML source code string.
374
- extension_percentage: Percentage of extra space to add vertically (e.g., 4 means 4% total).
375
- trim_whitespace: Whether to trim excess whitespace from the image.
376
-
377
- Returns:
378
- A PIL Image object of the screenshot. Returns a 1x1 black image on error.
379
  """
380
- tmp_path = None # 初期化
381
- driver = None # 初期化
382
 
383
- # 1) Save HTML code to a temporary file
384
  try:
385
  with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
386
  tmp_path = tmp_file.name
@@ -388,15 +337,14 @@ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0
388
  logger.info(f"HTML saved to temporary file: {tmp_path}")
389
  except Exception as e:
390
  logger.error(f"Error writing temporary HTML file: {e}")
391
- return Image.new('RGB', (1, 1), color=(0, 0, 0)) # エラー時は黒画像
392
 
393
- # 2) Headless Chrome(Chromium) options
394
  options = Options()
395
  options.add_argument("--headless")
396
  options.add_argument("--no-sandbox")
397
  options.add_argument("--disable-dev-shm-usage")
398
  options.add_argument("--force-device-scale-factor=1")
399
- # Font Awesomeが読み込まれない場合があるため、読み込み待機時間を長く設定
400
  options.add_argument("--disable-features=NetworkService")
401
  options.add_argument("--dns-prefetch-disable")
402
 
@@ -405,101 +353,117 @@ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0
405
  driver = webdriver.Chrome(options=options)
406
  logger.info("WebDriver initialized.")
407
 
408
- # 3) 初期ウィンドウサイズを設定(コンテンツの種類に関わらず同じサイズ)
409
- initial_width = 1200
410
  initial_height = 1000
411
  driver.set_window_size(initial_width, initial_height)
412
  file_url = "file://" + tmp_path
413
  logger.info(f"Navigating to {file_url}")
414
  driver.get(file_url)
415
 
416
- # 4) Wait for page load with extended timeout
417
  logger.info("Waiting for body element...")
418
  WebDriverWait(driver, 15).until(
419
  EC.presence_of_element_located((By.TAG_NAME, "body"))
420
  )
421
  logger.info("Body element found. Waiting for potential resource loading...")
422
-
423
- # リソース読み込みの待機時間
424
- time.sleep(3) # 初期待機
425
-
426
- # 5) Font Awesomeと外部リソースの読み込み完了を確認
427
  try:
428
- # Font Awesomeと外部リソースの読み込み完了を確認するスクリプト
429
  resource_check_script = """
430
- return new Promise((resolve) => {
431
- // Font Awesomeの読み込み確認
432
- const checkFontAwesome = () => {
433
- const icons = document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]');
434
- if (icons.length > 0) {
435
- console.log('Font Awesome icons found:', icons.length);
436
- // すべてのフォントの読み込み完了を待つ
437
- document.fonts.ready.then(() => {
438
- console.log('All fonts loaded');
439
- setTimeout(resolve, 1000); // 安定化のための追加待機
440
- });
 
 
 
441
  } else {
442
- // アイコンがない、またはすでに読み込み完了
443
- document.fonts.ready.then(() => setTimeout(resolve, 500));
444
  }
445
- };
446
-
447
- // DOMContentLoadedまたはloadイベント後にチェック
448
- if (document.readyState === 'complete') {
449
- checkFontAwesome();
450
- } else {
451
- window.addEventListener('load', checkFontAwesome);
452
- }
453
- });
454
  """
455
-
456
  logger.info("Waiting for Font Awesome and other resources to load...")
457
- driver.set_script_timeout(15) # 15秒のタイムアウト
458
- driver.execute_async_script(f"const callback = arguments[arguments.length - 1]; {resource_check_script}.then(callback);")
 
 
459
  logger.info("Resources loaded successfully")
460
- except Exception as e:
461
  logger.warning(f"Resource loading check failed: {e}. Using fallback wait.")
462
- time.sleep(8) # より長い待機時間を設定
463
-
464
- # 6) スクロールを制御してコンテンツ全体が描画されるようにする
 
 
 
465
  try:
466
  scroll_script = """
467
- return new Promise(resolve => {
468
- const height = Math.max(
469
- document.documentElement.scrollHeight,
470
- document.body.scrollHeight
471
- );
472
- const viewportHeight = window.innerHeight;
473
-
474
- // ページを少しずつスクロールして全体を描画させる
475
- const scrollStep = Math.floor(viewportHeight * 0.8);
476
- let currentPos = 0;
477
-
478
- const scrollDown = () => {
479
- if (currentPos < height) {
480
- window.scrollTo(0, currentPos);
481
- currentPos += scrollStep;
482
- setTimeout(scrollDown, 100);
483
- } else {
484
- // 最後にトップに戻す
485
- window.scrollTo(0, 0);
486
- setTimeout(resolve, 300);
487
- }
488
- };
489
-
490
- scrollDown();
491
- });
 
 
 
 
 
 
 
 
 
 
 
 
492
  """
493
-
494
  logger.info("Ensuring all content is rendered...")
495
- driver.execute_async_script(f"const callback = arguments[arguments.length - 1]; {scroll_script}.then(callback);")
496
- except Exception as e:
497
- logger.warning(f"Content rendering check failed: {e}")
498
- # スクロールを元の位置に戻す
499
- driver.execute_script("window.scrollTo(0, 0);")
500
- time.sleep(1)
501
-
502
- # 7) Hide scrollbars via CSS
 
 
 
 
 
 
 
 
 
503
  try:
504
  driver.execute_script(
505
  "document.documentElement.style.overflow = 'hidden';"
@@ -509,181 +473,164 @@ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0
509
  except Exception as e:
510
  logger.warning(f"Could not hide scrollbars via JS: {e}")
511
 
512
- # 8) Get full page dimensions accurately with improved script
513
  try:
514
- # より正確なページ寸法を取得するためのJavaScriptコード
515
  dimensions_script = """
516
  return {
517
  width: Math.max(
518
- document.documentElement.scrollWidth,
519
- document.documentElement.offsetWidth,
520
- document.documentElement.clientWidth,
521
- document.body ? document.body.scrollWidth : 0,
522
- document.body ? document.body.offsetWidth : 0,
523
- document.body ? document.body.clientWidth : 0
524
  ),
525
  height: Math.max(
526
- document.documentElement.scrollHeight,
527
- document.documentElement.offsetHeight,
528
- document.documentElement.clientHeight,
529
- document.body ? document.body.scrollHeight : 0,
530
- document.body ? document.body.offsetHeight : 0,
531
- document.body ? document.body.clientHeight : 0
532
  )
533
  };
534
  """
535
  dimensions = driver.execute_script(dimensions_script)
536
  scroll_width = dimensions['width']
537
  scroll_height = dimensions['height']
538
-
539
  logger.info(f"Detected dimensions: width={scroll_width}, height={scroll_height}")
540
-
541
- # スクロールして確認する追加の検証
542
- driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
543
- time.sleep(1) # スクロール完了を待つ
544
- driver.execute_script("window.scrollTo(0, 0);")
545
- time.sleep(1) # 元の位置に戻す
546
-
547
- # 再検証
548
- dimensions_after_scroll = driver.execute_script(dimensions_script)
549
- scroll_height = max(scroll_height, dimensions_after_scroll['height'])
550
-
551
- logger.info(f"After scroll check, height={scroll_height}")
552
-
553
- # 最小値と最大値の設定
554
- scroll_width = max(scroll_width, 100) # 最小幅
555
- scroll_height = max(scroll_height, 100) # 最小高さ
556
-
557
- scroll_width = min(scroll_width, 2000) # 最大幅
558
- scroll_height = min(scroll_height, 4000) # 最大高さ
559
 
560
  except Exception as e:
561
  logger.error(f"Error getting page dimensions: {e}")
562
- # フォールバックとしてデフォルト値を設定
563
  scroll_width = 1200
564
  scroll_height = 1000
565
  logger.warning(f"Falling back to dimensions: width={scroll_width}, height={scroll_height}")
566
-
567
- # 9) レイアウト安定化の確認 (修正版)
568
  try:
569
  stability_script = """
570
- return new Promise((resolve, reject) => {
571
- let lastHeight = 0;
572
- let lastWidth = 0;
573
- let stableCount = 0;
574
- const maxChecks = 15; // 最大チェック回数(無限ループ防止)
575
- let checkCount = 0;
576
-
577
- const checkStability = () => {
578
- checkCount++;
579
- if (checkCount > maxChecks) {
580
- console.warn('Layout stability check reached max attempts.');
581
- resolve(false); // 安定しなかったと判断
582
- return;
583
- }
584
-
585
- // body要素が存在し、offsetHeight/offsetWidthが利用可能かチェック
586
- const bodyExists = document.body && typeof document.body.offsetHeight === 'number' && typeof document.body.offsetWidth === 'number';
587
-
588
- if (!bodyExists) {
589
- // bodyがまだ利用できない場合、少し待って再試行
590
- console.warn('Document body not fully available yet for stability check.');
591
- setTimeout(checkStability, 250); // 待機時間を少し長くする
592
- return;
593
- }
594
-
595
- const currentHeight = document.body.offsetHeight;
596
- const currentWidth = document.body.offsetWidth;
597
-
598
- // サイズが0の場合も不安定とみなす(初期化中など)
599
- if (currentHeight === 0 || currentWidth === 0) {
600
- stableCount = 0; // カウントリセット
601
- lastHeight = 0; // 前回値もリセット
602
- lastWidth = 0;
603
- console.warn('Body dimensions are zero, waiting...');
604
- setTimeout(checkStability, 250); // 再試行
605
- return;
606
- }
607
 
608
- if (currentHeight === lastHeight && currentWidth === lastWidth) {
609
- stableCount++;
610
- if (stableCount >= 3) { // 3回連続で同じなら安定と判断
611
- console.log('Layout deemed stable.');
612
- resolve(true); // 安定した
613
  return;
614
  }
615
- } else {
616
- stableCount = 0; // サイズが変わったらリセット
617
- lastHeight = currentHeight;
618
- lastWidth = currentWidth;
619
- console.log(`Layout changed: ${lastWidth}x${lastHeight}. Resetting stability count.`);
620
- }
621
 
622
- // 次のチェックをスケジュール
623
- setTimeout(checkStability, 200);
624
- };
625
 
626
- // 初回チェックを開始
627
- // 少し遅延させて初回実行(DOMが完全に準備されるのを待つ)
628
- setTimeout(checkStability, 100);
629
- });
630
- """
 
 
 
631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  logger.info("Checking layout stability...")
633
- # 非同期スクリプトのタイムアウトを設定 (例: 20秒)
634
  driver.set_script_timeout(20)
635
- stable = driver.execute_async_script(f"const callback = arguments[arguments.length - 1]; {stability_script}.then(callback);")
 
 
636
 
637
  if stable:
638
  logger.info("Layout is stable")
639
  else:
640
- # 安定しなかった場合も警告を出す
641
  logger.warning("Layout did not stabilize within the expected time. Proceeding anyway.")
642
- time.sleep(2) # 念のため少し待つ
643
 
644
  except TimeoutException:
645
- # スクリプト実行がタイムアウトした場合
646
  logger.warning("Layout stability check timed out. Proceeding anyway.")
647
- time.sleep(2) # 念のため少し待つ
 
 
 
648
  except Exception as e:
649
- # その他の予期せぬエラー(元のエラーが発生していた箇所)
650
- logger.error(f"Error during layout stability check: {e}", exc_info=True) # スタックトレース付きでエラー出力
651
- time.sleep(2) # エラー時も待機して続行
652
 
653
- # 10) Calculate adjusted height with user-specified margin
654
  adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
655
- # Ensure adjusted height is not excessively large or small
656
- adjusted_height = max(adjusted_height, scroll_height, 100) # 最小高さを確保
657
  logger.info(f"Adjusted height calculated: {adjusted_height} (extension: {extension_percentage}%)")
658
 
659
- # 11) Set window size to full page dimensions
660
  adjusted_width = scroll_width
661
  logger.info(f"Resizing window to: width={adjusted_width}, height={adjusted_height}")
662
  driver.set_window_size(adjusted_width, adjusted_height)
663
  logger.info("Waiting for layout stabilization after resize...")
664
-
665
- # レイアウト安定化のための待機
666
- time.sleep(4) # 統一した待機時間
667
 
668
- # 外部リソースの読み込み状態を確認
669
  try:
670
  resource_state = driver.execute_script("""
671
  return {
672
  readyState: document.readyState,
673
- resourcesComplete: !document.querySelector('img:not([complete])') &&
674
  !document.querySelector('link[rel="stylesheet"]:not([loaded])')
675
  };
676
  """)
677
  logger.info(f"Resource state: {resource_state}")
678
-
679
- # ドキュメントの読み込みが完了していない場合、追加で待機
680
  if resource_state['readyState'] != 'complete':
681
  logger.info("Document still loading, waiting additional time...")
682
  time.sleep(2)
683
  except Exception as e:
684
  logger.warning(f"Error checking resource state: {e}")
685
 
686
- # Scroll to top just in case
687
  try:
688
  driver.execute_script("window.scrollTo(0, 0)")
689
  time.sleep(1)
@@ -691,28 +638,20 @@ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0
691
  except Exception as e:
692
  logger.warning(f"Could not scroll to top: {e}")
693
 
694
- # 12) Take screenshot
695
  logger.info("Taking screenshot...")
696
  png = driver.get_screenshot_as_png()
697
  logger.info("Screenshot taken successfully.")
698
-
699
- # Convert to PIL Image
700
  img = Image.open(BytesIO(png))
701
-
702
- # 画像サイズの確認とログ
703
  logger.info(f"Screenshot dimensions: {img.width}x{img.height}")
704
-
705
- # 余白トリミングが有効な場合
706
  if trim_whitespace:
707
- # 余分な空白をトリミング
708
  img = trim_image_whitespace(img, threshold=248, padding=20)
709
  logger.info(f"Trimmed dimensions: {img.width}x{img.height}")
710
-
711
  return img
712
 
713
  except Exception as e:
714
  logger.error(f"An error occurred during screenshot generation: {e}", exc_info=True)
715
- return Image.new('RGB', (1, 1), color=(0, 0, 0)) # Return black 1x1 image on error
716
  finally:
717
  logger.info("Cleaning up...")
718
  if driver:
@@ -728,23 +667,17 @@ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0
728
  except Exception as e:
729
  logger.error(f"Error removing temporary file {tmp_path}: {e}")
730
 
731
- # --- Geminiを使った新しい関数 ---
732
  def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3, trim_whitespace: bool = True) -> Image.Image:
733
- """テキストをGemini APIでHTMLに変換し、スクリーンショットを生成する統合関数"""
734
  try:
735
- # 1. テキストからHTMLを生成(温度パラメータも渡す)
736
  html_code = generate_html_from_text(text, temperature)
737
-
738
- # 2. HTMLからスクリーンショットを生成
739
  return render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace)
740
  except Exception as e:
741
  logger.error(f"テキストからスクリーンショット生成中にエラーが発生: {e}", exc_info=True)
742
- return Image.new('RGB', (1, 1), color=(0, 0, 0)) # エラー時は黒画像
743
 
744
- # --- FastAPI Setup ---
745
  app = FastAPI()
746
-
747
- # CORS設定を追加
748
  app.add_middleware(
749
  CORSMiddleware,
750
  allow_origins=["*"],
@@ -752,203 +685,107 @@ app.add_middleware(
752
  allow_methods=["*"],
753
  allow_headers=["*"],
754
  )
755
-
756
- # 静的ファイルのサービング設定
757
- # Gradioのディレクトリを探索してアセットを見つける
758
  gradio_dir = os.path.dirname(gr.__file__)
759
  logger.info(f"Gradio version: {gr.__version__}")
760
  logger.info(f"Gradio directory: {gradio_dir}")
761
-
762
- # 基本的な静的ファイルディレクトリをマウント
763
  static_dir = os.path.join(gradio_dir, "templates", "frontend", "static")
764
  if os.path.exists(static_dir):
765
  logger.info(f"Mounting static directory: {static_dir}")
766
  app.mount("/static", StaticFiles(directory=static_dir), name="static")
767
-
768
- # _appディレクトリを探す(新しいSvelteKitベースのフロントエンド用)
769
  app_dir = os.path.join(gradio_dir, "templates", "frontend", "_app")
770
  if os.path.exists(app_dir):
771
  logger.info(f"Mounting _app directory: {app_dir}")
772
  app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
773
-
774
- # assetsディレクトリを探す
775
  assets_dir = os.path.join(gradio_dir, "templates", "frontend", "assets")
776
  if os.path.exists(assets_dir):
777
  logger.info(f"Mounting assets directory: {assets_dir}")
778
  app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
779
-
780
- # cdnディレクトリがあれば追加
781
  cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
782
  if os.path.exists(cdn_dir):
783
  logger.info(f"Mounting cdn directory: {cdn_dir}")
784
  app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
785
 
786
- # API Endpoint for screenshot generation
787
- @app.post("/api/screenshot",
788
- response_class=StreamingResponse,
789
- tags=["Screenshot"],
790
- summary="Render HTML to Full Page Screenshot",
791
- description="Takes HTML code and an optional vertical extension percentage, renders it using a headless browser, and returns the full-page screenshot as a PNG image.")
792
  async def api_render_screenshot(request: ScreenshotRequest):
793
- """
794
- API endpoint to render HTML and return a screenshot.
795
- """
796
  try:
797
  logger.info(f"API request received. Extension: {request.extension_percentage}%")
798
- # Run the blocking Selenium code in a separate thread (FastAPI handles this)
799
  pil_image = render_fullpage_screenshot(
800
  request.html_code,
801
  request.extension_percentage,
802
  request.trim_whitespace
803
  )
804
-
805
  if pil_image.size == (1, 1):
806
  logger.error("Screenshot generation failed, returning 1x1 image.")
807
- # Optionally return a proper error response instead of 1x1 image
808
- # raise HTTPException(status_code=500, detail="Failed to generate screenshot")
809
-
810
- # Convert PIL Image to PNG bytes
811
  img_byte_arr = BytesIO()
812
  pil_image.save(img_byte_arr, format='PNG')
813
- img_byte_arr.seek(0) # Go to the start of the BytesIO buffer
814
-
815
  logger.info("Returning screenshot as PNG stream.")
816
  return StreamingResponse(img_byte_arr, media_type="image/png")
817
-
818
  except Exception as e:
819
  logger.error(f"API Error: {e}", exc_info=True)
820
  raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
821
 
822
- # --- 新しいGemini API連携エンドポイント ---
823
- @app.post("/api/text-to-screenshot",
824
- response_class=StreamingResponse,
825
- tags=["Screenshot", "Gemini"],
826
- summary="テキストからインフォグラフィックを生成",
827
- description="テキストをGemini APIを使ってHTMLインフォグラフィックに変換し、スクリーンショットとして返します。")
828
  async def api_text_to_screenshot(request: GeminiRequest):
829
- """
830
- テキストからHTMLインフォグラフィックを生成してスクリーンショットを返すAPIエンドポイント
831
- """
832
  try:
833
  logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長さ: {len(request.text)}, 拡張率: {request.extension_percentage}%, 温度: {request.temperature}")
834
-
835
- # テキストからHTMLを生成してスクリーンショットを作成(温度パラメータも渡す)
836
  pil_image = text_to_screenshot(
837
  request.text,
838
  request.extension_percentage,
839
  request.temperature,
840
  request.trim_whitespace
841
  )
842
-
843
  if pil_image.size == (1, 1):
844
  logger.error("スクリーンショット生成に失敗しました。1x1画像を返します。")
845
- # raise HTTPException(status_code=500, detail="スクリーンショット生成に失敗しました")
846
-
847
- # PIL画像をPNGバイトに変換
848
  img_byte_arr = BytesIO()
849
  pil_image.save(img_byte_arr, format='PNG')
850
- img_byte_arr.seek(0) # BytesIOバッファの先頭に戻る
851
-
852
  logger.info("スクリーンショットをPNGストリームとして返します。")
853
  return StreamingResponse(img_byte_arr, media_type="image/png")
854
-
855
  except Exception as e:
856
  logger.error(f"API Error: {e}", exc_info=True)
857
  raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
858
 
859
- # --- Gradio Interface Definition ---
860
- # 入力モードの選択用Radioコンポーネント
861
  def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace):
862
- """入力モードに応じて適切な処理を行う"""
863
  if input_mode == "HTML入力":
864
- # HTMLモードの場合は既存の処理
865
  return render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
866
  else:
867
- # テキスト入力モードの場合はGemini APIを使用
868
  return text_to_screenshot(input_text, extension_percentage, temperature, trim_whitespace)
869
 
870
- # Gradio UIの定義
871
  with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Base()) as iface:
872
  gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
873
  gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
874
-
875
  with gr.Row():
876
- input_mode = gr.Radio(
877
- ["HTML入力", "テキスト入力"],
878
- label="入力モード",
879
- value="HTML入力"
880
- )
881
-
882
- # 共用のテキストボックス
883
- input_text = gr.Textbox(
884
- lines=15,
885
- label="入力",
886
- placeholder="HTMLコードまたはテキストを入力してください。入力モードに応じて処理されます。"
887
- )
888
-
889
  with gr.Row():
890
- extension_percentage = gr.Slider(
891
- minimum=0,
892
- maximum=30,
893
- step=1.0,
894
- value=6, # デフォルト値6%
895
- label="上下高さ拡張率(%)"
896
- )
897
-
898
- # 温度調整スライダー(テキストモード時のみ表示)
899
- temperature = gr.Slider(
900
- minimum=0.0,
901
- maximum=1.0,
902
- step=0.1,
903
- value=0.3, # デフォルト値を0.3に下げて創造性を抑制
904
- label="生成時の温度(低い=一貫性高、高い=創造性高)",
905
- visible=False # 最初は非表示
906
- )
907
-
908
- # 余白トリミングオプション
909
- trim_whitespace = gr.Checkbox(
910
- label="余白を自動トリミング",
911
- value=True,
912
- info="生成される画像から余分な空白領域を自動的に削除します"
913
- )
914
-
915
  submit_btn = gr.Button("生成")
916
  output_image = gr.Image(type="pil", label="ページ全体のスクリーンショット")
917
-
918
- # 入力モード変更時のイベント処理(テキストモード時のみ温度スライダーを表示)
919
  def update_temperature_visibility(mode):
920
- # Gradio 4.x用のアップデート方法
921
  return {"visible": mode == "テキスト入力", "__type__": "update"}
922
-
923
- input_mode.change(
924
- fn=update_temperature_visibility,
925
- inputs=input_mode,
926
- outputs=temperature
927
- )
928
-
929
- # 生成ボタンクリック時のイベント処理
930
- submit_btn.click(
931
- fn=process_input,
932
- inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace],
933
- outputs=output_image
934
- )
935
-
936
- # 環境変数情報を表示
937
  gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
938
  gr.Markdown(f"""
939
  ## APIエンドポイント
940
- - `/api/screenshot` - HTMLコードからスクリーンショットを生成
941
  - `/api/text-to-screenshot` - テキストからインフォグラフィックスクリーンショットを生成
942
-
943
  ## 設定情報
944
  - 使用モデル: {gemini_model} (環境変数 GEMINI_MODEL で変更可能)
945
  """)
946
 
947
- # --- Mount Gradio App onto FastAPI ---
948
  app = gr.mount_gradio_app(app, iface, path="/")
949
 
950
- # --- Run with Uvicorn (for local testing) ---
951
  if __name__ == "__main__":
952
  import uvicorn
953
  logger.info("Starting Uvicorn server for local development...")
954
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ # -*- coding: utf-8 -*-
2
  import gradio as gr
3
  from fastapi import FastAPI, HTTPException, Body
4
  from fastapi.responses import StreamingResponse
 
10
  from selenium.webdriver.common.by import By
11
  from selenium.webdriver.support.ui import WebDriverWait
12
  from selenium.webdriver.support import expected_conditions as EC
13
+ # TimeoutException と JavascriptException をインポート
14
+ from selenium.common.exceptions import TimeoutException, JavascriptException
15
  from PIL import Image
16
  from io import BytesIO
17
  import tempfile
 
52
  margin-right: 8px !important;
53
  vertical-align: middle !important;
54
  }
55
+
56
  /* テキスト内のアイコン位置調整 */
57
+ h1 [class*="fa-"], h2 [class*="fa-"], h3 [class*="fa-"],
58
  h4 [class*="fa-"], h5 [class*="fa-"], h6 [class*="fa-"] {
59
  vertical-align: middle !important;
60
  margin-right: 10px !important;
61
  }
62
+
63
  /* 特定パターンの修正 */
64
  .fa + span, .fas + span, .far + span, .fab + span,
65
  span + .fa, span + .fas, span + .far, span + .fab {
66
  display: inline-block !important;
67
  margin-left: 5px !important;
68
  }
69
+
70
  /* カード内アイコン修正 */
71
  .card [class*="fa-"], .card-body [class*="fa-"] {
72
  float: none !important;
73
  clear: none !important;
74
  position: relative !important;
75
  }
76
+
77
  /* アイコンと文字が重なる場合の調整 */
78
  li [class*="fa-"], p [class*="fa-"] {
79
  margin-right: 10px !important;
80
  }
81
+
82
  /* インラインアイコンのスペーシング */
83
  .inline-icon {
84
  display: inline-flex !important;
85
  align-items: center !important;
86
  justify-content: flex-start !important;
87
  }
88
+
89
  /* アイコン後のテキスト */
90
  [class*="fa-"] + span {
91
  display: inline-block !important;
 
93
  }
94
  </style>
95
  """
96
+
97
  # headタグがある場合はその中に追加
98
  if '<head>' in html_code:
99
  return html_code.replace('</head>', f'{fa_fix_css}</head>')
 
106
  body_start = html_code.find('<body')
107
  if body_start > 0:
108
  return html_code[:body_start] + f'<head>{fa_fix_css}</head>' + html_code[body_start:]
109
+
110
  # どちらもない場合は先頭に追加
111
  return f'<html><head>{fa_fix_css}</head>' + html_code + '</html>'
112
 
 
118
  if not api_key:
119
  logger.error("GEMINI_API_KEY 環境変数が設定されていません")
120
  raise ValueError("GEMINI_API_KEY 環境変数が設定されていません")
121
+
122
  # モデル名の取得(環境変数から、なければデフォルト値)
123
  model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
124
  logger.info(f"使用するGeminiモデル: {model_name}")
125
+
126
  # Gemini APIの設定
127
  genai.configure(api_key=api_key)
128
+
129
+ # システムプロンプト(変更なし)
130
  system_instruction = """# グラフィックレコーディング風インフォグラフィック変換プロンプト V2
131
  ## 目的
132
  以下の内容を、超一流デザイナーが作成したような、日本語で完璧なグラフィックレコーディング風のHTMLインフォグラフィックに変換してください。情報設計とビジュアルデザインの両面で最高水準を目指します。
 
234
  - フッターに出典情報と関連するFont Awesomeアイコン(fa-book, fa-citation等)を明記
235
  ## 変換する文章/記事
236
  ーーー<ユーザーが入力(または添付)>ーーー"""
237
+
238
  # モデルを初期化して処理
239
  logger.info(f"Gemini APIにリクエストを送信: テキスト長さ = {len(text)}, 温度 = {temperature}")
240
+
241
  # モデル初期化とフォールバック処理
242
  try:
243
  model = genai.GenerativeModel(model_name)
244
  except Exception as e:
 
245
  fallback_model = "gemini-pro"
246
  logger.warning(f"{model_name}が利用できません: {e}, フォールバックモデル{fallback_model}を使用します")
247
  model = genai.GenerativeModel(fallback_model)
248
+
249
+ # 生成設定 (変更なし)
250
  generation_config = {
251
+ "temperature": temperature,
252
+ "top_p": 0.7,
253
+ "top_k": 20,
254
  "max_output_tokens": 8192,
255
+ "candidate_count": 1
256
  }
257
+
258
+ # 安全設定 (変更なし)
259
  safety_settings = [
260
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
261
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
262
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
263
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}
 
 
 
 
 
 
 
 
 
 
 
 
264
  ]
265
+
266
  # プロンプト構築
267
  prompt = f"{system_instruction}\n\n{text}"
268
+
269
  # コンテンツ生成
270
  response = model.generate_content(
271
  prompt,
272
  generation_config=generation_config,
273
  safety_settings=safety_settings
274
  )
275
+
276
+ # レスポンスからHTMLを抽出 (変更なし)
277
  raw_response = response.text
 
 
278
  html_start = raw_response.find("```html")
279
  html_end = raw_response.rfind("```")
280
+
281
  if html_start != -1 and html_end != -1 and html_start < html_end:
282
+ html_start += 7
283
  html_code = raw_response[html_start:html_end].strip()
284
  logger.info(f"HTMLの生成に成功: 長さ = {len(html_code)}")
 
 
285
  html_code = enhance_font_awesome_layout(html_code)
286
  logger.info("Font Awesomeレイアウトの最適化を適用しました")
 
287
  return html_code
288
  else:
 
289
  logger.warning("レスポンスから```html```タグが見つかりませんでした。全テキストを返します。")
290
  return raw_response
291
+
292
  except Exception as e:
293
  logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
294
  raise Exception(f"Gemini APIでのHTML生成に失敗しました: {e}")
295
 
296
+ # 画像から余分な空白領域をトリミングする関数 (変更なし)
297
  def trim_image_whitespace(image, threshold=250, padding=10):
 
 
 
 
 
 
 
 
 
 
 
 
298
  gray = image.convert('L')
 
 
299
  data = gray.getdata()
300
  width, height = gray.size
 
 
301
  min_x, min_y = width, height
302
  max_x = max_y = 0
 
 
303
  pixels = list(data)
304
  pixels = [pixels[i * width:(i + 1) * width] for i in range(height)]
 
 
305
  for y in range(height):
306
  for x in range(width):
307
+ if pixels[y][x] < threshold:
308
  min_x = min(min_x, x)
309
  min_y = min(min_y, y)
310
  max_x = max(max_x, x)
311
  max_y = max(max_y, y)
 
 
312
  if min_x > max_x or min_y > max_y:
313
  logger.warning("トリミング領域が見つかりません。元の画像を返します。")
314
  return image
 
 
315
  min_x = max(0, min_x - padding)
316
  min_y = max(0, min_y - padding)
317
  max_x = min(width - 1, max_x + padding)
318
  max_y = min(height - 1, max_y + padding)
 
 
319
  trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
 
320
  logger.info(f"画像をトリミングしました: 元サイズ {width}x{height} → トリミング後 {trimmed.width}x{trimmed.height}")
321
  return trimmed
322
 
323
  # --- Core Screenshot Logic ---
324
+ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
325
  trim_whitespace: bool = True) -> Image.Image:
326
  """
327
  Renders HTML code to a full-page screenshot using Selenium.
 
 
 
 
 
 
 
 
328
  """
329
+ tmp_path = None
330
+ driver = None
331
 
332
+ # 1) Save HTML code to a temporary file (変更なし)
333
  try:
334
  with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
335
  tmp_path = tmp_file.name
 
337
  logger.info(f"HTML saved to temporary file: {tmp_path}")
338
  except Exception as e:
339
  logger.error(f"Error writing temporary HTML file: {e}")
340
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
341
 
342
+ # 2) Headless Chrome options (変更なし)
343
  options = Options()
344
  options.add_argument("--headless")
345
  options.add_argument("--no-sandbox")
346
  options.add_argument("--disable-dev-shm-usage")
347
  options.add_argument("--force-device-scale-factor=1")
 
348
  options.add_argument("--disable-features=NetworkService")
349
  options.add_argument("--dns-prefetch-disable")
350
 
 
353
  driver = webdriver.Chrome(options=options)
354
  logger.info("WebDriver initialized.")
355
 
356
+ # 3) Initial window size and navigation (変更なし)
357
+ initial_width = 1200
358
  initial_height = 1000
359
  driver.set_window_size(initial_width, initial_height)
360
  file_url = "file://" + tmp_path
361
  logger.info(f"Navigating to {file_url}")
362
  driver.get(file_url)
363
 
364
+ # 4) Wait for body (変更なし)
365
  logger.info("Waiting for body element...")
366
  WebDriverWait(driver, 15).until(
367
  EC.presence_of_element_located((By.TAG_NAME, "body"))
368
  )
369
  logger.info("Body element found. Waiting for potential resource loading...")
370
+ time.sleep(3)
371
+
372
+ # 5) Font Awesome and resource check (修正: 即時実行関数 + コメント削除)
 
 
373
  try:
 
374
  resource_check_script = """
375
+ return (function() {
376
+ return new Promise((resolve) => {
377
+ const checkFontAwesome = () => {
378
+ const icons = document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]');
379
+ if (icons.length > 0) {
380
+ document.fonts.ready.then(() => {
381
+ setTimeout(resolve, 1000);
382
+ });
383
+ } else {
384
+ document.fonts.ready.then(() => setTimeout(resolve, 500));
385
+ }
386
+ };
387
+ if (document.readyState === 'complete') {
388
+ checkFontAwesome();
389
  } else {
390
+ window.addEventListener('load', checkFontAwesome);
 
391
  }
392
+ });
393
+ })();
 
 
 
 
 
 
 
394
  """
 
395
  logger.info("Waiting for Font Awesome and other resources to load...")
396
+ driver.set_script_timeout(15)
397
+ # execute_async_script に渡す前に文字列として結合
398
+ full_script = f"const callback = arguments[arguments.length - 1]; ({resource_check_script}).then(callback);"
399
+ driver.execute_async_script(full_script)
400
  logger.info("Resources loaded successfully")
401
+ except (TimeoutException, JavascriptException) as e: # JavascriptExceptionも捕捉
402
  logger.warning(f"Resource loading check failed: {e}. Using fallback wait.")
403
+ time.sleep(8)
404
+ except Exception as e: # その他の予期せぬエラー
405
+ logger.error(f"Unexpected error during resource check: {e}", exc_info=True)
406
+ time.sleep(8)
407
+
408
+ # 6) Ensure content rendering by scrolling (修正: 即時実行関数 + コメント削除 + ロジック微調整)
409
  try:
410
  scroll_script = """
411
+ return (function() {
412
+ return new Promise(resolve => {
413
+ const pageHeight = Math.max(
414
+ document.documentElement.scrollHeight || 0,
415
+ document.body ? document.body.scrollHeight || 0 : 0
416
+ );
417
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 800;
418
+ const scrollStep = Math.floor(viewportHeight * 0.8);
419
+ let currentPos = 0;
420
+
421
+ const scrollDown = () => {
422
+ if (!document.body || pageHeight <= 0) {
423
+ window.scrollTo(0, 0);
424
+ setTimeout(resolve, 300);
425
+ return;
426
+ }
427
+
428
+ if (currentPos < pageHeight) {
429
+ window.scrollTo(0, currentPos);
430
+ currentPos += scrollStep;
431
+ if (currentPos >= pageHeight || scrollStep <= 0) {
432
+ window.scrollTo(0, pageHeight);
433
+ setTimeout(() => {
434
+ window.scrollTo(0, 0);
435
+ setTimeout(resolve, 300);
436
+ }, 150);
437
+ } else {
438
+ setTimeout(scrollDown, 100);
439
+ }
440
+ } else {
441
+ window.scrollTo(0, 0);
442
+ setTimeout(resolve, 300);
443
+ }
444
+ };
445
+ setTimeout(scrollDown, 100);
446
+ });
447
+ })();
448
  """
 
449
  logger.info("Ensuring all content is rendered...")
450
+ driver.set_script_timeout(20) # スクロール用にタイムアウトを少し長く
451
+ # execute_async_script に渡す前に文字列として結合
452
+ full_script = f"const callback = arguments[arguments.length - 1]; ({scroll_script}).then(callback);"
453
+ driver.execute_async_script(full_script)
454
+ logger.info("Content rendering scroll finished.")
455
+ except (TimeoutException, JavascriptException) as e: # JavascriptExceptionも捕捉
456
+ logger.warning(f"Content rendering check failed: {e}. Scrolling to top and waiting.")
457
+ try:
458
+ driver.execute_script("window.scrollTo(0, 0);")
459
+ except Exception as scroll_err:
460
+ logger.error(f"Could not scroll to top after render check failure: {scroll_err}")
461
+ time.sleep(3) # フォールバック待機
462
+ except Exception as e: # その他の予期せぬエラー
463
+ logger.error(f"Unexpected error during content rendering scroll: {e}", exc_info=True)
464
+ time.sleep(3)
465
+
466
+ # 7) Hide scrollbars (変更なし)
467
  try:
468
  driver.execute_script(
469
  "document.documentElement.style.overflow = 'hidden';"
 
473
  except Exception as e:
474
  logger.warning(f"Could not hide scrollbars via JS: {e}")
475
 
476
+ # 8) Get full page dimensions (変更なし)
477
  try:
 
478
  dimensions_script = """
479
  return {
480
  width: Math.max(
481
+ document.documentElement.scrollWidth || 0,
482
+ document.documentElement.offsetWidth || 0,
483
+ document.documentElement.clientWidth || 0,
484
+ document.body ? document.body.scrollWidth || 0 : 0,
485
+ document.body ? document.body.offsetWidth || 0 : 0,
486
+ document.body ? document.body.clientWidth || 0 : 0
487
  ),
488
  height: Math.max(
489
+ document.documentElement.scrollHeight || 0,
490
+ document.documentElement.offsetHeight || 0,
491
+ document.documentElement.clientHeight || 0,
492
+ document.body ? document.body.scrollHeight || 0 : 0,
493
+ document.body ? document.body.offsetHeight || 0 : 0,
494
+ document.body ? document.body.clientHeight || 0 : 0
495
  )
496
  };
497
  """
498
  dimensions = driver.execute_script(dimensions_script)
499
  scroll_width = dimensions['width']
500
  scroll_height = dimensions['height']
 
501
  logger.info(f"Detected dimensions: width={scroll_width}, height={scroll_height}")
502
+
503
+ # スクロール検証 (エラーハンドリング追加)
504
+ try:
505
+ driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
506
+ time.sleep(1)
507
+ driver.execute_script("window.scrollTo(0, 0);")
508
+ time.sleep(1)
509
+ dimensions_after_scroll = driver.execute_script(dimensions_script)
510
+ scroll_height = max(scroll_height, dimensions_after_scroll['height'])
511
+ logger.info(f"After scroll check, height={scroll_height}")
512
+ except Exception as scroll_e:
513
+ logger.warning(f"Error during dimension scroll check: {scroll_e}")
514
+
515
+ scroll_width = max(scroll_width, 100)
516
+ scroll_height = max(scroll_height, 100)
517
+ scroll_width = min(scroll_width, 2000)
518
+ scroll_height = min(scroll_height, 4000)
 
 
519
 
520
  except Exception as e:
521
  logger.error(f"Error getting page dimensions: {e}")
 
522
  scroll_width = 1200
523
  scroll_height = 1000
524
  logger.warning(f"Falling back to dimensions: width={scroll_width}, height={scroll_height}")
525
+
526
+ # 9) Layout stability check (修正: 即時実行関数 + コメント削除 + JavascriptException捕捉)
527
  try:
528
  stability_script = """
529
+ return (function() {
530
+ return new Promise((resolve, reject) => {
531
+ let lastHeight = 0;
532
+ let lastWidth = 0;
533
+ let stableCount = 0;
534
+ const maxChecks = 15;
535
+ let checkCount = 0;
536
+
537
+ const checkStability = () => {
538
+ checkCount++;
539
+ if (checkCount > maxChecks) {
540
+ console.warn('Layout stability check reached max attempts.');
541
+ resolve(false);
542
+ return;
543
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
 
545
+ const bodyExists = document.body && typeof document.body.offsetHeight === 'number' && typeof document.body.offsetWidth === 'number';
546
+ if (!bodyExists) {
547
+ console.warn('Document body not fully available yet for stability check.');
548
+ setTimeout(checkStability, 250);
 
549
  return;
550
  }
 
 
 
 
 
 
551
 
552
+ const currentHeight = document.body.offsetHeight;
553
+ const currentWidth = document.body.offsetWidth;
 
554
 
555
+ if (currentHeight === 0 || currentWidth === 0) {
556
+ stableCount = 0;
557
+ lastHeight = 0;
558
+ lastWidth = 0;
559
+ console.warn('Body dimensions are zero, waiting...');
560
+ setTimeout(checkStability, 250);
561
+ return;
562
+ }
563
 
564
+ if (currentHeight === lastHeight && currentWidth === lastWidth) {
565
+ stableCount++;
566
+ if (stableCount >= 3) {
567
+ console.log('Layout deemed stable.');
568
+ resolve(true);
569
+ return;
570
+ }
571
+ } else {
572
+ stableCount = 0;
573
+ lastHeight = currentHeight;
574
+ lastWidth = currentWidth;
575
+ console.log(`Layout changed: ${lastWidth}x${lastHeight}. Resetting stability count.`);
576
+ }
577
+ setTimeout(checkStability, 200);
578
+ };
579
+ setTimeout(checkStability, 100);
580
+ });
581
+ })();
582
+ """
583
  logger.info("Checking layout stability...")
 
584
  driver.set_script_timeout(20)
585
+ # execute_async_script に渡す前に文字列として結合
586
+ full_script = f"const callback = arguments[arguments.length - 1]; ({stability_script}).then(callback);"
587
+ stable = driver.execute_async_script(full_script)
588
 
589
  if stable:
590
  logger.info("Layout is stable")
591
  else:
 
592
  logger.warning("Layout did not stabilize within the expected time. Proceeding anyway.")
593
+ time.sleep(2)
594
 
595
  except TimeoutException:
 
596
  logger.warning("Layout stability check timed out. Proceeding anyway.")
597
+ time.sleep(2)
598
+ except JavascriptException as e: # JavascriptExceptionを捕捉
599
+ logger.error(f"Javascript error during layout stability check: {e}", exc_info=True)
600
+ time.sleep(2)
601
  except Exception as e:
602
+ logger.error(f"Unexpected error during layout stability check: {e}", exc_info=True)
603
+ time.sleep(2)
 
604
 
605
+ # 10) Calculate adjusted height (変更なし)
606
  adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
607
+ adjusted_height = max(adjusted_height, scroll_height, 100)
 
608
  logger.info(f"Adjusted height calculated: {adjusted_height} (extension: {extension_percentage}%)")
609
 
610
+ # 11) Set window size and wait (変更なし)
611
  adjusted_width = scroll_width
612
  logger.info(f"Resizing window to: width={adjusted_width}, height={adjusted_height}")
613
  driver.set_window_size(adjusted_width, adjusted_height)
614
  logger.info("Waiting for layout stabilization after resize...")
615
+ time.sleep(4)
 
 
616
 
617
+ # Resource state check (変更なし, エラーハンドリングは元々���り)
618
  try:
619
  resource_state = driver.execute_script("""
620
  return {
621
  readyState: document.readyState,
622
+ resourcesComplete: !document.querySelector('img:not([complete])') &&
623
  !document.querySelector('link[rel="stylesheet"]:not([loaded])')
624
  };
625
  """)
626
  logger.info(f"Resource state: {resource_state}")
 
 
627
  if resource_state['readyState'] != 'complete':
628
  logger.info("Document still loading, waiting additional time...")
629
  time.sleep(2)
630
  except Exception as e:
631
  logger.warning(f"Error checking resource state: {e}")
632
 
633
+ # Scroll to top (変更なし, エラーハンドリングは元々あり)
634
  try:
635
  driver.execute_script("window.scrollTo(0, 0)")
636
  time.sleep(1)
 
638
  except Exception as e:
639
  logger.warning(f"Could not scroll to top: {e}")
640
 
641
+ # 12) Take screenshot (変更なし)
642
  logger.info("Taking screenshot...")
643
  png = driver.get_screenshot_as_png()
644
  logger.info("Screenshot taken successfully.")
 
 
645
  img = Image.open(BytesIO(png))
 
 
646
  logger.info(f"Screenshot dimensions: {img.width}x{img.height}")
 
 
647
  if trim_whitespace:
 
648
  img = trim_image_whitespace(img, threshold=248, padding=20)
649
  logger.info(f"Trimmed dimensions: {img.width}x{img.height}")
 
650
  return img
651
 
652
  except Exception as e:
653
  logger.error(f"An error occurred during screenshot generation: {e}", exc_info=True)
654
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
655
  finally:
656
  logger.info("Cleaning up...")
657
  if driver:
 
667
  except Exception as e:
668
  logger.error(f"Error removing temporary file {tmp_path}: {e}")
669
 
670
+ # --- Geminiを使った新しい関数 --- (変更なし)
671
  def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3, trim_whitespace: bool = True) -> Image.Image:
 
672
  try:
 
673
  html_code = generate_html_from_text(text, temperature)
 
 
674
  return render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace)
675
  except Exception as e:
676
  logger.error(f"テキストからスクリーンショット生成中にエラーが発生: {e}", exc_info=True)
677
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
678
 
679
+ # --- FastAPI Setup --- (変更なし)
680
  app = FastAPI()
 
 
681
  app.add_middleware(
682
  CORSMiddleware,
683
  allow_origins=["*"],
 
685
  allow_methods=["*"],
686
  allow_headers=["*"],
687
  )
 
 
 
688
  gradio_dir = os.path.dirname(gr.__file__)
689
  logger.info(f"Gradio version: {gr.__version__}")
690
  logger.info(f"Gradio directory: {gradio_dir}")
 
 
691
  static_dir = os.path.join(gradio_dir, "templates", "frontend", "static")
692
  if os.path.exists(static_dir):
693
  logger.info(f"Mounting static directory: {static_dir}")
694
  app.mount("/static", StaticFiles(directory=static_dir), name="static")
 
 
695
  app_dir = os.path.join(gradio_dir, "templates", "frontend", "_app")
696
  if os.path.exists(app_dir):
697
  logger.info(f"Mounting _app directory: {app_dir}")
698
  app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
 
 
699
  assets_dir = os.path.join(gradio_dir, "templates", "frontend", "assets")
700
  if os.path.exists(assets_dir):
701
  logger.info(f"Mounting assets directory: {assets_dir}")
702
  app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
 
 
703
  cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
704
  if os.path.exists(cdn_dir):
705
  logger.info(f"Mounting cdn directory: {cdn_dir}")
706
  app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
707
 
708
+ # API Endpoint for screenshot generation (変更なし)
709
+ @app.post("/api/screenshot", response_class=StreamingResponse, tags=["Screenshot"], summary="Render HTML to Full Page Screenshot")
 
 
 
 
710
  async def api_render_screenshot(request: ScreenshotRequest):
 
 
 
711
  try:
712
  logger.info(f"API request received. Extension: {request.extension_percentage}%")
 
713
  pil_image = render_fullpage_screenshot(
714
  request.html_code,
715
  request.extension_percentage,
716
  request.trim_whitespace
717
  )
 
718
  if pil_image.size == (1, 1):
719
  logger.error("Screenshot generation failed, returning 1x1 image.")
 
 
 
 
720
  img_byte_arr = BytesIO()
721
  pil_image.save(img_byte_arr, format='PNG')
722
+ img_byte_arr.seek(0)
 
723
  logger.info("Returning screenshot as PNG stream.")
724
  return StreamingResponse(img_byte_arr, media_type="image/png")
 
725
  except Exception as e:
726
  logger.error(f"API Error: {e}", exc_info=True)
727
  raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
728
 
729
+ # Gemini API連携エンドポイント (変更なし)
730
+ @app.post("/api/text-to-screenshot", response_class=StreamingResponse, tags=["Screenshot", "Gemini"], summary="テキストからインフォグラフィックを生成")
 
 
 
 
731
  async def api_text_to_screenshot(request: GeminiRequest):
 
 
 
732
  try:
733
  logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長さ: {len(request.text)}, 拡張率: {request.extension_percentage}%, 温度: {request.temperature}")
 
 
734
  pil_image = text_to_screenshot(
735
  request.text,
736
  request.extension_percentage,
737
  request.temperature,
738
  request.trim_whitespace
739
  )
 
740
  if pil_image.size == (1, 1):
741
  logger.error("スクリーンショット生成に失敗しました。1x1画像を返します。")
 
 
 
742
  img_byte_arr = BytesIO()
743
  pil_image.save(img_byte_arr, format='PNG')
744
+ img_byte_arr.seek(0)
 
745
  logger.info("スクリーンショットをPNGストリームとして返します。")
746
  return StreamingResponse(img_byte_arr, media_type="image/png")
 
747
  except Exception as e:
748
  logger.error(f"API Error: {e}", exc_info=True)
749
  raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
750
 
751
+ # --- Gradio Interface Definition --- (変更なし)
 
752
  def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace):
 
753
  if input_mode == "HTML入力":
 
754
  return render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
755
  else:
 
756
  return text_to_screenshot(input_text, extension_percentage, temperature, trim_whitespace)
757
 
 
758
  with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Base()) as iface:
759
  gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
760
  gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
 
761
  with gr.Row():
762
+ input_mode = gr.Radio(["HTML入力", "テキスト入力"], label="入力モード", value="HTML入力")
763
+ input_text = gr.Textbox(lines=15, label="入力", placeholder="HTMLコードまたはテキストを入力してください。入力モードに応じて処理されます。")
 
 
 
 
 
 
 
 
 
 
 
764
  with gr.Row():
765
+ extension_percentage = gr.Slider(minimum=0, maximum=30, step=1.0, value=6, label="上下高さ拡張率(%)")
766
+ temperature = gr.Slider(minimum=0.0, maximum=1.0, step=0.1, value=0.3, label="生成時の温度(低い=一貫性高、高い=創造性高)", visible=False)
767
+ trim_whitespace = gr.Checkbox(label="余白を自動トリミング", value=True, info="生成される画像から余分な空白領域を自動的に削除します")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
  submit_btn = gr.Button("生成")
769
  output_image = gr.Image(type="pil", label="ページ全体のスクリーンショット")
 
 
770
  def update_temperature_visibility(mode):
 
771
  return {"visible": mode == "テキスト入力", "__type__": "update"}
772
+ input_mode.change(fn=update_temperature_visibility, inputs=input_mode, outputs=temperature)
773
+ submit_btn.click(fn=process_input, inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace], outputs=output_image)
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
775
  gr.Markdown(f"""
776
  ## APIエンドポイント
777
+ - `/api/screenshot` - HTMLコードからスクリーンショットを生成
778
  - `/api/text-to-screenshot` - テキストからインフォグラフィックスクリーンショットを生成
779
+
780
  ## 設定情報
781
  - 使用モデル: {gemini_model} (環境変数 GEMINI_MODEL で変更可能)
782
  """)
783
 
784
+ # --- Mount Gradio App onto FastAPI --- (変更なし)
785
  app = gr.mount_gradio_app(app, iface, path="/")
786
 
787
+ # --- Run with Uvicorn (for local testing) --- (変更なし)
788
  if __name__ == "__main__":
789
  import uvicorn
790
  logger.info("Starting Uvicorn server for local development...")
791
+ uvicorn.run(app, host="0.0.0.0", port=7860)