curlkun commited on
Commit
2bcdb19
·
verified ·
1 Parent(s): ba474f5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +187 -1137
app.py CHANGED
@@ -1,1172 +1,222 @@
1
- import gradio as gr
2
- import fitz # PyMuPDF
3
- import difflib
4
- import tempfile
 
 
 
 
 
 
 
 
5
  import os
6
- import pandas as pd
7
- import numpy as np
8
- from PIL import Image, ImageDraw, ImageFont
9
- from scipy.ndimage import binary_dilation
10
- import io
11
-
12
- try:
13
- from bs4 import BeautifulSoup
14
- BS4_AVAILABLE = True
15
- except ImportError:
16
- BS4_AVAILABLE = False
17
-
18
- try:
19
- from striprtf.striprtf import rtf_to_text
20
- STRIPRTF_AVAILABLE = True
21
- except ImportError:
22
- STRIPRTF_AVAILABLE = False
23
-
24
- try:
25
- from docx import Document as DocxDocument
26
- DOCX_AVAILABLE = True
27
- except ImportError:
28
- DOCX_AVAILABLE = False
29
-
30
- try:
31
- import openpyxl
32
- OPENPYXL_AVAILABLE = True
33
- except ImportError:
34
- OPENPYXL_AVAILABLE = False
35
-
36
- try:
37
- from pptx import Presentation
38
- PPTX_AVAILABLE = True
39
- except ImportError:
40
- PPTX_AVAILABLE = False
41
-
42
- # ------------------------------------------------------------------ #
43
- # 定数・設定
44
- # ------------------------------------------------------------------ #
45
-
46
- COLOR_PRESETS = {
47
- "赤 / 青 (標準)": {"del": (1, 0.5, 0.5), "ins": (0.5, 0.5, 1), "alpha": 0.3},
48
- "赤 / 青 (濃いめ)": {"del": (1, 0.5, 0.5), "ins": (0.5, 0.5, 1), "alpha": 0.6},
49
- "黄 / 緑 (標準)": {"del": (1, 1, 0.5), "ins": (0.5, 1, 0.5), "alpha": 0.3},
50
- "黄 / 緑 (濃いめ)": {"del": (1, 1, 0.5), "ins": (0.5, 1, 0.5), "alpha": 0.6},
51
- "ピンク / 水色 (標準)": {"del": (1, 0.7, 0.8), "ins": (0.5, 0.9, 1), "alpha": 0.3},
52
- "グレー / オレンジ (標準)": {"del": (0.8, 0.8, 0.8), "ins": (1, 0.7, 0.4), "alpha": 0.3},
53
- }
54
-
55
- PAGE_W = 595
56
- PAGE_H = 842
57
- MARGIN_X = 40
58
- MARGIN_TOP = 40
59
- LINE_HEIGHT = 18
60
- FONT_SIZE = 10
61
- FONT_PATH = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
62
- FONT_NAME = "notocjk"
63
-
64
- IMAGE_SUPPORTED_TYPES = [".jpg", ".jpeg", ".bmp", ".png", ".tif", ".tiff", ".pdf"]
65
 
66
  # ------------------------------------------------------------------ #
67
- # ページ範囲パース
68
  # ------------------------------------------------------------------ #
69
- def parse_page_range(page_range_str, max_pages):
70
- page_range_str = page_range_str.strip()
71
- if not page_range_str or page_range_str.lower() == "all":
72
- return list(range(max_pages))
73
- pages = set()
74
- for part in page_range_str.split(","):
75
- part = part.strip()
76
- if "-" in part:
77
- bounds = part.split("-")
78
- if len(bounds) == 2:
79
- try:
80
- start = int(bounds[0].strip()) - 1
81
- end = int(bounds[1].strip()) - 1
82
- for p in range(start, end + 1):
83
- if 0 <= p < max_pages:
84
- pages.add(p)
85
- except ValueError:
86
- pass
87
- else:
88
- try:
89
- p = int(part) - 1
90
- if 0 <= p < max_pages:
91
- pages.add(p)
92
- except ValueError:
93
- pass
94
- return sorted(list(pages))
95
 
96
- # ------------------------------------------------------------------ #
97
- # シート名取得
98
- # ------------------------------------------------------------------ #
99
- def get_sheet_names(file_obj):
100
- if file_obj is None:
101
- return []
102
- ext = os.path.splitext(file_obj.name)[1].lower()
103
- if ext != ".xlsx":
104
- return []
105
  try:
106
- wb = openpyxl.load_workbook(file_obj.name, read_only=True, data_only=True)
107
- names = wb.sheetnames
108
- wb.close()
109
- return names
110
- except Exception:
111
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- # ------------------------------------------------------------------ #
114
- # テキスト抽出(PDF以外)
115
- # ------------------------------------------------------------------ #
116
- def extract_text_from_file(path, page_range_str="", selected_sheets=None):
117
- ext = os.path.splitext(path)[1].lower()
118
- try:
119
- if ext == ".txt":
120
- with open(path, "r", encoding="utf-8") as f:
121
- return f.read(), None, False
122
- elif ext in [".html", ".htm"]:
123
- if not BS4_AVAILABLE:
124
- return None, "beautifulsoup4 がインストールされていません", False
125
- with open(path, "r", encoding="utf-8") as f:
126
- soup = BeautifulSoup(f.read(), "html.parser")
127
- return soup.get_text(), None, False
128
- elif ext == ".rtf":
129
- if not STRIPRTF_AVAILABLE:
130
- return None, "striprtf がインストールされていません", False
131
- with open(path, "r", encoding="utf-8", errors="ignore") as f:
132
- raw = f.read()
133
- return rtf_to_text(raw, errors="ignore"), None, False
134
- elif ext == ".docx":
135
- if not DOCX_AVAILABLE:
136
- return None, "python-docx がインストールされていません", False
137
- doc = DocxDocument(path)
138
- return "\n".join([p.text for p in doc.paragraphs]), None, False
139
- elif ext == ".xlsx":
140
- if not OPENPYXL_AVAILABLE:
141
- return None, "openpyxl がインストールされていません", False
142
- wb = openpyxl.load_workbook(path, data_only=True)
143
- if selected_sheets:
144
- target_sheets = [ws for ws in wb.worksheets if ws.title in selected_sheets]
145
- else:
146
- target_sheets = wb.worksheets
147
- pages = []
148
- for ws in target_sheets:
149
- lines = [f"[シート: {ws.title}]"]
150
- for row in ws.iter_rows(values_only=True):
151
- line = "\t".join([str(c) if c is not None else "" for c in row])
152
- if line.strip():
153
- lines.append(line)
154
- pages.append("\n".join(lines))
155
- return pages, None, True
156
- elif ext == ".pptx":
157
- if not PPTX_AVAILABLE:
158
- return None, "python-pptx がインストールされていません", False
159
- prs = Presentation(path)
160
- max_slides = len(prs.slides)
161
- target_slides = parse_page_range(page_range_str, max_slides)
162
- pages = []
163
- for i in target_slides:
164
- slide = prs.slides[i]
165
- lines = [f"[スライド {i + 1}]"]
166
- for shape in slide.shapes:
167
- if shape.has_text_frame:
168
- for para in shape.text_frame.paragraphs:
169
- t = para.text.strip()
170
- if t:
171
- lines.append(t)
172
- pages.append("\n".join(lines))
173
- return pages, None, True
174
- else:
175
- return None, f"非対応の形式です: {ext}", False
176
  except Exception as e:
177
- return None, f"抽出エラー: {str(e)}", False
 
 
178
 
179
  # ------------------------------------------------------------------ #
180
- # PDF → テキスト+座標マップ
181
  # ------------------------------------------------------------------ #
182
- def get_text_and_rects(doc, page_range_str=""):
183
- target_pages = parse_page_range(page_range_str, len(doc))
184
- new_doc = fitz.open()
185
- full_text = ""
186
- char_map = []
187
- for new_page_idx, page_num in enumerate(target_pages):
188
- new_doc.insert_pdf(doc, from_page=page_num, to_page=page_num)
189
- page = doc[page_num]
190
  blocks = page.get_text("rawdict")["blocks"]
 
