File size: 16,553 Bytes
a9b39bd
 
b26fd7c
 
 
a9b39bd
 
dd6c350
9e83646
a9b39bd
7780644
a9b39bd
 
 
837c220
 
a9b39bd
 
 
 
4d1318f
 
b26fd7c
44ff874
cd97a2a
b26fd7c
 
 
cd97a2a
 
a9b39bd
 
 
b26fd7c
 
 
 
 
 
 
 
a9b39bd
837c220
 
b26fd7c
837c220
829743f
837c220
 
829743f
b26fd7c
837c220
b26fd7c
 
837c220
 
829743f
b26fd7c
a9b39bd
 
 
837c220
 
829743f
837c220
4d1318f
837c220
dd6c350
 
4d1318f
a9b39bd
 
 
 
 
 
 
b26fd7c
 
 
 
 
 
 
 
 
 
a9b39bd
b26fd7c
a9b39bd
 
b26fd7c
a9b39bd
 
b26fd7c
a9b39bd
 
 
 
 
 
 
 
 
b26fd7c
a9b39bd
 
 
 
aea2a0a
b26fd7c
a9b39bd
 
 
 
 
 
b26fd7c
a9b39bd
7780644
 
 
 
aea2a0a
b26fd7c
 
 
a9b39bd
 
 
 
 
 
 
b26fd7c
aea2a0a
b26fd7c
aea2a0a
a9b39bd
 
 
 
 
 
 
 
 
 
 
b26fd7c
a9b39bd
 
b26fd7c
 
 
 
 
 
 
 
 
 
 
 
 
a9b39bd
 
b26fd7c
aea2a0a
a9b39bd
 
aea2a0a
7780644
 
b26fd7c
a9b39bd
7780644
a9b39bd
7780644
a9b39bd
7780644
 
aea2a0a
2622d8f
 
 
aea2a0a
a9b39bd
b26fd7c
9e83646
 
 
 
b26fd7c
9e83646
aea2a0a
b26fd7c
2622d8f
a9b39bd
aea2a0a
a9b39bd
2622d8f
aea2a0a
7780644
a9b39bd
7780644
 
 
 
2622d8f
7780644
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2622d8f
 
7780644
2622d8f
7780644
c2a7e87
b26fd7c
2622d8f
4d1318f
a9b39bd
b26fd7c
a9b39bd
b9aa4f2
2622d8f
707e04d
a9b39bd
4d151db
4d1318f
7780644
 
b26fd7c
7780644
 
b26fd7c
7780644
4d1318f
fbd2399
2622d8f
 
 
 
 
 
 
 
 
 
 
b26fd7c
 
 
 
 
 
 
 
 
9e83646
b26fd7c
 
 
 
 
9e83646
 
dd6c350
b26fd7c
 
44ff874
b26fd7c
44ff874
b26fd7c
 
a9b39bd
b9aa4f2
44ff874
b26fd7c
44ff874
 
 
b26fd7c
44ff874
 
 
 
 
 
b26fd7c
44ff874
 
 
7780644
 
44ff874
7780644
44ff874
 
 
 
 
 
 
 
b26fd7c
44ff874
 
 
 
a9b39bd
 
b26fd7c
a9b39bd
44ff874
 
b26fd7c
44ff874
 
 
 
 
b26fd7c
44ff874
 
 
 
 
b26fd7c
44ff874
 
b26fd7c
44ff874
 
 
 
b26fd7c
44ff874
 
b26fd7c
 
cd97a2a
a9b39bd
7780644
a9b39bd
b26fd7c
4d1318f
5bb44a9
dd6c350
4d1318f
a9b39bd
 
 
 
 
 
 
 
b26fd7c
dd6c350
b9aa4f2
5bb44a9
74af422
4d1318f
b26fd7c
 
 
a9b39bd
 
 
 
 
 
 
 
b26fd7c
b250db4
a9b39bd
b26fd7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd6c350
 
4d1318f
b26fd7c
 
 
 
 
 
 
9e83646
b26fd7c
dd6c350
9e83646
 
 
 
b26fd7c
9e83646
b26fd7c
 
9e83646
b26fd7c
9e83646
b26fd7c
9e83646
b26fd7c
9e83646
 
b26fd7c
 
9e83646
b26fd7c
 
 
 
 
 
9e83646
 
 
 
 
b26fd7c
 
9e83646
 
b26fd7c
9e83646
b26fd7c
 
 
9e83646
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
#!/usr/bin/env python3
"""
PDF OCR & Markdown変換ツール (v5.latest)

最新のgoogle.genai SDKを使用したPDFからMarkdownへの変換アプリ
"""

import os
import socket
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import List, Tuple

