curlkun commited on
Commit
cdc0a8a
·
verified ·
1 Parent(s): 0ff7a89

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +109 -853
app.py CHANGED
@@ -1,885 +1,141 @@
1
- import gradio as gr
2
- import difflib
3
- import re
4
- import html
5
  import io
 
6
  from reportlab.lib.pagesizes import A4
7
- from reportlab.lib.units import mm
8
  from reportlab.pdfbase import pdfmetrics
9
  from reportlab.pdfbase.ttfonts import TTFont
10
- from reportlab.pdfgen import canvas as rl_canvas
11
- import fitz # PyMuPDF
12
  import tempfile
13
- import os
14
-
15
- # フォント登録
16
- pdfmetrics.registerFont(TTFont("NotoSansJP", "NotoSansJP-Regular.ttf"))
17
-
18
- # ============================================================
19
- # 文字除去ユーティリティ
20
- # ============================================================
21
-
22
- LATIN_LIGATURES = set(range(0xFB00, 0xFB07))
23
-
24
- KANGXI_RADICALS = set(range(0x2F00, 0x2FD6))
25
- CJK_RADICALS_SUPPLEMENT = set(range(0x2E80, 0x2F00))
26
-
27
- COMPATIBILITY_IDEOGRAPHS = set(range(0xF900, 0xFB00))
28
- ENCLOSED_CJK = set(range(0x1F200, 0x1F300))
29
- VERTICAL_BRACKETS = set(range(0xFE10, 0xFE20))
30
- CJK_COMPATIBILITY = set(range(0x3300, 0x3400))
31
- ENCLOSED_ALPHANUMERICS = set(range(0x2460, 0x2500))
32
- ENCLOSED_CJK_LETTERS = set(range(0x3200, 0x3300))
33
-
34
- HYPHENS = set([
35
- '\u002D', # 半角ハイフンマイナス
36
- '\uFF0D', # 全角ハイフンマイナス
37
- '\u2010', # HYPHEN
38
- '\u2011', # NON-BREAKING HYPHEN
39
- '\u2012', # FIGURE DASH
40
- '\u2013', # EN DASH
41
- '\u2014', # EM DASH
42
- ])
43
-
44
- KANJI_NUMBERS = set('零一二三四五六七八九十')
45
- ARABIC_NUMBERS = set('01234567890123456789')
46
-
47
- YAKUMONO = set(
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
- BRACKET_PAIRS = [
80
- ('(', ')'),
81
- ('(', ')'),
82
- ('[', ']'),
83
- ('〔', '〕'),
84
- ('《', '》'),
85
- ('〈', '〉'),
86
- ('【', '】'),
87
- ('[', ']'),
88
- ('「', '」'),
89
- ('『', '』'),
90
- ]
91
- OPEN_BRACKETS = set(p[0] for p in BRACKET_PAIRS)
92
- CLOSE_BRACKETS = set(p[1] for p in BRACKET_PAIRS)
93
- BRACKET_MAP = {p[0]: p[1] for p in BRACKET_PAIRS}
94
-
95
- def remove_brackets_with_content(text: str) -> str:
96
- result = []
97
- i = 0
98
- n = len(text)
99
- while i < n:
100
- ch = text[i]
101
- if ch in OPEN_BRACKETS:
102
- expected_close = BRACKET_MAP[ch]
103
- depth = 1
104
- j = i + 1
105
- while j < n and depth > 0:
106
- if text[j] == ch:
107
- depth += 1
108
- elif text[j] == expected_close:
109
- depth -= 1
110
- elif text[j] in CLOSE_BRACKETS and text[j] != expected_close:
111
- break
112
- j += 1
113
- if depth == 0:
114
- i = j
115
- else:
116
- result.append(ch)
117
- i += 1
118
- else:
119
- result.append(ch)
120
- i += 1
121
- return ''.join(result)
122
-
123
- def remove_yakumono(text: str) -> str:
124
- return ''.join(c for c in text if c not in YAKUMONO)
125
-
126
- # ============================================================
127
- # 逆マッピング
128
- # ============================================================
129
-
130
- def build_active_flags(original: str, settings: dict) -> list:
131
- n = len(original)
132
- flags = [True] * n
133
-
134
- if settings.get('ignore_brackets_with_content'):
135
- i = 0
136
- while i < n:
137
- ch = original[i]
138
- if ch in OPEN_BRACKETS:
139
- expected_close = BRACKET_MAP[ch]
140
- depth = 1
141
- j = i + 1
142
- while j < n and depth > 0:
143
- if original[j] == ch: depth += 1
144
- elif original[j] == expected_close: depth -= 1
145
- elif original[j] in CLOSE_BRACKETS and original[j] != expected_close: break
146
- j += 1
147
- if depth == 0:
148
- for k in range(i, j): flags[k] = False
149
- i = j
150
- else:
151
- i += 1
152
- else:
153
- i += 1
154
-
155
- if settings.get('ignore_yakumono'):
156
- for i, ch in enumerate(original):
157
- if ch in YAKUMONO: flags[i] = False
158
-
159
- if settings.get('ignore_alpha'):
160
- for i, ch in enumerate(original):
161
- if re.match(r'[A-Za-zA-Za-z]', ch): flags[i] = False
162
-
163
- if settings.get('ignore_digit'):
164
- for i, ch in enumerate(original):
165
- if re.match(r'[0-90-9]', ch): flags[i] = False
166
-
167
- if settings.get('ignore_kana'):
168
- for i, ch in enumerate(original):
169
- if re.match(r'[ァ-ンヲ-゚]', ch): flags[i] = False
170
-
171
- if settings.get('ignore_ligature'):
172
- for i, ch in enumerate(original):
173
- if ord(ch) in LATIN_LIGATURES: flags[i] = False
174
-
175
- if settings.get('ignore_kangxi'):
176
- for i, ch in enumerate(original):
177
- if ord(ch) in KANGXI_RADICALS or ord(ch) in CJK_RADICALS_SUPPLEMENT: flags[i] = False
178
-
179
- if settings.get('ignore_kanjinum'):
180
- for i, ch in enumerate(original):
181
- if ch in KANJI_NUMBERS or ch in ARABIC_NUMBERS: flags[i] = False
182
-
183
- if settings.get('ignore_hyphen'):
184
- for i, ch in enumerate(original):
185
- if ch in HYPHENS: flags[i] = False
186
-
187
- if settings.get('ignore_kumimoji'):
188
- for i, ch in enumerate(original):
189
- if (ord(ch) in COMPATIBILITY_IDEOGRAPHS or ord(ch) in ENCLOSED_CJK
190
- or ord(ch) in VERTICAL_BRACKETS or ord(ch) in CJK_COMPATIBILITY
191
- or ord(ch) in ENCLOSED_ALPHANUMERICS or ord(ch) in ENCLOSED_CJK_LETTERS):
192
- flags[i] = False
193
-
194
- if settings.get('ignore_space'):
195
- for i, ch in enumerate(original):
196
- if ch in ' \t ': flags[i] = False
197
-
198
- if settings.get('ignore_newline'):
199
- for i, ch in enumerate(original):
200
- if ch in '\r\n': flags[i] = False
201
-
202
- return flags
203
-
204
- def build_active_text_and_map(original: str, flags: list):
205
- active_chars = [(i, ch) for i, ch in enumerate(original) if flags[i]]
206
- active_text = ''.join(ch for _, ch in active_chars)
207
- index_map = [i for i, _ in active_chars]
208
- return active_text, index_map
209
-
210
- # ============================================================
211
- # ポストフィルタリング
212
- # ============================================================
213
-
214
- EQUAL_MERGE_THRESHOLD = 0
215
-
216
- def filter_opcodes(opcodes):
217
- ops = list(opcodes)
218
- n = len(ops)
219
- if n == 0:
220
- return [], []
221
-
222
- is_diff = []
223
- for tag, i1, i2, j1, j2 in ops:
224
- if tag == 'equal':
225
- is_diff.append((i2 - i1) <= EQUAL_MERGE_THRESHOLD)
226
- else:
227
- is_diff.append(True)
228
-
229
- # 両端のequal区間はdiff扱いにしない
230
- for idx in range(n):
231
- if ops[idx][0] == 'equal':
232
- is_diff[idx] = False
233
- else:
234
- break
235
- for idx in range(n-1, -1, -1):
236
- if ops[idx][0] == 'equal':
237
- is_diff[idx] = False
238
- else:
239
- break
240
-
241
- result_src = []
242
- result_dst = []
243
-
244
- i = 0
245
- while i < n:
246
- if is_diff[i]:
247
- j = i + 1
248
- while j < n and is_diff[j]:
249
- j += 1
250
- for k in range(i, j):
251
- tag, i1, i2, j1, j2 = ops[k]
252
- if tag in ('replace', 'delete'):
253
- for pos in range(i1, i2):
254
- result_src.append(pos)
255
- if tag in ('replace', 'insert'):
256
- for pos in range(j1, j2):
257
- result_dst.append(pos)
258
- i = j
259
- else:
260
- i += 1
261
-
262
- return result_src, result_dst
263
-
264
- # ============================================================
265
- # 比較コア
266
- # ============================================================
267
-
268
- def merge_ranges(ranges):
269
- merged = []
270
- for s, e in sorted(ranges):
271
- if s >= e: continue
272
- if merged and s <= merged[-1][1]:
273
- merged[-1] = (merged[-1][0], max(merged[-1][1], e))
274
- else:
275
- merged.append([s, e])
276
- return [(s, e) for s, e in merged]
277
-
278
- def compare_texts(src: str, dst: str, settings: dict):
279
- src_flags = build_active_flags(src, settings)
280
- dst_flags = build_active_flags(dst, settings)
281
-
282
- src_active, src_map = build_active_text_and_map(src, src_flags)
283
- dst_active, dst_map = build_active_text_and_map(dst, dst_flags)
284
-
285
- color_mode = settings.get('color_mode', 'color1')
286
- if color_mode == 'color1':
287
- hl_style = 'background-color:#ffaaaa;color:#000000;'
288
- elif color_mode == 'color2':
289
- hl_style = 'background-color:#aaaaff;color:#000000;'
290
- else:
291
- hl_style = 'background-color:#000000;color:#ffffff;'
292
-
293
- src_diff_ranges = []
294
- dst_diff_ranges = []
295
-
296
- if src_active != '' or dst_active != '':
297
- matcher = difflib.SequenceMatcher(None, src_active, dst_active, autojunk=False)
298
- opcodes = matcher.get_opcodes()
299
- result_src, result_dst = filter_opcodes(opcodes)
300
-
301
- for pos in result_src:
302
- orig = src_map[pos]
303
- src_diff_ranges.append((orig, orig + 1))
304
-
305
- for pos in result_dst:
306
- orig = dst_map[pos]
307
- dst_diff_ranges.append((orig, orig + 1))
308
-
309
- has_diff = bool(src_diff_ranges or dst_diff_ranges)
310
-
311
- def build_html(text: str, highlight_ranges: list) -> str:
312
- merged = merge_ranges(highlight_ranges)
313
- if not merged:
314
- return '<span>' + html.escape(text).replace('\n', '<br>') + '</span>'
315
- parts = []
316
- prev = 0
317
- for s, e in merged:
318
- s = max(s, prev)
319
- e = min(e, len(text))
320
- if s >= e:
321
- continue
322
- if prev < s:
323
- parts.append('<span>' + html.escape(text[prev:s]).replace('\n', '<br>') + '</span>')
324
- parts.append(
325
- f'<span style="{hl_style}">'
326
- + html.escape(text[s:e]).replace('\n', '<br>')
327
- + '</span>'
328
- )
329
- prev = e
330
- if prev < len(text):
331
- parts.append('<span>' + html.escape(text[prev:]).replace('\n', '<br>') + '</span>')
332
- return ''.join(parts)
333
-
334
- src_html = build_html(src, merge_ranges(src_diff_ranges))
335
- dst_html = build_html(dst, merge_ranges(dst_diff_ranges))
336
-
337
- return src_html, dst_html, has_diff
338
-
339
- # ============================================================
340
- # 統計
341
- # ============================================================
342
-
343
- def count_stats(text: str) -> dict:
344
- char_count = len(re.sub(r'[  \t\r\n]', '', text))
345
- space_count = len(re.findall(r'[  \t]', text))
346
- newline_count = len(re.findall(r'\r\n|\r|\n', text))
347
- space_incl = char_count + space_count
348
- newline_incl = char_count + newline_count
349
- words = len(re.split(r'[  \t\r\n]+', text.strip())) if text.strip() else 0
350
- return {
351
- 'char': char_count,
352
- 'space': space_count,
353
- 'space_incl': space_incl,
354
- 'newline': newline_count,
355
- 'newline_incl': newline_incl,
356
- 'words': words,
357
- }
358
-
359
- # ============================================================
360
- # PDF生成
361
- # ============================================================
362
-
363
- def generate_pdf(src_orig: str, dst_orig: str, settings: dict,
364
- src_html_body: str, dst_html_body: str, has_diff: bool) -> str:
365
-
366
- FONT = "NotoSansJP"
367
- PAGE_W, PAGE_H = A4
368
- MARGIN = 15 * mm
369
- COL_GAP = 4 * mm
370
- COL_W = (PAGE_W - MARGIN * 2 - COL_GAP) / 2
371
- FONT_SIZE = 8.5
372
- LINE_HEIGHT= 13
373
- COL_L = MARGIN
374
- COL_R = MARGIN + COL_W + COL_GAP
375
- DIV_X = MARGIN + COL_W + COL_GAP / 2
376
-
377
- color_mode = settings.get('color_mode', 'color1')
378
- if color_mode == 'color1':
379
- HL_COLOR_RL = (1.0, 0.6, 0.6)
380
- HL_FG_RL = (0.0, 0.0, 0.0)
381
- HL_COLOR_MU = (1.0, 0.6, 0.6)
382
- elif color_mode == 'color2':
383
- HL_COLOR_RL = (0.6, 0.8, 1.0)
384
- HL_FG_RL = (0.0, 0.0, 0.0)
385
- HL_COLOR_MU = (0.6, 0.8, 1.0)
386
- else: # monochrome
387
- HL_COLOR_RL = (0.0, 0.0, 0.0)
388
- HL_FG_RL = (1.0, 1.0, 1.0)
389
- HL_COLOR_MU = (0.5, 0.5, 0.5)
390
 
391
- # ----------------------------------------------------------
392
- # HTMLから差分範囲を復元
393
- # ----------------------------------------------------------
394
- def extract_diff_ranges(text: str, html_body: str) -> list:
395
- """
396
- html_bodyのspanタグからハイライト箇所の文字インデックスリストを復元する。
397
- 戻り値: [(start, end), ...] ※textのインデックス
398
- """
399
- ranges = []
400
- # ハイライトspanを検索
401
- pattern = re.compile(
402
- r'<span style="[^"]*background-color[^"]*">(.*?)</span>',
403
- re.DOTALL
404
- )
405
- # タグを除去してプレーンテキストを再構築しながら位置を追跡
406
- plain = ''
407
- pos = 0
408
- for m in re.finditer(
409
- r'<span(?:\s+style="([^"]*)")?>(.*?)</span>',
410
- html_body, re.DOTALL
411
- ):
412
- style = m.group(1) or ''
413
- content = m.group(2)
414
- # <br>を改行に戻す
415
- content = content.replace('<br>', '\n')
416
- # HTMLエスケープを戻す
417
- content = html.unescape(content)
418
- is_hl = 'background-color' in style
419
- start = len(plain)
420
- plain += content
421
- end = len(plain)
422
- if is_hl:
423
- ranges.append((start, end))
424
- return ranges
425
 
426
- src_diff_ranges = extract_diff_ranges(src_orig, src_html_body)
427
- dst_diff_ranges = extract_diff_ranges(dst_orig, dst_html_body)
428
-
429
- # ----------------------------------------------------------
430
- # テキストを折り返し済み描画単位に変換
431
- # ----------------------------------------------------------
432
- def to_sublines(text: str, col_w: float, font_size: float):
433
- """
434
- 戻り値: [(char_offset, line_text), ...]
435
- char_offset: テキスト先頭からの文字インデックス
436
- """
437
- result = []
438
- lines = text.split('\n')
439
- offset = 0
440
- for line in lines:
441
- cur = ''
442
- cur_start = offset
443
- for ci, ch in enumerate(line):
444
- test = cur + ch
445
- if pdfmetrics.stringWidth(test, FONT, font_size) > col_w - 2 and cur:
446
- result.append((cur_start, cur))
447
- cur = ch
448
- cur_start = offset + ci
449
- else:
450
- cur = test
451
- result.append((cur_start, cur))
452
- offset += len(line) + 1 # +1 for '\n'
453
- return result
454
-
455
- # ----------------------------------------------------------
456
- # ハイライト判定
457
- # ----------------------------------------------------------
458
- def is_highlighted(char_idx: int, diff_ranges: list) -> bool:
459
- for s, e in diff_ranges:
460
- if s <= char_idx < e:
461
- return True
462
- return False
463
-
464
- # ----------------------------------------------------------
465
- # 1行描画(背景矩形 → 文字)
466
- # 戻り値: ハイライトした文字の座標リスト [(x0,y0,x1,y1), ...]
467
- # ※ PyMuPDF注釈付与用
468
- # ----------------------------------------------------------
469
- def draw_subline(c, sub_text: str, sub_offset: int, diff_ranges: list,
470
- x_start: float, y_rl: float, font_size: float) -> list:
471
- """
472
- y_rl: reportlabのy座標(下原点)
473
- 戻り値: ハイライト文字のrect座標リスト (reportlab座標系)
474
- """
475
- if not sub_text:
476
- return []
477
 
478
- hl_rects = []
 
479
 
480
- # パス1: 背景矩形
481
- dx = x_start
482
- for ci, ch in enumerate(sub_text):
483
- cw = pdfmetrics.stringWidth(ch, FONT, font_size)
484
- if is_highlighted(sub_offset + ci, diff_ranges):
485
  c.saveState()
486
- c.setFillColorRGB(*HL_COLOR_RL)
487
- c.rect(dx, y_rl - 2, cw, LINE_HEIGHT - 1, fill=1, stroke=0)
488
  c.restoreState()
489
- hl_rects.append((dx, y_rl - 2, dx + cw, y_rl + LINE_HEIGHT - 1))
490
- dx += cw
491
-
492
- # パス2: 文字描画
493
- dx = x_start
494
- seg = ''
495
- seg_hl = None
496
- seg_x = x_start
497
-
498
- for ci, ch in enumerate(sub_text):
499
- hl = is_highlighted(sub_offset + ci, diff_ranges)
500
- cw = pdfmetrics.stringWidth(ch, FONT, font_size)
501
- if seg_hl is None:
502
- seg_hl, seg, seg_x = hl, ch, dx
503
- elif hl == seg_hl:
504
- seg += ch
505
- else:
506
- c.saveState()
507
- c.setFont(FONT, font_size)
508
- c.setFillColorRGB(*(HL_FG_RL if seg_hl else (0.0, 0.0, 0.0)))
509
- c.drawString(seg_x, y_rl, seg)
510
- c.restoreState()
511
- seg_x += pdfmetrics.stringWidth(seg, FONT, font_size)
512
- seg_hl, seg = hl, ch
513
- dx += cw
514
-
515
- if seg:
516
  c.saveState()
517
- c.setFont(FONT, font_size)
518
- c.setFillColorRGB(*(HL_FG_RL if seg_hl else (0.0, 0.0, 0.0)))
519
- c.drawString(seg_x, y_rl, seg)
 
 
 
520
  c.restoreState()
521
-
522
- return hl_rects
523
-
524
- # ----------------------------------------------------------
525
- # reportlabでベースPDF生成
526
- # ----------------------------------------------------------
527
- def build_base_pdf() -> tuple:
528
- """
529
- 戻り値: (pdf_bytes, all_hl_rects)
530
- all_hl_rects: {page_no: {'src': [(x0,y0_rl,x1,y1_rl),...], 'dst': [...]}}
531
- ※ y座標はreportlab座標系(下原点)
532
- """
533
- buffer = io.BytesIO()
534
- c = rl_canvas.Canvas(buffer, pagesize=A4)
535
- all_hl = {} # page_no -> {'src':[], 'dst':[]}
536
- page_no = 0
537
-
538
- def hline(y, lw=0.5):
539
  c.saveState()
540
- c.setStrokeColorRGB(0.5, 0.5, 0.5)
541
- c.setLineWidth(lw)
542
- c.line(MARGIN, y, PAGE_W - MARGIN, y)
543
  c.restoreState()
 
544
 
545
- def new_page():
546
- nonlocal page_no
547
- c.showPage()
548
- page_no += 1
549
-
550
- def ensure_page_space(y, needed=LINE_HEIGHT):
551
- if y < MARGIN + needed:
552
- new_page()
553
- return PAGE_H - MARGIN
554
- return y
555
-
556
- y = PAGE_H - MARGIN
557
-
558
- # タイトル
559
- c.saveState()
560
- c.setFont(FONT, 18)
561
- c.setFillColorRGB(0, 0, 0)
562
- title = "テキスト比較ツール"
563
- c.drawString((PAGE_W - pdfmetrics.stringWidth(title, FONT, 18)) / 2, y, title)
564
- c.restoreState()
565
- y -= 24
566
-
567
- hline(y)
568
- y -= 10
569
-
570
- # 比較設定
571
- c.saveState()
572
- c.setFont(FONT, 10)
573
- c.setFillColorRGB(0, 0, 0)
574
- c.drawString(MARGIN, y, "■ 比較設定")
575
- c.restoreState()
576
- y -= 14
577
-
578
- ignore_labels = {
579
- 'ignore_alpha': '英字(全半角)無視',
580
- 'ignore_digit': '数字(全半角)無視',
581
- 'ignore_kana': 'カナ(全半角)無視',
582
- 'ignore_yakumono': '約物(全半角)無視',
583
- 'ignore_ligature': 'リガチャ(合字)無視',
584
- 'ignore_kangxi': '康煕部首(CJK部首補助)無視',
585
- 'ignore_brackets_with_content': '括弧付き文字無視',
586
- 'ignore_kanjinum': '漢数字(算用数字)無視',
587
- 'ignore_hyphen': 'ハイフン無視',
588
- 'ignore_kumimoji': '組み文字無視',
589
- 'ignore_space': 'スペース(空白)削除',
590
- 'ignore_newline': '改行削除',
591
- }
592
- checked = [v for k, v in ignore_labels.items() if settings.get(k)]
593
- c.saveState()
594
- c.setFont(FONT, 8)
595
- c.setFillColorRGB(0, 0, 0)
596
- c.drawString(MARGIN, y, "有効な無視設定: " + ("、".join(checked) if checked else "なし"))
597
- c.restoreState()
598
- y -= 12
599
-
600
- hline(y)
601
- y -= 14
602
-
603
- # 結果ヘッダー
604
- c.saveState()
605
- c.setFont(FONT, 14)
606
- c.setFillColorRGB(0.85, 0, 0)
607
- c.drawString(MARGIN, y, "相違が見つかりました" if has_diff else "相違はありません")
608
- c.restoreState()
609
- y -= 20
610
-
611
- # カラムヘッダー
612
- c.saveState()
613
- c.setFont(FONT, 9)
614
- c.setFillColorRGB(0, 0, 0)
615
- c.drawString(COL_L, y, "【比較元】")
616
- c.drawString(COL_R, y, "【比較先】")
617
- c.restoreState()
618
- y -= 14
619
-
620
- # 本文描画
621
- sublines_src = to_sublines(src_orig, COL_W, FONT_SIZE)
622
- sublines_dst = to_sublines(dst_orig, COL_W, FONT_SIZE)
623
- max_lines = max(len(sublines_src), len(sublines_dst))
624
- while len(sublines_src) < max_lines:
625
- sublines_src.append((len(src_orig), ''))
626
- while len(sublines_dst) < max_lines:
627
- sublines_dst.append((len(dst_orig), ''))
628
-
629
- seg_y_top = y
630
- all_hl[page_no] = {'src': [], 'dst': []}
631
-
632
- for (off_s, txt_s), (off_d, txt_d) in zip(sublines_src, sublines_dst):
633
- y = ensure_page_space(y)
634
- if y == PAGE_H - MARGIN:
635
- # ページ送り発生 → 縦区切り線を前ページに描画済み想定
636
- # 新ページの縦線開始位置を更新
637
- seg_y_top = y
638
- all_hl[page_no] = {'src': [], 'dst': []}
639
-
640
- rects_s = draw_subline(c, txt_s, off_s, src_diff_ranges, COL_L, y, FONT_SIZE)
641
- rects_d = draw_subline(c, txt_d, off_d, dst_diff_ranges, COL_R, y, FONT_SIZE)
642
- all_hl[page_no]['src'].extend(rects_s)
643
- all_hl[page_no]['dst'].extend(rects_d)
644
- y -= LINE_HEIGHT
645
-
646
- # 縦区切り線
647
- c.saveState()
648
- c.setStrokeColorRGB(0.5, 0.5, 0.5)
649
- c.setLineWidth(0.5)
650
- c.line(DIV_X, seg_y_top, DIV_X, y + LINE_HEIGHT)
651
- c.restoreState()
652
-
653
- y -= 6
654
-
655
- # 統計
656
- hline(y, lw=0.3)
657
- y -= 10
658
-
659
- src_stats = count_stats(src_orig)
660
- dst_stats = count_stats(dst_orig)
661
 
662
- def draw_stats(x, stats):
663
- nonlocal y
664
- for line in [
665
- f"文字数: {stats['char']}",
666
- f"空白数: {stats['space']} 空白込み文字数: {stats['space_incl']}",
667
- f"改行数: {stats['newline']} 改行込み文字数: {stats['newline_incl']}",
668
- f"単語数: {stats['words']}",
669
- ]:
670
- c.saveState()
671
- c.setFont(FONT, 7.5)
672
- c.setFillColorRGB(0.2, 0.2, 0.2)
673
- c.drawString(x, y, line)
674
- c.restoreState()
675
- y -= 10
676
-
677
- y_stats = y
678
- draw_stats(COL_L, src_stats)
679
- y = y_stats
680
- draw_stats(COL_R, dst_stats)
681
-
682
- c.save()
683
- return buffer.getvalue(), all_hl
684
-
685
- # ----------------------------------------------------------
686
- # PyMuPDFでHighlight Annotation付与
687
- # ----------------------------------------------------------
688
- def add_highlight_annots(pdf_bytes: bytes, all_hl: dict) -> bytes:
689
  doc = fitz.open(stream=pdf_bytes, filetype="pdf")
 
 
690
 
691
- for page_no, hl_data in all_hl.items():
692
- if page_no >= len(doc):
693
- continue
694
- page = doc[page_no]
695
- page_h = page.rect.height # PyMuPDF座標系の高さ(上原点)
696
-
697
- rects_all = hl_data.get('src', []) + hl_data.get('dst', [])
698
- for (x0, y0_rl, x1, y1_rl) in rects_all:
699
- # reportlab座標(下原点) → PyMuPDF座標(上原点)変換
700
- # reportlab: y0_rl=下端, y1_rl=上端
701
- # PyMuPDF: y_top = page_h - y1_rl, y_bottom = page_h - y0_rl
702
- mu_y_top = page_h - y1_rl
703
- mu_y_bottom = page_h - y0_rl
704
- rect = fitz.Rect(x0, mu_y_top, x1, mu_y_bottom)
705
- annot = page.add_highlight_annot(rect)
706
- annot.set_colors(stroke=HL_COLOR_MU)
707
- annot.update()
708
 
709
  result = io.BytesIO()
710
  doc.save(result)
711
  doc.close()
712
- return result.getvalue()
713
-
714
- # ----------------------------------------------------------
715
- # 実行
716
- # ----------------------------------------------------------
717
- base_pdf_bytes, all_hl_rects = build_base_pdf()
718
- final_pdf_bytes = add_highlight_annots(base_pdf_bytes, all_hl_rects)
719
-
720
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
721
- tmp.write(final_pdf_bytes)
722
- tmp.close()
723
- return tmp.name
724
-
725
- # ============================================================
726
- # Gradio メイン処理
727
- # ============================================================
728
 
729
- MAX_CHARS = 50000
730
 
731
- def run_compare(src_raw, dst_raw,
732
- ig_alpha, ig_digit, ig_kana, ig_yakumono,
733
- ig_ligature, ig_kangxi, ig_bracket_content,
734
- ig_kanjinum, ig_hyphen, ig_kumimoji,
735
- ig_space, ig_newline,
736
- color_mode):
 
 
 
 
737
 
738
- src = (src_raw or '')[:MAX_CHARS]
739
- dst = (dst_raw or '')[:MAX_CHARS]
740
-
741
- settings = {
742
- 'ignore_alpha': ig_alpha,
743
- 'ignore_digit': ig_digit,
744
- 'ignore_kana': ig_kana,
745
- 'ignore_yakumono': ig_yakumono,
746
- 'ignore_ligature': ig_ligature,
747
- 'ignore_kangxi': ig_kangxi,
748
- 'ignore_brackets_with_content': ig_bracket_content,
749
- 'ignore_kanjinum': ig_kanjinum,
750
- 'ignore_hyphen': ig_hyphen,
751
- 'ignore_kumimoji': ig_kumimoji,
752
- 'ignore_space': ig_space,
753
- 'ignore_newline': ig_newline,
754
- 'color_mode': {'カラー1(赤)': 'color1', 'カラー2(青)': 'color2', 'モノクロ': 'monochrome'}.get(color_mode, 'color1'),
755
- }
756
-
757
- src_html_body, dst_html_body, has_diff = compare_texts(src, dst, settings)
758
- src_stats = count_stats(src)
759
- dst_stats = count_stats(dst)
760
-
761
- if has_diff:
762
- diff_label = '<p style="color:red;font-size:1.4em;font-weight:bold;">相違が見つかりました</p>'
763
- else:
764
- diff_label = '<p style="color:red;font-size:1.4em;font-weight:bold;">相違はありません</p>'
765
-
766
- def stats_html(stats):
767
- return (
768
- f'<p style="font-size:0.85em;">'
769
- f'文字数: {stats["char"]} '
770
- f'空白数: {stats["space"]} 空白込み文字数: {stats["space_incl"]}<br>'
771
- f'改行数: {stats["newline"]} 改行込み文字数: {stats["newline_incl"]}<br>'
772
- f'単語数: {stats["words"]}'
773
- f'</p>'
774
- )
775
-
776
- result_html = f"""
777
- {diff_label}
778
- <div style="display:flex;gap:0;font-size:1em;">
779
- <div style="flex:1;padding:8px;word-break:break-all;">
780
- <div style="font-weight:bold;margin-bottom:4px;">比較元</div>
781
- <div style="font-size:1em;line-height:1.8;">{src_html_body}</div>
782
- {stats_html(src_stats)}
783
- </div>
784
- <div style="width:1px;background:#333;"></div>
785
- <div style="flex:1;padding:8px;word-break:break-all;">
786
- <div style="font-weight:bold;margin-bottom:4px;">比較先</div>
787
- <div style="font-size:1em;line-height:1.8;">{dst_html_body}</div>
788
- {stats_html(dst_stats)}
789
- </div>
790
- </div>
791
- """
792
-
793
- pdf_path = generate_pdf(src, dst, settings, src_html_body, dst_html_body, has_diff)
794
-
795
- return result_html, pdf_path
796
-
797
- def clear_all():
798
- return (
799
- '', # src
800
- '', # dst
801
- False, False, False, False,
802
- False, False, False,
803
- False, False, False,
804
- False, False,
805
- 'カラー1(赤)',
806
- '', # result_html
807
- None, # pdf
808
- )
809
-
810
- # ============================================================
811
- # UI
812
- # ============================================================
813
-
814
- with gr.Blocks(title="テキスト比較ツール") as demo:
815
-
816
- gr.Markdown("# テキスト比較ツール")
817
- gr.HTML("<hr>")
818
- gr.Markdown(
819
- "下の枠内(テキストエリア)に比較したい文章を入力し、「比較」ボタンを押します。\n\n"
820
- "※最大50,000文字まで入力できます。コピーペースト時に50,000文字を超えた文字はすべて削除されます。"
821
- "(参考:A4、1ページで1,000~1,500文字程度)"
822
- )
823
-
824
- with gr.Row():
825
- src_input = gr.Textbox(label="比較元データ", lines=12, max_lines=30)
826
- dst_input = gr.Textbox(label="比較先データ", lines=12, max_lines=30)
827
-
828
- gr.HTML('<p style="color:red;font-size:0.85em;">※チェックを入れた項目は差分があっても検出されません</p>')
829
-
830
- with gr.Row():
831
- ig_alpha = gr.Checkbox(label="英字(全半角)無視")
832
- ig_digit = gr.Checkbox(label="数字(全半角)無視")
833
- ig_kana = gr.Checkbox(label="カナ(全半角)無視")
834
- ig_yakumono = gr.Checkbox(label="約物(全半角)無視")
835
-
836
- with gr.Row():
837
- ig_ligature = gr.Checkbox(label="リガチャ(合字)無視")
838
- ig_kangxi = gr.Checkbox(label="康煕部首(CJK部首補助)無視")
839
- ig_bracket_content = gr.Checkbox(label="括弧付き文字無視")
840
- ig_kanjinum = gr.Checkbox(label="漢数字(算用数字)無視")
841
-
842
- with gr.Row():
843
- ig_hyphen = gr.Checkbox(label="ハイフン無視")
844
- ig_kumomoji = gr.Checkbox(label="組み文字無視")
845
- ig_space = gr.Checkbox(label="スペース(空白)削除")
846
- ig_newline = gr.Checkbox(label="改行削除")
847
-
848
- with gr.Row():
849
- compare_btn = gr.Button("比較", variant="primary")
850
- clear_btn = gr.Button("テキストクリア")
851
-
852
- gr.HTML("<hr>")
853
 
854
  color_mode = gr.Radio(
855
  choices=["カラー1(赤)", "カラー2(青)", "モノクロ"],
856
- value="カラー1(赤)",
857
- label="差分箇所の色表示設定",
858
  )
 
 
 
859
 
860
- result_html = gr.HTML(label="比較結果")
861
- pdf_output = gr.File(label="PDFダウンロード")
862
-
863
- all_inputs = [
864
- src_input, dst_input,
865
- ig_alpha, ig_digit, ig_kana, ig_yakumono,
866
- ig_ligature, ig_kangxi, ig_bracket_content,
867
- ig_kanjinum, ig_hyphen, ig_kumomoji,
868
- ig_space, ig_newline,
869
- color_mode,
870
- ]
871
- all_outputs_clear = [
872
- src_input, dst_input,
873
- ig_alpha, ig_digit, ig_kana, ig_yakumono,
874
- ig_ligature, ig_kangxi, ig_bracket_content,
875
- ig_kanjinum, ig_hyphen, ig_kumomoji,
876
- ig_space, ig_newline,
877
- color_mode,
878
- result_html, pdf_output,
879
- ]
880
-
881
- compare_btn.click(fn=run_compare, inputs=all_inputs, outputs=[result_html, pdf_output])
882
- clear_btn.click(fn=clear_all, inputs=[], outputs=all_outputs_clear)
883
 
884
- if __name__ == "__main__":
885
- demo.launch()
 
1
+ # verify_highlight_mono.py
2
+ # モノクロAnnotationの見た目を確認する検証用アプリ
3
+
 
4
  import io
5
+ import gradio as gr
6
  from reportlab.lib.pagesizes import A4
7
+ from reportlab.pdfgen import canvas as rl_canvas
8
  from reportlab.pdfbase import pdfmetrics
9
  from reportlab.pdfbase.ttfonts import TTFont
10
+ import fitz
 
11
  import tempfile
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ FONT_NAME = "NotoSansJP"
14
+ FONT_FILE = "NotoSansJP-Regular.ttf"
15
+
16
+ pdfmetrics.registerFont(TTFont(FONT_NAME, FONT_FILE))
17
+
18
+ PAGE_W, PAGE_H = A4
19
+ MARGIN = 15
20
+ FONT_SIZE = 12
21
+ LINE_HEIGHT= 18
22
+
23
+ COLORS = {
24
+ "カラー1(赤)": {
25
+ "rl_bg": (1.0, 0.6, 0.6),
26
+ "rl_fg": (0.0, 0.0, 0.0),
27
+ "mu": (1.0, 0.6, 0.6),
28
+ },
29
+ "カラー2(青)": {
30
+ "rl_bg": (0.6, 0.8, 1.0),
31
+ "rl_fg": (0.0, 0.0, 0.0),
32
+ "mu": (0.6, 0.8, 1.0),
33
+ },
34
+ "モノクロ": {
35
+ "rl_bg": (0.0, 0.0, 0.0),
36
+ "rl_fg": (1.0, 1.0, 1.0),
37
+ "mu": (0.5, 0.5, 0.5),
38
+ },
39
+ }
40
+
41
+ def build_pdf(color_mode: str) -> bytes:
42
+ col = COLORS[color_mode]
43
+ rl_bg = col["rl_bg"]
44
+ rl_fg = col["rl_fg"]
45
+ mu_color = col["mu"]
 
46
 
47
+ # reportlabでベースPDF生成
48
+ buffer = io.BytesIO()
49
+ c = rl_canvas.Canvas(buffer, pagesize=A4)
50
+ c.setFont(FONT_NAME, FONT_SIZE)
51
+
52
+ lines = [
53
+ ("通常テキストです。ハイライトなし。", False),
54
+ ("ここは差分箇所です。ハイライトあり。", True),
55
+ ("また通常テキストです。", False),
56
+ ("ここも差分箇所。ABCXYZ", True),
57
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ hl_rects = [] # (x0, y0_rl, x1, y1_rl)
60
+ y = PAGE_H - MARGIN - 20
61
 
62
+ for text, is_hl in lines:
63
+ if is_hl:
64
+ tw = pdfmetrics.stringWidth(text, FONT_NAME, FONT_SIZE)
65
+ # モノクロのみ背景矩形を描画
66
+ if color_mode == "モノクロ":
67
  c.saveState()
68
+ c.setFillColorRGB(*rl_bg)
69
+ c.rect(MARGIN, y - 2, tw, LINE_HEIGHT - 1, fill=1, stroke=0)
70
  c.restoreState()
71
+ # ハイライト文字色設定
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  c.saveState()
73
+ c.setFont(FONT_NAME, FONT_SIZE)
74
+ if color_mode == "モノクロ":
75
+ c.setFillColorRGB(*rl_fg)
76
+ else:
77
+ c.setFillColorRGB(0.0, 0.0, 0.0)
78
+ c.drawString(MARGIN, y, text)
79
  c.restoreState()
80
+ # Annotation用座標記録(カラー1/2のみ)
81
+ if color_mode != "モノクロ":
82
+ hl_rects.append((MARGIN, y - 2, MARGIN + tw, y + LINE_HEIGHT - 1))
83
+ else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  c.saveState()
85
+ c.setFont(FONT_NAME, FONT_SIZE)
86
+ c.setFillColorRGB(0.0, 0.0, 0.0)
87
+ c.drawString(MARGIN, y, text)
88
  c.restoreState()
89
+ y -= LINE_HEIGHT
90
 
91
+ c.save()
92
+ pdf_bytes = buffer.getvalue()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
+ # PyMuPDFでAnnotation付与(カラー1/2のみ)
95
+ if color_mode != "モノクロ" and hl_rects:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  doc = fitz.open(stream=pdf_bytes, filetype="pdf")
97
+ page = doc[0]
98
+ page_h = page.rect.height
99
 
100
+ for (x0, y0_rl, x1, y1_rl) in hl_rects:
101
+ mu_y_top = page_h - y1_rl
102
+ mu_y_bottom = page_h - y0_rl
103
+ rect = fitz.Rect(x0, mu_y_top, x1, mu_y_bottom)
104
+ annot = page.add_highlight_annot(rect)
105
+ annot.set_colors(stroke=mu_color)
106
+ annot.update()
 
 
 
 
 
 
 
 
 
 
107
 
108
  result = io.BytesIO()
109
  doc.save(result)
110
  doc.close()
111
+ pdf_bytes = result.getvalue()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ return pdf_bytes
114
 
115
+ def run_verify(color_mode):
116
+ try:
117
+ pdf_bytes = build_pdf(color_mode)
118
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
119
+ tmp.write(pdf_bytes)
120
+ tmp.close()
121
+ return f"[OK] PDF生成完了 ({len(pdf_bytes)} bytes)", tmp.name
122
+ except Exception as e:
123
+ import traceback
124
+ return f"[ERROR] {e}\n{traceback.format_exc()}", None
125
 
126
+ with gr.Blocks(title="モノクロAnnotation検証") as demo:
127
+ gr.Markdown("## モノクロAnnotation検証")
128
+ gr.Markdown("カラーモードを選択して実行し、PDFの見た目を確認してください。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  color_mode = gr.Radio(
131
  choices=["カラー1(赤)", "カラー2(青)", "モノクロ"],
132
+ value="モノクロ",
133
+ label="カラーモード"
134
  )
135
+ run_btn = gr.Button("検証実行", variant="primary")
136
+ log_out = gr.Textbox(label="ログ", lines=5)
137
+ pdf_out = gr.File(label="PDFダウンロード")
138
 
139
+ run_btn.click(fn=run_verify, inputs=[color_mode], outputs=[log_out, pdf_out])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
+ demo.launch()