191
  for block in blocks:
192
- if "lines" in block:
193
- for line in block["lines"]:
194
- for span in line["spans"]:
195
- for char in span["chars"]:
196
- c = char["c"]
197
- full_text += c
198
- char_map.append({
199
- "page": new_page_idx,
200
- "rect": char["bbox"],
201
- "char": c,
202
- })
203
- return full_text, char_map, new_doc
204
-
205
- # ------------------------------------------------------------------ #
206
- # テキスト → 座標付きPDF
207
- # ------------------------------------------------------------------ #
208
- def text_to_pdf_with_rects(text):
209
- lines = text.splitlines()
210
- doc = fitz.open()
211
- char_rects = []
212
- page = None
213
- y = MARGIN_TOP
214
- page_idx = -1
215
-
216
- CHAR_W_FULL = FONT_SIZE
217
- CHAR_W_HALF = FONT_SIZE * 0.6
218
-
219
- def get_char_width(c):
220
- return CHAR_W_HALF if c.isascii() else CHAR_W_FULL
221
-
222
- def new_page():
223
- nonlocal page, y, page_idx
224
- page = doc.new_page(width=PAGE_W, height=PAGE_H)
225
- y = MARGIN_TOP
226
- page_idx += 1
227
-
228
- new_page()
229
-
230
- for line in lines:
231
- if y + LINE_HEIGHT > PAGE_H - MARGIN_TOP:
232
- new_page()
233
- display = line if line.strip() != "" else " "
234
- try:
235
- page.insert_text(
236
- (MARGIN_X, y + FONT_SIZE), display,
237
- fontfile=FONT_PATH, fontname=FONT_NAME,
238
- fontsize=FONT_SIZE, color=(0, 0, 0),
239
- )
240
- except Exception:
241
- page.insert_text(
242
- (MARGIN_X, y + FONT_SIZE), display,
243
- fontsize=FONT_SIZE, color=(0, 0, 0),
244
- )
245
- x = MARGIN_X
246
- for c in line:
247
- cw = get_char_width(c)
248
- char_rects.append({"page": page_idx, "rect": (x, y, x + cw, y + LINE_HEIGHT), "char": c})
249
- x += cw
250
- char_rects.append({"page": page_idx, "rect": (x, y, x, y + LINE_HEIGHT), "char": "\n"})
251
- y += LINE_HEIGHT
252
-
253
- return doc, char_rects
254
-
255
- # ------------------------------------------------------------------ #
256
- # ページリスト → 座標付きPDF
257
- # ------------------------------------------------------------------ #
258
- def pages_to_pdf_with_rects(page_texts):
259
- doc = fitz.open()
260
- char_rects = []
261
-
262
- CHAR_W_FULL = FONT_SIZE
263
- CHAR_W_HALF = FONT_SIZE * 0.6
264
-
265
- def get_char_width(c):
266
- return CHAR_W_HALF if c.isascii() else CHAR_W_FULL
267
-
268
- for page_idx, page_text in enumerate(page_texts):
269
- page = doc.new_page(width=PAGE_W, height=PAGE_H)
270
- y = MARGIN_TOP
271
- lines = page_text.splitlines()
272
- for line in lines:
273
- if y + LINE_HEIGHT > PAGE_H - MARGIN_TOP:
274
- try:
275
- page.insert_text(
276
- (MARGIN_X, y + FONT_SIZE), "...",
277
- fontfile=FONT_PATH, fontname=FONT_NAME,
278
- fontsize=FONT_SIZE, color=(0.5, 0.5, 0.5),
279
- )
280
- except Exception:
281
- pass
282
  break