import gradio as gr
import fitz  # PyMuPDF
from google import genai
from google.genai import types

from presets import SYSTEM_PROMPTS, list_presets


# 環境変数から設定を読み込む
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")

if not GEMINI_API_KEY:
    print("⚠️ 警告: GEMINI_API_KEYが設定されていません。")
    print("環境変数またはHugging Face Spacesのシークレットに設定してください。")


def split_pdf(pdf_path: str, output_dir: str, pages_per_chunk: int) -> List[Tuple[int, str]]:
    """
    PDFを指定ページ数ごとに分割する

    Args:
        pdf_path: 元のPDFファイルパス
        output_dir: 分割PDFの保存先ディレクトリ
        pages_per_chunk: 何ページごとに分割するか

    Returns:
        (開始ページ番号, 分割PDFパス)のリスト
    """
    pdf_document = fitz.open(pdf_path)
    total_pages = len(pdf_document)

    split_pdfs = []

    for start_page in range(0, total_pages, pages_per_chunk):
        end_page = min(start_page + pages_per_chunk - 1, total_pages - 1)

        # 新しいPDFドキュメントを作成
        output_pdf = fitz.open()

        # 指定範囲のページを新しいPDFに追加
        for page_num in range(start_page, end_page + 1):
            output_pdf.insert_pdf(pdf_document, from_page=page_num, to_page=page_num)

        # 分割したPDFを保存
        output_path = os.path.join(
            output_dir, f"split_{start_page+1}_to_{end_page+1}.pdf"
        )
        output_pdf.save(output_path)
        output_pdf.close()

        split_pdfs.append((start_page, output_path))

    pdf_document.close()
    return split_pdfs


def process_pdf_chunk(
    pdf_path: str,
    system_instruction: str,
    model_name: str,
    enable_thinking: bool,
) -> str:
    """
    1つのPDFチャンクをGemini APIで処理

    Args:
        pdf_path: PDFファイルパス
        system_instruction: システムインストラクション
        model_name: 使用するモデル名
        enable_thinking: Thinking Mode有効化

    Returns:
        生成されたMarkdown文字列
    """
    # PDFをバイナリ読み込み
    pdf_bytes = Path(pdf_path).read_bytes()

    # Geminiクライアント初期化
    client = genai.Client(api_key=GEMINI_API_KEY)

    # リクエストコンテンツを構築
    contents = [
        types.Content(
            role="user",
            parts=[
                types.Part.from_text(
                    text="添付のPDFを人間が読む順序に従ってMarkdownへ変換してください。"
                ),
                types.Part.from_bytes(
                    data=pdf_bytes,
                    mime_type="application/pdf"
                ),
            ],
        ),
    ]

    # 生成設定
    config = types.GenerateContentConfig(
        system_instruction=[
            types.Part.from_text(text=system_instruction),
        ],
    )

    # Thinking Mode設定
    if enable_thinking:
        try:
            config.thinking_config = types.ThinkingConfig()
        except (TypeError, ValueError) as thinking_error:
            print(f"⚠️ ThinkingConfig設定時にエラーが発生しました: {thinking_error}")

    # ストリーミング処理
    collected_chunks: List[str] = []

    try:
        for chunk in client.models.generate_content_stream(
            model=model_name,
            contents=contents,
            config=config,
        ):
            if hasattr(chunk, "text") and chunk.text:
                collected_chunks.append(chunk.text)

        return "".join(collected_chunks)

    except Exception as e:
        return f"<!-- エラー: {e} -->\n\n⚠️ このチャンクの処理でエラーが発生しました。"