283
- display = line if line.strip() != "" else " "
284
- try:
285
- page.insert_text(
286
- (MARGIN_X, y + FONT_SIZE), display,
287
- fontfile=FONT_PATH, fontname=FONT_NAME,
288
- fontsize=FONT_SIZE, color=(0, 0, 0),
289
- )
290
- except Exception:
291
- page.insert_text(
292
- (MARGIN_X, y + FONT_SIZE), display,
293
- fontsize=FONT_SIZE, color=(0, 0, 0),
294
- )
295
- x = MARGIN_X
296
- for c in line:
297
- cw = get_char_width(c)
298
- char_rects.append({"page": page_idx, "rect": (x, y, x + cw, y + LINE_HEIGHT), "char": c})
299
- x += cw
300
- char_rects.append({"page": page_idx, "rect": (x, y, x, y + LINE_HEIGHT), "char": "\n"})
301
- y += LINE_HEIGHT
302
-
303
- return doc, char_rects
304
-
305
- # ------------------------------------------------------------------ #
306
- # 空白処理
307
- # ------------------------------------------------------------------ #
308
- def preprocess_text(text, mode):
309
- if mode == "そのまま (Strict)":
310
- return text, list(range(len(text)))
311
- processed_text = ""
312
- index_map = []
313
- if mode == "無視 (Ignore)":
314
- for i, char in enumerate(text):
315
- if not char.isspace():
316
- processed_text += char
317
- index_map.append(i)
318
- elif mode == "正規化 (Normalize)":
319
- last_was_space = False
320
- for i, char in enumerate(text):
321
- if char.isspace():
322
- if not last_was_space:
323
- processed_text += " "
324
- index_map.append(i)
325
- last_was_space = True
326
- else:
327
- processed_text += char
328
- index_map.append(i)
329
- last_was_space = False
330
- return processed_text, index_map
331
-
332
- def get_page_from_index(char_map, index):
333
- if not char_map: return 0
334
- if index < 0: return char_map[0]["page"]
335
- if index >= len(char_map): return char_map[-1]["page"]
336
- return char_map[index]["page"]
337
-
338
- # ------------------------------------------------------------------ #
339
- # 差異なしスタンプ(PDF用)
340
- # ------------------------------------------------------------------ #
341
- def add_no_diff_stamp_pdf(page):
342
- import datetime, pytz
343
- jst = pytz.timezone("Asia/Tokyo")
344
- now = datetime.datetime.now(jst)
345
- ts = now.strftime("%-Y/%-m/%-d %-H:%M")
346
-
347
- rect_x, rect_y, rect_w, rect_h = 20, 20, 110, 38
348
- rect = fitz.Rect(rect_x, rect_y, rect_x + rect_w, rect_y + rect_h)
349
- page.draw_rect(rect, color=(1, 0, 0), width=1.5)
350
- try:
351
- # 「差異無し」センタリング
352
- text1_w = 4 * 12
353
- # タイムスタンプ:センタリング位置から8px右にオフセット
354
- text2_w = len(ts) * 5
355
- page.insert_text(
356
- (rect_x + (rect_w - text1_w) / 2, rect_y + 16), "差異無し",
357
- fontfile=FONT_PATH, fontname=FONT_NAME,
358
- fontsize=12, color=(1, 0, 0),
359
- )
360
- page.insert_text(
361
- (rect_x + (rect_w - text2_w) / 2 + 8, rect_y + 30), ts,
362
- fontfile=FONT_PATH, fontname=FONT_NAME,
363
- fontsize=8, color=(1, 0, 0),
364
- )
365
- except Exception:
366
- page.insert_text((rect_x + 5, rect_y + 16), "NO DIFF", fontsize=12, color=(1, 0, 0))
367
- page.insert_text((rect_x + 5, rect_y + 30), ts, fontsize=8, color=(1, 0, 0))
368
-
369
- # ------------------------------------------------------------------ #
370
- # 左右見開きPDF生成
371
- # ------------------------------------------------------------------ #
372
- def create_side_by_side_pdf(path1, path2, output_path):
373
- doc1 = fitz.open(path1)
374
- doc2 = fitz.open(path2)
375
- len1, len2 = len(doc1), len(doc2)
376
- max_pages = max(len1, len2)
377
- out_doc = fitz.open()
378
- for i in range(max_pages):
379
- rect1 = doc1[i].rect if i < len1 else fitz.Rect(0, 0, PAGE_W, PAGE_H)
380
- rect2 = doc2[i].rect if i < len2 else fitz.Rect(0, 0, PAGE_W, PAGE_H)
381
- new_width = rect1.width + rect2.width
382
- new_height = max(rect1.height, rect2.height)
383
- new_page = out_doc.new_page(width=new_width, height=new_height)
384
- if i < len1:
385
- new_page.show_pdf_page(rect1, doc1, i)
386
- if i < len2:
387
- target_rect = fitz.Rect(rect1.width, 0, rect1.width + rect2.width, rect2.height)
388
- new_page.show_pdf_page(target_rect, doc2, i)
389
- out_doc.save(output_path)
390
- out_doc.close()
391
-
392
- # ------------------------------------------------------------------ #
393
- # シート選択UI更新
394
- # ------------------------------------------------------------------ #
395
- def update_sheets1(file_obj):
396
- names = get_sheet_names(file_obj)
397
- if not names:
398
- return gr.CheckboxGroup.update(choices=[], value=[], visible=False)
399
- return gr.CheckboxGroup.update(choices=names, value=names, visible=True)
400
-
401
- def update_sheets2(file_obj):
402
- names = get_sheet_names(file_obj)
403
- if not names:
404
- return gr.CheckboxGroup.update(choices=[], value=[], visible=False)
405
- return gr.CheckboxGroup.update(choices=names, value=names, visible=True)
406
-
407
- # ------------------------------------------------------------------ #
408
- # 四角注釈テキスト抽出
409
- # ------------------------------------------------------------------ #
410
- def get_annotation_texts(doc):
411
- annotations = []
412
- for page_num in range(len(doc)):
413
- page = doc[page_num]
414
- for annot in page.annots():
415
- if annot.type[0] == fitz.PDF_ANNOT_SQUARE:
416
- rect = annot.rect
417
- text = page.get_text("text", clip=rect).strip()
418
- annotations.append({"page": page_num, "rect": rect, "text": text})
419
- annotations.sort(key=lambda a: (a["page"], a["rect"].y0, a["rect"].x0))
420
- return annotations
421
-
422
- # ------------------------------------------------------------------ #
423
- # 画像比較ユーティリティ
424
- # ------------------------------------------------------------------ #
425
- def load_as_pil_images(file_obj, page_range_str: str):
426
- if file_obj is None:
427
- return [], "ファイルがありません"
428
- path = file_obj.name
429
- ext = os.path.splitext(path)[1].lower()
430
- if ext == ".pdf":
431
- try:
432
- doc = fitz.open(path)
433
- max_pages = len(doc)
434
- target_pages = parse_page_range(page_range_str, max_pages)
435
- if not target_pages:
436
- return [], f"有効なページ番号がありません(全{max_pages}ページ)"
437
- images = []
438
- for p_idx in target_pages:
439
- pix = doc[p_idx].get_pixmap(dpi=150)
440
- img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
441
- images.append((img, p_idx + 1))
442
- doc.close()
443
- return images, ""
444
- except Exception as e:
445
- return [], f"PDFの読み込みエラー: {str(e)}"
446
- else:
447
- try:
448
- img = Image.open(path).convert("RGB")
449
- return [(img, 1)], ""
450
- except Exception as e:
451
- return [], f"画像の読み込みエラー: {str(e)}"
452
-
453
- def resize_to_match(img1: Image.Image, img2: Image.Image, mode: str):
454
- if img1.size == img2.size:
455
- return img1, img2
456
- if mode == "エラーにする":
457
- raise ValueError(f"画像サイズが一致しません: 元={img1.size}, 修正={img2.size}")
458
- img2 = img2.resize(img1.size, Image.LANCZOS)
459
- return img1, img2
460
-
461
- def apply_grayscale_bg(img: Image.Image, changed_mask: np.ndarray) -> Image.Image:
462
- color_arr = np.array(img, dtype=np.uint8)
463
- gray_arr = np.array(img.convert("L").convert("RGB"), dtype=np.uint8)
464
- result = gray_arr.copy()
465
- result[changed_mask] = color_arr[changed_mask]
466
- return Image.fromarray(result)
467
-
468
- def compute_highlighted_and_diff(img1: Image.Image, img2: Image.Image, threshold: int, highlight_mode: str):
469
- arr1 = np.array(img1, dtype=np.int16)
470
- arr2 = np.array(img2, dtype=np.int16)
471
- diff = np.abs(arr1 - arr2).max(axis=2)
472
- changed_mask = diff > threshold
473
- has_diff = bool(changed_mask.any())
474
-
475
- brightness1 = arr1.mean(axis=2)
476
- brightness2 = arr2.mean(axis=2)
477
-
478
- if highlight_mode == "標準(半透明)":
479
- hl1 = np.array(img1, dtype=np.uint8).copy()
480
- hl2 = np.array(img2, dtype=np.uint8).copy()
481
- hl1[changed_mask] = [220, 50, 50]
482
- hl2[changed_mask] = [50, 50, 220]
483
- gray = np.array(img1.convert("L").convert("RGB"), dtype=np.uint8)
484
- diff_arr = gray.copy()
485
- diff_arr[changed_mask & (brightness1 >= brightness2)] = [220, 50, 50]
486
- diff_arr[changed_mask & (brightness1 < brightness2)] = [50, 50, 220]
487
- else:
488
- dilated = binary_dilation(changed_mask, iterations=3)
489
- def blend(base: np.ndarray, mask: np.ndarray, color: list, alpha: float) -> np.ndarray:
490
- result = base.copy()
491
- overlay = np.array(color, dtype=np.uint8)
492
- result[mask] = (base[mask] * (1 - alpha) + overlay * alpha).astype(np.uint8)
493
- return result
494
- hl1 = blend(np.array(img1, dtype=np.uint8), dilated, [255, 0, 0], 0.5)
495
- hl2 = blend(np.array(img2, dtype=np.uint8), dilated, [0, 0, 255], 0.5)
496
- gray = np.array(img1.convert("L").convert("RGB"), dtype=np.uint8)
497
- diff_arr = blend(gray, dilated, [255, 0, 0], 0.5)
498
- diff_arr = blend(diff_arr, dilated & (brightness1 < brightness2), [0, 0, 255], 0.5)
499
-
500
- stats = {
501
- "差異ピクセル数": int(changed_mask.sum()),
502
- "総ピクセル数": diff.size,
503
- "差異率": f"{changed_mask.sum() / diff.size * 100:.2f}%",
504
- }
505
- return Image.fromarray(hl1), Image.fromarray(hl2), Image.fromarray(diff_arr), has_diff, stats, changed_mask
506
-
507
- # ------------------------------------------------------------------ #
508
- # 差異なしスタンプ(画像用)
509
- # ------------------------------------------------------------------ #
510
- def add_no_diff_stamp_img(img: Image.Image) -> Image.Image:
511
- import datetime, pytz
512
- jst = pytz.timezone("Asia/Tokyo")
513
- now = datetime.datetime.now(jst)
514
- ts = now.strftime("%-Y/%-m/%-d %-H:%M")
515
-
516
- result = img.copy()
517
- draw = ImageDraw.Draw(result)
518
- x, y, w, h = 20, 20, 160, 52
519
- draw.rectangle([x, y, x + w, y + h], outline=(220, 0, 0), width=2)
520
- try:
521
- font_main = ImageFont.truetype(FONT_PATH, 18)
522
- font_sub = ImageFont.truetype(FONT_PATH, 12)
523
- except Exception:
524
- font_main = ImageFont.load_default()
525
- font_sub = ImageFont.load_default()
526
-
527
- # センタリング
528
- bbox1 = draw.textbbox((0, 0), "差異無し", font=font_main)
529
- bbox2 = draw.textbbox((0, 0), ts, font=font_sub)
530
- text1_w = bbox1[2] - bbox1[0]
531
- text2_w = bbox2[2] - bbox2[0]
532
- draw.text((x + (w - text1_w) / 2, y + 6), "差異無し", fill=(220, 0, 0), font=font_main)
533
- draw.text((x + (w - text2_w) / 2, y + 32), ts, fill=(220, 0, 0), font=font_sub)
534
- return result
535
-
536
- def pil_to_bytes(img: Image.Image) -> bytes:
537
- buf = io.BytesIO()
538
- img.save(buf, format="PNG")
539
- return buf.getvalue()
540
-
541
- def build_pdf_from_pil_images(image_list: list) -> fitz.Document:
542
- doc = fitz.open()
543
- for img in image_list:
544
- w, h = img.size
545
- page = doc.new_page(width=w, height=h)
546
- page.insert_image(fitz.Rect(0, 0, w, h), stream=pil_to_bytes(img))
547
- return doc
548
-
549
- def build_sidebyside_pdf_img(hl1_list, hl2_list, mask_list, sidebyside_bg_mode) -> fitz.Document:
550
- doc = fitz.open()
551
- for hl1, hl2, changed_mask in zip(hl1_list, hl2_list, mask_list):
552
- if sidebyside_bg_mode == "グレースケール":
553
- hl1 = apply_grayscale_bg(hl1, changed_mask)
554
- hl2 = apply_grayscale_bg(hl2, changed_mask)
555
- w1, h1 = hl1.size
556
- w2, h2 = hl2.size
557
- new_w = w1 + w2
558
- new_h = max(h1, h2)
559
- page = doc.new_page(width=new_w, height=new_h)
560
- page.insert_image(fitz.Rect(0, 0, w1, h1), stream=pil_to_bytes(hl1))
561
- page.insert_image(fitz.Rect(w1, 0, w1 + w2, h2), stream=pil_to_bytes(hl2))
562
- return doc
563
 
564
  # ------------------------------------------------------------------ #
565
- # 注釈比較メイン処理
566
  # ------------------------------------------------------------------ #
567
- def process_annotations(file1_obj, file2_obj, color_mode):
568
- if file1_obj is None or file2_obj is None:
569
- return "ファイルをアップロードしてください", None, None, None, None
570
-
571
- file1_path = file1_obj.name
572
- file2_path = file2_obj.name
573
- ext1 = os.path.splitext(file1_path)[1].lower()
574
- ext2 = os.path.splitext(file2_path)[1].lower()
575
-
576
- if ext1 != ".pdf" or ext2 != ".pdf":
577
- return "注釈比較モードはPDFのみ対応しています", None, None, None, None
578
-
579
- colors = COLOR_PRESETS.get(color_mode, COLOR_PRESETS["赤 / 青 (標準)"])
580
- del_color = colors["del"]
581
- ins_color = colors["ins"]
582
- opacity = colors["alpha"]
583
- temp_dir = tempfile.gettempdir()
584
-
585
  try:
586
- doc1 = fitz.open(file1_path)
587
- doc2 = fitz.open(file2_path)
588
- except Exception as e:
589
- return f"エラー: {str(e)}", None, None, None, None
590
-
591
- annots1 = get_annotation_texts(doc1)
592
- annots2 = get_annotation_texts(doc2)
593
-
594
- if len(annots1) == 0 and len(annots2) == 0:
595
- return "四角注釈が見つかりませんでした", None, None, None, None
596
-
597
- n = min(len(annots1), len(annots2))
598
- skipped = abs(len(annots1) - len(annots2))
599
-
600
- changes = []
601
- summary_counts = {"delete": 0, "insert": 0}
602
- changed_pages_doc1 = set()
603
- changed_pages_doc2 = set()
604
 
605
- for i in range(n):
606
- a1 = annots1[i]
607
- a2 = annots2[i]
608
 
609
- def get_char_map_in_rect(doc, page_num, rect):
610
- page = doc[page_num]
611
- raw = page.get_text("rawdict", clip=rect)
612
- char_map = []
613
- text = ""
614
- for block in raw["blocks"]:
615
  if "lines" not in block:
616
  continue
617
  for line in block["lines"]:
618
  for span in line["spans"]:
 
 
 
 
619
  for char in span["chars"]:
620
- c = char["c"]
621
- text += c
622
- char_map.append({"page": page_num, "rect": char["bbox"], "char": c})
623
- return text, char_map
624
-
625
- text1, char_map1 = get_char_map_in_rect(doc1, a1["page"], a1["rect"])
626
- text2, char_map2 = get_char_map_in_rect(doc2, a2["page"], a2["rect"])
627
-
628
- matcher = difflib.SequenceMatcher(None, text1, text2)
629
- has_diff = False
630
-
631
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
632
- if tag == "equal":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  continue
634
- has_diff = True
635
- if tag in ["delete", "replace"]:
636
- for ci in range(i1, i2):
637
- if ci < len(char_map1):
638
- info = char_map1[ci]
639
- p_idx = info["page"]
640
- changed_pages_doc1.add(p_idx)
641
- doc1[p_idx].draw_rect(
642
- fitz.Rect(info["rect"]), color=None,
643
- fill=del_color, fill_opacity=opacity,
644
- )
645
- if tag in ["insert", "replace"]:
646
- for cj in range(j1, j2):
647
- if cj < len(char_map2):
648
- info = char_map2[cj]
649
- p_idx = info["page"]
650
- changed_pages_doc2.add(p_idx)
651
- doc2[p_idx].draw_rect(
652
- fitz.Rect(info["rect"]), color=None,
653
- fill=ins_color, fill_opacity=opacity,
654
- )
655
- if has_diff:
656
- summary_counts["delete"] += len(text1)
657
- summary_counts["insert"] += len(text2)
658
- changes.append({"Type": "変更(旧)", "Page": a1["page"] + 1, "AnnotNo": i + 1, "Text": text1})
659
- changes.append({"Type": "変更(新)", "Page": a2["page"] + 1, "AnnotNo": i + 1, "Text": text2})
660
-
661
- all_changed_pages = changed_pages_doc1 | changed_pages_doc2
662
- for p_idx in range(len(doc1)):
663
- if p_idx not in all_changed_pages:
664
- add_no_diff_stamp_pdf(doc1[p_idx])
665
- for p_idx in range(len(doc2)):
666
- if p_idx not in all_changed_pages:
667
- add_no_diff_stamp_pdf(doc2[p_idx])
668
-
669
- out1_path = os.path.join(temp_dir, "result_1_Original.pdf")
670
- out2_path = os.path.join(temp_dir, "result_2_Modified.pdf")
671
- combined_path = os.path.join(temp_dir, "result_3_Combined.pdf")
672
- csv_path = os.path.join(temp_dir, "diff_report.csv")
673
-
674
- doc1.save(out1_path, garbage=4, deflate=True)
675
- doc2.save(out2_path, garbage=4, deflate=True)
676
- doc1.close()
677
- doc2.close()
678
-
679
- create_side_by_side_pdf(out1_path, out2_path, combined_path)
680
-
681
- df = pd.DataFrame(changes)
682
- df.to_csv(csv_path, index=False, encoding="utf-8-sig")
683
-
684
- summary_text = (
685
- f"### 注釈比較完了\n"
686
- f"**配色**: {color_mode} \n"
687
- f"- **比較注釈数**: {n}\n"
688
- f"- **削除対象文字数**: {summary_counts['delete']}\n"
689
- f"- **追加対象文字数**: {summary_counts['insert']}\n"
690
- f"- **差分注釈数**: {len(set(c['AnnotNo'] for c in changes)) if changes else 0}\n"
691
- )
692
- if skipped > 0:
693
- summary_text += f"- ⚠️ 注釈数が異なるため {skipped} 件をスキップしました\n"
694
-
695
- return summary_text, out1_path, out2_path, combined_path, csv_path
696
-
697
- # ------------------------------------------------------------------ #
698
- # ファイル比較メイン処理
699
- # ------------------------------------------------------------------ #
700
- def process_files(file1_obj, file2_obj, page_range_str, whitespace_mode, color_mode, sheets1, sheets2):
701
- if file1_obj is None or file2_obj is None:
702
- return "ファイルをアップロードしてください", None, None, None, None
703
-
704
- file1_path = file1_obj.name
705
- file2_path = file2_obj.name
706
- ext1 = os.path.splitext(file1_path)[1].lower()
707
- ext2 = os.path.splitext(file2_path)[1].lower()
708
-
709
- colors = COLOR_PRESETS.get(color_mode, COLOR_PRESETS["赤 / 青 (標準)"])
710
- del_color = colors["del"]
711
- ins_color = colors["ins"]
712
- opacity = colors["alpha"]
713
- temp_dir = tempfile.gettempdir()
714
-
715
- if ext1 == ".pdf":
716
- try:
717
- src_doc1 = fitz.open(file1_path)
718
- except Exception as e:
719
- return f"エラー(ファイル1): {str(e)}", None, None, None, None
720
- full_text1, map1, doc1 = get_text_and_rects(src_doc1, page_range_str)
721
- src_doc1.close()
722
- is_pdf1 = True
723
- is_paged1 = False
724
- else:
725
- selected1 = sheets1 if ext1 == ".xlsx" else None
726
- result1, err1, is_paged1 = extract_text_from_file(file1_path, page_range_str, selected1)
727
- if err1:
728
- return f"ファイル1 エラー: {err1}", None, None, None, None
729
- if is_paged1:
730
- doc1, map1 = pages_to_pdf_with_rects(result1)
731
- full_text1 = "\n".join(result1)
732
- else:
733
- doc1, map1 = text_to_pdf_with_rects(result1)
734
- full_text1 = result1
735
- is_pdf1 = False
736
-
737
- if ext2 == ".pdf":
738
- try:
739
- src_doc2 = fitz.open(file2_path)
740
- except Exception as e:
741
- return f"エラー(ファイル2): {str(e)}", None, None, None, None
742
- full_text2, map2, doc2 = get_text_and_rects(src_doc2, page_range_str)
743
- src_doc2.close()
744
- is_pdf2 = True
745
- is_paged2 = False
746
- else:
747
- selected2 = sheets2 if ext2 == ".xlsx" else None
748
- result2, err2, is_paged2 = extract_text_from_file(file2_path, page_range_str, selected2)
749
- if err2:
750
- return f"ファイル2 エラー: {err2}", None, None, None, None
751
- if is_paged2:
752
- doc2, map2 = pages_to_pdf_with_rects(result2)
753
- full_text2 = "\n".join(result2)
754
- else:
755
- doc2, map2 = text_to_pdf_with_rects(result2)
756
- full_text2 = result2
757
- is_pdf2 = False
758
-
759
- if is_pdf1 and is_pdf2:
760
- cmp_text1, idx_map1 = preprocess_text(full_text1, whitespace_mode)
761
- cmp_text2, idx_map2 = preprocess_text(full_text2, whitespace_mode)
762
- else:
763
- cmp_text1, idx_map1 = preprocess_text(full_text1, "無視 (Ignore)")
764
- cmp_text2, idx_map2 = preprocess_text(full_text2, "無視 (Ignore)")
765
-
766
- matcher = difflib.SequenceMatcher(None, cmp_text1, cmp_text2)
767
-
768
- changes = []
769
- summary_counts = {"delete": 0, "insert": 0}
770
- changed_pages_doc1 = set()
771
- changed_pages_doc2 = set()
772
-
773
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
774
- if tag == "equal":
775
- continue
776
- if tag in ["delete", "replace"]:
777
- diff_str = cmp_text1[i1:i2]
778
- summary_counts["delete"] += len(diff_str)
779
- for c_idx in range(i1, i2):
780
- if c_idx < len(idx_map1):
781
- orig_idx = idx_map1[c_idx]
782
- if orig_idx < len(map1):
783
- info = map1[orig_idx]
784
- if info.get("char") == "\n":
785
- continue
786
- p_idx = info["page"]
787
- changed_pages_doc1.add(p_idx)
788
- doc1[p_idx].draw_rect(
789
- fitz.Rect(info["rect"]), color=None,
790
- fill=del_color, fill_opacity=opacity,
791
- )
792
- display_page = get_page_from_index(map1, idx_map1[min(i1, len(idx_map1) - 1)]) + 1 if idx_map1 else 1
793
- changes.append({"Type": "削除/変更(旧)", "Page": display_page, "Text": diff_str})
794
-
795
- if tag in ["insert", "replace"]:
796
- diff_str = cmp_text2[j1:j2]
797
- summary_counts["insert"] += len(diff_str)
798
- for c_idx in range(j1, j2):
799
- if c_idx < len(idx_map2):
800
- orig_idx = idx_map2[c_idx]
801
- if orig_idx < len(map2):
802
- info = map2[orig_idx]
803
- if info.get("char") == "\n":
804
- continue
805
- p_idx = info["page"]
806
- changed_pages_doc2.add(p_idx)
807
- doc2[p_idx].draw_rect(
808
- fitz.Rect(info["rect"]), color=None,
809
- fill=ins_color, fill_opacity=opacity,
810
- )
811
- display_page = get_page_from_index(map2, idx_map2[min(j1, len(idx_map2) - 1)]) + 1 if idx_map2 else 1
812
- changes.append({"Type": "追加/変更(新)", "Page": display_page, "Text": diff_str})
813
-
814
- if tag == "replace":
815
- if idx_map1:
816
- page_num_1 = get_page_from_index(map1, idx_map1[min(i1, len(idx_map1) - 1)])
817
- changed_pages_doc2.add(page_num_1)
818
- if idx_map2:
819
- page_num_2 = get_page_from_index(map2, idx_map2[min(j1, len(idx_map2) - 1)])
820
- changed_pages_doc1.add(page_num_2)
821
-
822
- all_changed_pages = changed_pages_doc1 | changed_pages_doc2
823
- for p_idx in range(len(doc1)):
824
- if p_idx not in all_changed_pages:
825
- add_no_diff_stamp_pdf(doc1[p_idx])
826
- for p_idx in range(len(doc2)):
827
- if p_idx not in all_changed_pages:
828
- add_no_diff_stamp_pdf(doc2[p_idx])
829
-
830
- out1_path = os.path.join(temp_dir, "result_1_Original.pdf")
831
- out2_path = os.path.join(temp_dir, "result_2_Modified.pdf")
832
- combined_path = os.path.join(temp_dir, "result_3_Combined.pdf")
833
- csv_path = os.path.join(temp_dir, "diff_report.csv")
834
-
835
- doc1.save(out1_path, garbage=4, deflate=True)
836
- doc2.save(out2_path, garbage=4, deflate=True)
837
- doc1.close()
838
- doc2.close()
839
-
840
- create_side_by_side_pdf(out1_path, out2_path, combined_path)
841
 