def process_pdf(
    pdf_file,
    preset_name: str,
    custom_prompt: str,
    pages_per_chunk: int,
    model_name: str,
    enable_thinking: bool,
    progress=gr.Progress(),
) -> str:
    """
    PDFファイルを処理してMarkdownに変換

    Args:
        pdf_file: アップロードされたPDFファイル
        preset_name: プリセット名
        custom_prompt: カスタムシステムプロンプト
        pages_per_chunk: 何ページごとに分割するか
        model_name: 使用するモデル名
        enable_thinking: Thinking Mode有効化
        progress: Gradio進捗バー

    Returns:
        変換されたMarkdown文字列
    """
    if not GEMINI_API_KEY:
        return "❌ エラー: GEMINI_API_KEYが設定されていません。\n\n環境変数またはHugging Face Spacesのシークレットに設定してください。"

    if not pdf_file:
        return "❌ エラー: PDFファイルをアップロードしてください。"

    instruction_text = custom_prompt.strip()

    # システムインストラクション決定
    if preset_name == "カスタム":
        if not instruction_text:
            return "❌ エラー: カスタムプロンプトを入力してください。"
        system_instruction = instruction_text
    else:
        # プリセット選択時もテキストボックスを編集可能にする
        system_instruction = instruction_text or SYSTEM_PROMPTS[preset_name]

    progress_cb = progress if callable(progress) else (lambda *args, **kwargs: None)

    log_progress(progress_cb, 0.1, "PDFを読み込み中...")

    with tempfile.TemporaryDirectory() as temp_dir:
        # PDFファイルパス
        temp_pdf_path = resolve_pdf_path(pdf_file)
        if not temp_pdf_path or not os.path.exists(temp_pdf_path):
            return (
                "❌ エラー: アップロードされたPDFファイルを読み込めませんでした。\n"
                "再度アップロードしてから実行してください。"
            )

        # PDFを分割
        log_progress(progress_cb, 0.2, "PDFを分割中...")
        split_pdf_paths = split_pdf(temp_pdf_path, temp_dir, pages_per_chunk)

        total_chunks = len(split_pdf_paths)
        log_progress(progress_cb, 0.3, f"{total_chunks}個のチャンクに分割完了")

        # 各チャンクを最大3並列で処理
        markdown_results = {}
        max_workers = min(3, total_chunks)

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            future_map = {}
            for start_page, pdf_path in split_pdf_paths:
                future = executor.submit(
                    process_pdf_chunk,
                    pdf_path=pdf_path,
                    system_instruction=system_instruction,
                    model_name=model_name,
                    enable_thinking=enable_thinking,
                )
                future_map[future] = start_page

            completed = 0
            for future in as_completed(future_map):
                start_page = future_map[future]
                try:
                    result = future.result()
                except Exception as chunk_error:
                    result = (
                        f"<!-- エラー: {chunk_error} -->\n\n⚠️ このチャンクの処理でエラーが発生しました。"
                    )

                markdown_results[start_page] = result
                completed += 1
                progress_value = 0.3 + 0.6 * (completed / total_chunks)
                log_progress(
                    progress_cb,
                    progress_value,
                    f"チャンク {completed}/{total_chunks} を処理中...",
                )

        # 結果を結合
        log_progress(progress_cb, 0.9, "結果を結合中...")

        combined_markdown = "\n\n".join(
            markdown_results[page] for page in sorted(markdown_results.keys())
        )

        log_progress(progress_cb, 1.0, "完了!")

        return combined_markdown


def update_instruction_text(preset_name: str):
    """プリセットに対応するシステムインストラクションをテキストエリアへ反映"""

    if preset_name == "カスタム":
        return gr.update(value="")

    return gr.update(value=SYSTEM_PROMPTS[preset_name])


def log_progress(progress_cb, value: float, desc: str) -> None:
    """進捗バー更新と同時にログへも出力"""

    try:
        progress_cb(value, desc=desc)
    except TypeError:
        progress_cb(value)

    print(f"[Progress] {value * 100:.0f}% - {desc}", flush=True)


def resolve_pdf_path(uploaded_pdf) -> str:
    """
    GradioのFileコンポーネントから渡される値を安全にファイルパスへ変換
    """
    if uploaded_pdf is None:
        return ""

    if isinstance(uploaded_pdf, str):
        return uploaded_pdf

    if isinstance(uploaded_pdf, dict):
        # Gradio 5では辞書形式で渡されるケースもある
        return uploaded_pdf.get("name") or uploaded_pdf.get("path") or ""

    return getattr(uploaded_pdf, "name", "")