842
- df = pd.DataFrame(changes)
843
- df.to_csv(csv_path, index=False, encoding="utf-8-sig")
844
-
845
- applied_mode = whitespace_mode if (is_pdf1 and is_pdf2) else "無視 (Ignore) ※PDF以外は固定"
846
- summary_text = (
847
- f"### 比較完了\n"
848
- f"**ファイル1形式**: {ext1} \n"
849
- f"**ファイル2形式**: {ext2} \n"
850
- f"**空白処理**: {applied_mode} \n"
851
- f"**配色**: {color_mode} \n"
852
- f"- **削除対象文字数**: {summary_counts['delete']}\n"
853
- f"- **追加対象文字数**: {summary_counts['insert']}\n"
854
- f"- **検出ブロック数**: {len(changes)}"
855
- )
856
-
857
- return summary_text, out1_path, out2_path, combined_path, csv_path
858
-
859
- # ------------------------------------------------------------------ #
860
- # 画像比較メイン処理
861
- # ------------------------------------------------------------------ #
862
- def process_images(file1_obj, file2_obj, page_range_str, size_mode, threshold, highlight_mode, sidebyside_bg_mode):
863
- if file1_obj is None or file2_obj is None:
864
- return "ファイルをアップロードしてください", None, None, None
865
-
866
- images1, err1 = load_as_pil_images(file1_obj, page_range_str)
867
- images2, err2 = load_as_pil_images(file2_obj, page_range_str)
868
- if err1: return f"元ファイルエラー: {err1}", None, None, None
869
- if err2: return f"修正ファイルエラー: {err2}", None, None, None
870
- if not images1 or not images2:
871
- return "読み込める画像がありませんでした", None, None, None
872
-
873
- n_pages = min(len(images1), len(images2))
874
- skipped = abs(len(images1) - len(images2))
875
-
876
- hl1_list = []
877
- hl2_list = []
878
- diff_list = []
879
- mask_list = []
880
- summary_lines = []
881
-
882
- for i in range(n_pages):
883
- img1, label1 = images1[i]
884
- img2, label2 = images2[i]
885
- try:
886
- img1, img2 = resize_to_match(img1, img2, size_mode)
887
- except ValueError as e:
888
- return str(e), None, None, None
889
- try:
890
- hl1, hl2, diff_img, has_diff, stats, changed_mask = compute_highlighted_and_diff(
891
- img1, img2, threshold, highlight_mode
892
- )
893
- except Exception as e:
894
- return f"差分計算エラー(ページ {label1}): {str(e)}", None, None, None
895
-
896
- if not has_diff:
897
- hl1 = add_no_diff_stamp_img(hl1)
898
- hl2 = add_no_diff_stamp_img(hl2)
899
- diff_img = add_no_diff_stamp_img(diff_img)
900
- summary_lines.append(f"- ページ {label1}: **差異なし**")
901
- else:
902
- summary_lines.append(
903
- f"- ページ {label1}: 差異あり 差異率 {stats['差異率']}、"
904
- f"差異ピクセル数 {stats['差異ピクセル数']:,} / {stats['総ピクセル数']:,}"
905
- )
906
-
907
- hl1_list.append(hl1)
908
- hl2_list.append(hl2)
909
- diff_list.append(diff_img)
910
- mask_list.append(changed_mask)
911
-
912
- temp_dir = tempfile.gettempdir()
913
-
914
- # 結果1: 元ファイル(ハイライト済み)
915
- hl1_doc = build_pdf_from_pil_images(hl1_list)
916
- out1_path = os.path.join(temp_dir, "result_1_Original.pdf")
917
- hl1_doc.save(out1_path, garbage=4, deflate=True)
918
- hl1_doc.close()
919
-
920
- # 結果2: 修正ファイル(ハイライト済み)
921
- hl2_doc = build_pdf_from_pil_images(hl2_list)
922
- out2_path = os.path.join(temp_dir, "result_2_Modified.pdf")
923
- hl2_doc.save(out2_path, garbage=4, deflate=True)
924
- hl2_doc.close()
925
-
926
- # 統合ファイル: 差分PDF(赤/青合成)
927
- diff_doc = build_pdf_from_pil_images(diff_list)
928
- diff_path = os.path.join(temp_dir, "result_3_Combined.pdf")
929
- diff_doc.save(diff_path, garbage=4, deflate=True)
930
- diff_doc.close()
931
-
932
- status = (
933
- f"### 比較完了\n"
934
- f"- 比較ページ数: {n_pages} ページ\n"
935
- f"- しきい値: {threshold}\n"
936
- f"- ハイライトモード: {highlight_mode}\n"
937
- f"- サイズ不一致処理: {size_mode}\n"
938
- f"- 見開きPDF背景モード: {sidebyside_bg_mode}\n"
939
- )
940
- if skipped > 0:
941
- status += f"- ⚠️ ページ数が異なるため {skipped} ページをスキップしました\n"
942
- status += "\n" + "\n".join(summary_lines)
943
-
944
- return status, out1_path, out2_path, diff_path
945
 
946
  # ------------------------------------------------------------------ #
947
- # クリア
948
  # ------------------------------------------------------------------ #
949
- def clear_files():
950
- return (
951
- None, None, "", "無視 (Ignore)", "赤 / 青 (標準)",
952
- gr.CheckboxGroup.update(choices=[], value=[], visible=False),
953
- gr.CheckboxGroup.update(choices=[], value=[], visible=False),
954
- "", None, None, None, None,
955
- )
956
-
957
- def clear_images():
958
- return (
959
- None, None, "", 30,
960
- "リサイズして比較", "標準(半透明)", "カラー",
961
- "", None, None, None,
962
- )
963
-
964
- def clear_annotations():
965
- return (
966
- None, None, "赤 / 青 (標準)",
967
- "", None, None, None, None,
968
- )
 
 
 
 
 
 
969
 
970
  # ------------------------------------------------------------------ #
971
- # UI
972
  # ------------------------------------------------------------------ #
973
- with gr.Blocks(title="ファイル比較") as demo:
974
- gr.Markdown("## ファイル比較")
975
- gr.Markdown("対応形式(ファイル比較): `.pdf` `.txt` `.html` `.htm` `.rtf` `.docx` `.xlsx` `.pptx` / "
976
- "対応形式(画像比較): `.jpg` `.jpeg` `.bmp` `.png` `.tif` `.tiff` `.pdf`")
977
-
978
- with gr.Row():
979
- # ==================== 左カラム:入力 ====================
980
- with gr.Column(scale=1):
981
- with gr.Tabs():
982
-
983
- # ---------- タブ1: ファイル比較 ----------
984
- with gr.TabItem("ファイル比較"):
985
- gr.Markdown("### 設定 & ファイル入力")
986
- file_input1 = gr.File(
987
- label="元ファイル",
988
- file_types=[".pdf", ".txt", ".html", ".htm", ".rtf", ".docx", ".xlsx", ".pptx"],
989
- type="file",
990
- )
991
- sheets1_select = gr.CheckboxGroup(
992
- choices=[], value=[],
993
- label="元ファイル シート選択(xlsx のみ)",
994
- visible=False,
995
- )
996
- file_input2 = gr.File(
997
- label="修正ファイル",
998
- file_types=[".pdf", ".txt", ".html", ".htm", ".rtf", ".docx", ".xlsx", ".pptx"],
999
- type="file",
1000
- )
1001
- sheets2_select = gr.CheckboxGroup(
1002
- choices=[], value=[],
1003
- label="修正ファイル シート選択(xlsx のみ)",
1004
- visible=False,
1005
- )
1006
- file_page_range = gr.Textbox(
1007
- label="ページ / スライド指定(PDF・pptx のみ有効)",
1008
- placeholder="例: 1,3-5,7 空欄または all で全ページ",
1009
- value="",
1010
- )
1011
- file_color_select = gr.Dropdown(
1012
- choices=list(COLOR_PRESETS.keys()),
1013
- value="赤 / 青 (標準)",
1014
- label="ハイライト配色",
1015
- interactive=True,
1016
- )
1017
- file_mode_select = gr.Radio(
1018
- choices=["そのまま (Strict)", "正規化 (Normalize)", "無視 (Ignore)"],
1019
- value="無視 (Ignore)",
1020
- label="空白文字の扱い (PDF同士のみ有効)",
1021
- info="PDF以外のファイルは常に「無視」で処理されます。",
1022
- )
1023
- with gr.Row():
1024
- file_clear_btn = gr.Button("クリア", variant="secondary")
1025
- file_run_btn = gr.Button("比較を実行", variant="primary")
1026
-
1027
- # ---------- タブ2: 画像比較 ----------
1028
- with gr.TabItem("画像比較"):
1029
- gr.Markdown("### 設定 & ファイル入力")
1030
- img_input1 = gr.File(
1031
- label="元ファイル",
1032
- file_types=IMAGE_SUPPORTED_TYPES,
1033
- type="file",
1034
- )
1035
- img_input2 = gr.File(
1036
- label="修正ファイル",
1037
- file_types=IMAGE_SUPPORTED_TYPES,
1038
- type="file",
1039
- )
1040
- img_page_range = gr.Textbox(
1041
- label="比較ページ指定(PDFのみ)",
1042
- placeholder="例: 1,3-5,7 空欄または all で全ページ",
1043
- value="",
1044
- )
1045
- img_threshold = gr.Slider(
1046
- minimum=0, maximum=255, value=30, step=1,
1047
- label="差分感度しきい値(低いほど敏感)",
1048
- )
1049
- img_size_mode = gr.Radio(
1050
- choices=["リサイズして比較", "エラーにする"],
1051
- value="リサイズして比較",
1052
- label="画像サイズ不一致時の処理",
1053
- )
1054
- img_highlight_mode = gr.Radio(
1055
- choices=["標準(半透明)", "強調(塗りつぶし)"],
1056
- value="標準(半透明)",
1057
- label="ハイライトモード",
1058
- )
1059
- img_sidebyside_bg = gr.Radio(
1060
- choices=["カラー", "グレースケール"],
1061
- value="カラー",
1062
- label="見開きPDF背景モード",
1063
- )
1064
- with gr.Row():
1065
- img_clear_btn = gr.Button("クリア", variant="secondary")
1066
- img_run_btn = gr.Button("比較を実行", variant="primary")
1067
-
1068
- # ---------- タブ3: 注釈比較 ----------
1069
- with gr.TabItem("注釈比較(四角)"):
1070
- gr.Markdown("### 設定 & ファイル入力")
1071
- gr.Markdown("PDFの四角注釈内テキストを上から順に比較します。")
1072
- annot_file1 = gr.File(
1073
- label="元ファイル (PDF)",
1074
- file_types=[".pdf"],
1075
- type="file",
1076
- )
1077
- annot_file2 = gr.File(
1078
- label="修正ファイル (PDF)",
1079
- file_types=[".pdf"],
1080
- type="file",
1081
- )
1082
- annot_color_select = gr.Dropdown(
1083
- choices=list(COLOR_PRESETS.keys()),
1084
- value="赤 / 青 (標準)",
1085
- label="ハイライト配色",
1086
- interactive=True,
1087
- )
1088
- with gr.Row():
1089
- annot_clear_btn = gr.Button("クリア", variant="secondary")
1090
- annot_run_btn = gr.Button("比較を実行", variant="primary")
1091
-
1092
- # ==================== 右カラム:結果 ====================
1093
- with gr.Column(scale=1):
1094
- gr.Markdown("### 結果サマリ")
1095
- summary_box = gr.Markdown("ここに結果が表示されます...")
1096
- gr.Markdown("### ファイルダウンロード")
1097
- output_pdf1 = gr.File(label="結果1: 元ファイル")
1098
- output_pdf2 = gr.File(label="結果2: 修正ファイル")
1099
- output_combined = gr.File(label="統合ファイル (PDF)")
1100
- output_csv = gr.File(label="レポート: 差分リスト (CSV)", visible=True)
1101
-
1102
- # ----------------------------------------------------------------
1103
- # イベント
1104
- # ----------------------------------------------------------------
1105
- file_input1.change(fn=update_sheets1, inputs=[file_input1], outputs=[sheets1_select])
1106
- file_input2.change(fn=update_sheets2, inputs=[file_input2], outputs=[sheets2_select])
1107
-
1108
- # ファイル比較 実行
1109
- file_run_btn.click(
1110
- fn=process_files,
1111
- inputs=[file_input1, file_input2, file_page_range, file_mode_select,
1112
- file_color_select, sheets1_select, sheets2_select],
1113
- outputs=[summary_box, output_pdf1, output_pdf2, output_combined, output_csv],
1114
- )
1115
-
1116
- # 画像比較 実行(CSVは非表示にするためNoneを返す→output_csvをvisible制御)
1117
- def process_images_ui(f1, f2, pr, sm, th, hm, bg):
1118
- summary, p1, p2, combined = process_images(f1, f2, pr, sm, th, hm, bg)
1119
- return summary, p1, p2, combined, gr.File.update(value=None, visible=False)
1120
-
1121
- img_run_btn.click(
1122
- fn=process_images_ui,
1123
- inputs=[img_input1, img_input2, img_page_range,
1124
- img_size_mode, img_threshold, img_highlight_mode, img_sidebyside_bg],
1125
- outputs=[summary_box, output_pdf1, output_pdf2, output_combined, output_csv],
1126
- )
1127
-
1128
- # 注釈比較 実行
1129
- def process_annotations_ui(f1, f2, cm):
1130
- summary, p1, p2, combined, csv = process_annotations(f1, f2, cm)
1131
- return summary, p1, p2, combined, gr.File.update(value=csv, visible=True)
1132
-
1133
- annot_run_btn.click(
1134
- fn=process_annotations_ui,
1135
- inputs=[annot_file1, annot_file2, annot_color_select],
1136
- outputs=[summary_box, output_pdf1, output_pdf2, output_combined, output_csv],
1137
- )
1138
-
1139
- # ファイル比較 クリア
1140
- file_clear_btn.click(
1141
- fn=clear_files,
1142
- inputs=[],
1143
- outputs=[
1144
- file_input1, file_input2, file_page_range, file_mode_select, file_color_select,
1145
- sheets1_select, sheets2_select,
1146
- summary_box, output_pdf1, output_pdf2, output_combined, output_csv,
1147
- ],
1148
- )
1149
-
1150
- # 画像比較 クリア
1151
- img_clear_btn.click(
1152
- fn=clear_images,
1153
- inputs=[],
1154
- outputs=[
1155
- img_input1, img_input2, img_page_range,
1156
- img_threshold, img_size_mode, img_highlight_mode, img_sidebyside_bg,
1157
- summary_box, output_pdf1, output_pdf2, output_combined,
1158
- ],
1159
- )
1160
 
1161
- # 注釈比較 クリア
1162
- annot_clear_btn.click(
1163
- fn=clear_annotations,
1164
- inputs=[],
1165
- outputs=[
1166
- annot_file1, annot_file2, annot_color_select,
1167
- summary_box, output_pdf1, output_pdf2, output_combined, output_csv,
1168
- ],
1169
- )
1170
 
1171
- if __name__ == "__main__":
1172
- demo.launch()
 
 
 
1
+ """
2
+ フォント比較ツール 事前検証スクリプト
3
+ - OCGレイヤー作成・表示切替
4
+ - フォント色取得
5
+ - 座標ベースのマッチング
6
+ を検証する。
7
+
8
+ 実行方法:
9
+ python verify_font_compare.py test1.pdf test2.pdf
10
+ """
11
+
12
+ import sys
13
  import os
14
+ import fitz
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  # ------------------------------------------------------------------ #
17
+ # 検証1: OCG (レイヤー) の作成と描画
18
  # ------------------------------------------------------------------ #
19
+ def verify_ocg():
20
+ print("\n=== 検証1: OCGレイヤー作成 ===")
21
+ doc = fitz.open()
22
+ page = doc.new_page(width=595, height=842)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
 
 
 
 
 
 
 
 
 
24
  try:
25
+ ocg1 = doc.add_ocg("元ファイル_Arial", on=True)
26
+ ocg2 = doc.add_ocg("修正ファイル_Meiryo", on=True)
27
+ print(f" OCG作成成功: xref={ocg1}, {ocg2}")
28
+
29
+ # OCG付きで矩形を描画
30
+ shape = page.new_shape()
31
+ shape.draw_rect(fitz.Rect(50, 50, 150, 70))
32
+ shape.finish(fill=(1, 0.5, 0.5), fill_opacity=0.4, color=None, oc=ocg1)
33
+ shape.commit()
34
+
35
+ shape = page.new_shape()
36
+ shape.draw_rect(fitz.Rect(50, 80, 150, 100))
37
+ shape.finish(fill=(0.5, 0.5, 1), fill_opacity=0.4, color=None, oc=ocg2)
38
+ shape.commit()
39
+
40
+ out_path = "verify_ocg_output.pdf"
41
+ doc.save(out_path, garbage=4, deflate=True)
42
+ print(f" 保存成功: {out_path}")
43
+ print(" → AcrobatでPDFを開き、レイヤーパネルに2つのレイヤーが表示されることを確認してください")
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  except Exception as e:
46
+ print(f" ★エラー: {e}")
47
+ finally:
48
+ doc.close()
49
 