def create_interface():
    """Gradio UIを作成"""

    with gr.Blocks(title="PDF OCR & Markdown変換") as demo:
        gr.Markdown("# 📄 PDF OCR & Markdown変換ツール (v5.latest)")
        gr.Markdown(
            "PDFをアップロードすると、Gemini APIでOCR処理してMarkdown形式に変換します。\n\n"
            "✨ **新機能**: システムプロンプトのプリセット選択、分割単位の調整、Thinking Mode"
        )

        with gr.Row():
            with gr.Column(scale=1):
                pdf_input = gr.File(
                    label="📎 PDFファイルをアップロード",
                    file_types=[".pdf"],
                    type="filepath"
                )

                preset_dropdown = gr.Dropdown(
                    choices=list_presets() + ["カスタム"],
                    value="汎用OCR",
                    label="🎯 システムプロンプトのプリセット",
                    info="用途に応じたプリセットを選択"
                )

                custom_prompt_text = gr.Textbox(
                    label="✏️ システムインストラクション",
                    placeholder="プリセットに応じた指示が自動で入力されます。自由に編集できます。",
                    lines=10,
                    value=SYSTEM_PROMPTS["汎用OCR"],
                )

                pages_slider = gr.Slider(
                    minimum=1,
                    maximum=20,
                    value=5,
                    step=1,
                    label="📑 PDFを何ページごとに分割?",
                    info="小さいほど並列処理が増えますが、API呼び出し数も増えます"
                )

                model_dropdown = gr.Dropdown(
                    choices=[
                        "gemini-2.5-flash-preview-tts",
                        "gemini-flash-latest",
                        "gemini-pro-latest"
                    ],
                    value="gemini-flash-latest",
                    label="🤖 使用するモデル",
                    info="Flash: 高速・低コスト、Pro: 高精度"
                )

                thinking_checkbox = gr.Checkbox(
                    label="🧠 Thinking Mode有効化",
                    value=True,
                    info="モデルの思考過程を活用(より正確な結果)"
                )

                convert_btn = gr.Button(
                    "🚀 変換開始",
                    variant="primary",
                    size="lg"
                )

            with gr.Column(scale=2):
                markdown_output = gr.Textbox(
                    label="📝 変換結果(Markdown)",
                    lines=20,
                    max_lines=30,
                    show_copy_button=True
                )

                with gr.Row():
                    download_btn = gr.Button("💾 Markdownをダウンロード")

        preset_dropdown.change(
            fn=update_instruction_text,
            inputs=preset_dropdown,
            outputs=custom_prompt_text
        )

        convert_btn.click(
            fn=process_pdf,
            inputs=[
                pdf_input,
                preset_dropdown,
                custom_prompt_text,
                pages_slider,
                model_dropdown,
                thinking_checkbox,
            ],
            outputs=markdown_output
        )

        download_btn.click(
            None,
            markdown_output,
            [],
            js="""(x) => {
                const blob = new Blob([x], {type: 'text/markdown;charset=utf-8'});
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = 'converted.md';
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }"""
        )

        with gr.Accordion("📖 使用方法", open=False):
            gr.Markdown("""
### 基本的な使い方

1. **PDFアップロード**: 変換したいPDFファイルを選択
2. **プリセット選択**: 用途に応じたプリセットを選択
   - **汎用OCR**: 一般的な文書
   - **教育・参考書**: 教科書や参考書(右→左、上→下の読み順対応)
   - **ビジネス文書**: ビジネスレポート、提案書
   - **スクリーンショット**: ソフトウェアのUI画像
   - **カスタム**: 独自のシステムプロンプトを入力
3. **分割設定**: PDFを何ページごとに分割するか調整(デフォルト: 5ページ)
4. **モデル選択**: Flash(高速)またはPro(高精度)
5. **変換開始**: ボタンをクリックして処理開始
6. **結果確認**: 変換されたMarkdownを確認・ダウンロード

### Tips

- **大きなPDF**: ページ数を減らして分割すると並列処理で高速化
- **高精度**: Proモデル + Thinking Mode ONで最高品質
- **低コスト**: Flashモデル + Thinking Mode OFFで高速・低コスト
- **カスタムプロンプト**: 特定の用途に最適化した独自の指示を作成可能

### API Key設定

Hugging Face Spacesで使用する場合:
1. Spaceの設定で「Secrets」を開く
2. `GEMINI_API_KEY`という名前でGoogle AI StudioのAPIキーを設定
            """)

    return demo


def find_open_port() -> int:
    """利用可能なポート番号を取得"""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("", 0))
        return sock.getsockname()[1]


def launch_demo():
    """Gradioアプリを起動(ポート競合を自動回避)"""
    demo = create_interface()

    server_name = os.getenv("GRADIO_SERVER_NAME") or os.getenv("HOST", "0.0.0.0")
    share = os.getenv("GRADIO_SHARE", "").lower() == "true"

    candidate_ports: List[int | None] = []
    for env_var in ("GRADIO_SERVER_PORT", "PORT"):
        value = os.getenv(env_var)
        if value:
            try:
                candidate_ports.append(int(value))
            except ValueError:
                print(f"⚠️ 無効なポート番号が指定されています ({env_var}={value})")

    # デフォルト → 自動検出 → 最後にGradioのデフォルトへ
    candidate_ports.extend([7860, None])

    last_error: Exception | None = None

    for port in candidate_ports:
        kwargs = {
            "server_name": server_name,
            "share": share,
            "show_error": True,
        }

        if port is None:
            port_to_use = find_open_port()
        else:
            port_to_use = port

        kwargs["server_port"] = port_to_use

        try:
            print(f"ℹ️ Gradio UIを http://{server_name}:{port_to_use} で起動します。")
            demo.launch(**kwargs)
            return
        except OSError as error:
            print(f"⚠️ ポート {port_to_use} の起動に失敗しました: {error}")
            last_error = error

    if last_error:
        raise last_error


if __name__ == "__main__":
    launch_demo()