50
  # ------------------------------------------------------------------ #
51
+ # 検証2: フォント色の取得
52
  # ------------------------------------------------------------------ #
53
+ def verify_font_color(pdf_path):
54
+ print(f"\n=== 検証2: フォント色取得 [{pdf_path}] ===")
55
+ try:
56
+ doc = fitz.open(pdf_path)
57
+ page = doc[0]
 
 
 
58
  blocks = page.get_text("rawdict")["blocks"]
59
+ found = 0
60
  for block in blocks:
61
+ if "lines" not in block:
62
+ continue
63
+ for line in block["lines"]:
64
+ for span in line["spans"]:
65
+ color_val = span.get("color", None)
66
+ font = span.get("font", "")
67
+ size = round(span.get("size", 0), 2)
68
+ text = span.get("text", "")[:10]
69
+ print(f" font={font}, size={size}, color={color_val}, text='{text}'")
70
+ found += 1
71
+ if found >= 5:
72
+ break
73
+ if found >= 5:
74
+ break
75
+ if found >= 5:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  break
77
+ print(f" フォント色はint型で取得: RGB分解が必要")
78
+ # colorはRGB整数 (例: 0xFF0000=赤)
79
+ sample_color = 0xFF0000
80
+ r = (sample_color >> 16) & 0xFF
81
+ g = (sample_color >> 8) & 0xFF
82
+ b = (sample_color ) & 0xFF
83
+ print(f" 分解例: 0x{sample_color:06X} → R={r}, G={g}, B={b}")
84
+ doc.close()
85
+ except Exception as e:
86
+ print(f" ★エラー: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  # ------------------------------------------------------------------ #
89
+ # 検証3: 座標ベースのマッチング(2ファイル比較)
90
  # ------------------------------------------------------------------ #
91
+ def verify_position_matching(pdf1_path, pdf2_path, tolerance=5.0):
92
+ print(f"\n=== 検証3: 座標ベースマッチング (tolerance={tolerance}pt) ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  try:
94
+ doc1 = fitz.open(pdf1_path)
95
+ doc2 = fitz.open(pdf2_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ page1 = doc1[0]
98
+ page2 = doc2[0]
 
99
 
100
+ def get_char_list(page):
101
+ chars = []
102
+ for block in page.get_text("rawdict")["blocks"]:
 
 
 
103
  if "lines" not in block:
104
  continue
105
  for line in block["lines"]:
106
  for span in line["spans"]:
107
+ color_val = span.get("color", 0)
108
+ r = ((color_val >> 16) & 0xFF) / 255.0
109
+ g = ((color_val >> 8) & 0xFF) / 255.0
110
+ b = ((color_val ) & 0xFF) / 255.0
111
  for char in span["chars"]:
112
+ if not char["c"].isspace():
113
+ bbox = char["bbox"]
114
+ cx = (bbox[0] + bbox[2]) / 2
115
+ cy = (bbox[1] + bbox[3]) / 2
116
+ chars.append({
117
+ "char": char["c"],
118
+ "cx": cx,
119
+ "cy": cy,
120
+ "font": span.get("font", ""),
121
+ "font_size": round(span.get("size", 0), 2),
122
+ "color_r": round(r, 3),
123
+ "color_g": round(g, 3),
124
+ "color_b": round(b, 3),
125
+ "rect": bbox,
126
+ "page": 0,
127
+ })
128
+ return chars
129
+
130
+ chars1 = get_char_list(page1)
131
+ chars2 = get_char_list(page2)
132
+
133
+ print(f" ファイル1 文字数: {len(chars1)}")
134
+ print(f" ファイル2 文字数: {len(chars2)}")
135
+
136
+ matched = 0
137
+ font_diff = 0
138
+ size_diff = 0
139
+ color_diff = 0
140
+ unmatched = 0
141
+
142
+ for c1 in chars1:
143
+ # 同ページ・座標が近い文字を探す
144
+ candidates = [
145
+ c2 for c2 in chars2
146
+ if abs(c2["cx"] - c1["cx"]) <= tolerance
147
+ and abs(c2["cy"] - c1["cy"]) <= tolerance
148
+ ]
149
+ if not candidates:
150
+ unmatched += 1
151
  continue
152
+ # 最も近いものを選択
153
+ best = min(candidates, key=lambda c2: (abs(c2["cx"]-c1["cx"])**2 + abs(c2["cy"]-c1["cy"])**2)**0.5)
154
+ matched += 1
155
+ if c1["font"] != best["font"]:
156
+ font_diff += 1
157
+ if abs(c1["font_size"] - best["font_size"]) > 0.1:
158
+ size_diff += 1
159
+ if abs(c1["color_r"] - best["color_r"]) > 0.01 \
160
+ or abs(c1["color_g"] - best["color_g"]) > 0.01 \
161
+ or abs(c1["color_b"] - best["color_b"]) > 0.01:
162
+ color_diff += 1
163
+
164
+ print(f" マッチ成功: {matched} / {len(chars1)}")
165
+ print(f" マッチ未発見: {unmatched}")
166
+ print(f" フ���ント名差異: {font_diff}")
167
+ print(f" フォントサイズ差異: {size_diff}")
168
+ print(f" フォント色差異: {color_diff}")
169
+
170
+ doc1.close()
171
+ doc2.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
+ except Exception as e:
174
+ print(f" ★エラー: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  # ------------------------------------------------------------------ #
177
+ # 検証4: OCG付きshapeとハイライト注釈の共存確認
178
  # ------------------------------------------------------------------ #
179
+ def verify_ocg_with_real_pdf(pdf_path):
180
+ print(f"\n=== 検証4: 実PDFへのOCG付き描画 [{pdf_path}] ===")
181
+ try:
182
+ doc = fitz.open(pdf_path)
183
+ page = doc[0]
184
+
185
+ ocg1 = doc.add_ocg("テスト_元ファイル_Arial", on=True)
186
+ ocg2 = doc.add_ocg("テスト_修正ファイル_Meiryo", on=True)
187
+
188
+ shape = page.new_shape()
189
+ shape.draw_rect(fitz.Rect(50, 50, 200, 70))
190
+ shape.finish(fill=(1, 0.5, 0.5), fill_opacity=0.4, color=None, oc=ocg1)
191
+ shape.commit()
192
+
193
+ shape = page.new_shape()
194
+ shape.draw_rect(fitz.Rect(50, 75, 200, 95))
195
+ shape.finish(fill=(0.5, 0.5, 1), fill_opacity=0.4, color=None, oc=ocg2)
196
+ shape.commit()
197
+
198
+ out_path = "verify_ocg_real_output.pdf"
199
+ doc.save(out_path, garbage=4, deflate=True)
200
+ print(f" 保存成功: {out_path}")
201
+ print(" → AcrobatでPDFを開き、レイヤーのON/OFFで矩形が消えることを確認してください")
202
+ doc.close()
203
+ except Exception as e:
204
+ print(f" ★エラー: {e}")
205
 
206
  # ------------------------------------------------------------------ #
207
+ # メイン
208
  # ------------------------------------------------------------------ #
209
+ if __name__ == "__main__":
210
+ verify_ocg()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
+ if len(sys.argv) >= 2:
213
+ verify_font_color(sys.argv[1])
214
+ verify_ocg_with_real_pdf(sys.argv[1])
215
+ else:
216
+ print("\n※ PDF引数なし: 検証2, 4をスキップ")
217
+ print(" 使い方: python verify_font_compare.py test1.pdf test2.pdf")
 
 
 
218
 
219
+ if len(sys.argv) >= 3:
220
+ verify_position_matching(sys.argv[1], sys.argv[2])
221
+ else:
222
+ print("\n※ PDF引数2つなし: 検証3をスキップ")