curlkun commited on
Commit
b2f0449
·
verified ·
1 Parent(s): 268df50

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +172 -547
app.py CHANGED
@@ -1,549 +1,174 @@
1
- # verify_bug2_ver8.py
2
- # 実行方法: python verify_bug2_ver8.py
3
-
4
- import re
5
- import difflib
6
- import html
7
-
8
- # ============================================================
9
- # 定数
10
- # ============================================================
11
-
12
- LATIN_LIGATURES = set(range(0xFB00, 0xFB07))
13
- KANGXI_RADICALS = set(range(0x2F00, 0x2FD6))
14
- CJK_RADICALS_SUPPLEMENT = set(range(0x2E80, 0x2F00))
15
- COMPATIBILITY_IDEOGRAPHS = set(range(0xF900, 0xFB00))
16
- ENCLOSED_CJK = set(range(0x1F200, 0x1F300))
17
- VERTICAL_BRACKETS = set(range(0xFE10, 0xFE20))
18
- CJK_COMPATIBILITY = set(range(0x3300, 0x3400))
19
- ENCLOSED_ALPHANUMERICS = set(range(0x2460, 0x2500))
20
- ENCLOSED_CJK_LETTERS = set(range(0x3200, 0x3300))
21
-
22
- HYPHENS = set([
23
- '\u002D', '\uFF0D', '\u2010', '\u2011', '\u2012', '\u2013', '\u2014',
24
- ])
25
-
26
- KANJI_NUMBERS = set('零一二三四五六七八九十')
27
- ARABIC_NUMBERS = set('01234567890123456789')
28
-
29
- YAKUMONO = set(
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
- BRACKET_PAIRS = [
62
- ('(', ')'), ('(', ')'), ('[', ']'), ('〔', '〕'), ('《', '》'),
63
- ('〈', '〉'), ('【', '】'), ('[', ']'), ('「', '」'), ('『', '』'),
64
- ]
65
- OPEN_BRACKETS = set(p[0] for p in BRACKET_PAIRS)
66
- CLOSE_BRACKETS = set(p[1] for p in BRACKET_PAIRS)
67
- BRACKET_MAP = {p[0]: p[1] for p in BRACKET_PAIRS}
68
-
69
- # ============================================================
70
- # 関数
71
- # ============================================================
72
-
73
- def build_active_flags(original, settings):
74
- n = len(original)
75
- flags = [True] * n
76
-
77
- if settings.get('ignore_brackets_with_content'):
78
- i = 0
79
- while i < n:
80
- ch = original[i]
81
- if ch in OPEN_BRACKETS:
82
- expected_close = BRACKET_MAP[ch]
83
- depth = 1
84
- j = i + 1
85
- while j < n and depth > 0:
86
- if original[j] == ch: depth += 1
87
- elif original[j] == expected_close: depth -= 1
88
- elif original[j] in CLOSE_BRACKETS and original[j] != expected_close: break
89
- j += 1
90
- if depth == 0:
91
- for k in range(i, j): flags[k] = False
92
- i = j
93
- else:
94
- i += 1
95
- else:
96
- i += 1
97
-
98
- if settings.get('ignore_yakumono'):
99
- for i, ch in enumerate(original):
100
- if ch in YAKUMONO: flags[i] = False
101
- if settings.get('ignore_alpha'):
102
- for i, ch in enumerate(original):
103
- if re.match(r'[A-Za-zA-Za-z]', ch): flags[i] = False
104
- if settings.get('ignore_digit'):
105
- for i, ch in enumerate(original):
106
- if re.match(r'[0-90-9]', ch): flags[i] = False
107
- if settings.get('ignore_kana'):
108
- for i, ch in enumerate(original):
109
- if re.match(r'[ァ-ンヲ-゚]', ch): flags[i] = False
110
- if settings.get('ignore_ligature'):
111
- for i, ch in enumerate(original):
112
- if ord(ch) in LATIN_LIGATURES: flags[i] = False
113
- if settings.get('ignore_kangxi'):
114
- for i, ch in enumerate(original):
115
- if ord(ch) in KANGXI_RADICALS or ord(ch) in CJK_RADICALS_SUPPLEMENT: flags[i] = False
116
- if settings.get('ignore_kanjinum'):
117
- for i, ch in enumerate(original):
118
- if ch in KANJI_NUMBERS or ch in ARABIC_NUMBERS: flags[i] = False
119
- if settings.get('ignore_hyphen'):
120
- for i, ch in enumerate(original):
121
- if ch in HYPHENS: flags[i] = False
122
- if settings.get('ignore_kumimoji'):
123
- for i, ch in enumerate(original):
124
- if (ord(ch) in COMPATIBILITY_IDEOGRAPHS or ord(ch) in ENCLOSED_CJK
125
- or ord(ch) in VERTICAL_BRACKETS or ord(ch) in CJK_COMPATIBILITY
126
- or ord(ch) in ENCLOSED_ALPHANUMERICS or ord(ch) in ENCLOSED_CJK_LETTERS):
127
- flags[i] = False
128
- if settings.get('ignore_space'):
129
- for i, ch in enumerate(original):
130
- if ch in ' \t ': flags[i] = False
131
- if settings.get('ignore_newline'):
132
- for i, ch in enumerate(original):
133
- if ch in '\r\n': flags[i] = False
134
-
135
- return flags
136
-
137
- def build_active_text_and_map(original, flags):
138
- active_chars = [(i, ch) for i, ch in enumerate(original) if flags[i]]
139
- active_text = ''.join(ch for _, ch in active_chars)
140
- index_map = [i for i, _ in active_chars]
141
- return active_text, index_map
142
-
143
- def merge_ranges(ranges):
144
- merged = []
145
- for s, e in sorted(ranges):
146
- if s >= e: continue
147
- if merged and s <= merged[-1][1]:
148
- merged[-1] = (merged[-1][0], max(merged[-1][1], e))
149
- else:
150
- merged.append([s, e])
151
- return [(s, e) for s, e in merged]
152
-
153
- # ============================================================
154
- # ポストフィルタリング
155
- # ============================================================
156
-
157
- EQUAL_MERGE_THRESHOLD = 0
158
-
159
- def filter_opcodes(opcodes):
160
- ops = list(opcodes)
161
- n = len(ops)
162
- if n == 0:
163
- return [], []
164
-
165
- is_diff = []
166
- for tag, i1, i2, j1, j2 in ops:
167
- if tag == 'equal':
168
- is_diff.append((i2 - i1) <= EQUAL_MERGE_THRESHOLD)
169
- else:
170
- is_diff.append(True)
171
-
172
- # 両端のequal区間はdiff扱いにしない
173
- for idx in range(n):
174
- if ops[idx][0] == 'equal':
175
- is_diff[idx] = False
176
- else:
177
- break
178
- for idx in range(n-1, -1, -1):
179
- if ops[idx][0] == 'equal':
180
- is_diff[idx] = False
181
- else:
182
- break
183
-
184
- result_src = []
185
- result_dst = []
186
-
187
- i = 0
188
- while i < n:
189
- if is_diff[i]:
190
- j = i + 1
191
- while j < n and is_diff[j]:
192
- j += 1
193
- # この範囲内でreplace/delete/insertの文字位置のみ収集
194
- for k in range(i, j):
195
- tag, i1, i2, j1, j2 = ops[k]
196
- if tag in ('replace', 'delete'):
197
- for pos in range(i1, i2):
198
- result_src.append(pos)
199
- if tag in ('replace', 'insert'):
200
- for pos in range(j1, j2):
201
- result_dst.append(pos)
202
- i = j
203
- else:
204
- i += 1
205
-
206
- return result_src, result_dst
207
-
208
- # ============================================================
209
- # 比較コア
210
- # ============================================================
211
-
212
- def compare_texts(src, dst, settings):
213
- src_flags = build_active_flags(src, settings)
214
- dst_flags = build_active_flags(dst, settings)
215
-
216
- src_active, src_map = build_active_text_and_map(src, src_flags)
217
- dst_active, dst_map = build_active_text_and_map(dst, dst_flags)
218
-
219
- color_mode = settings.get('color_mode', 'color1')
220
- if color_mode == 'color1':
221
- hl_style = 'background-color:#ffaaaa;color:#000000;'
222
- elif color_mode == 'color2':
223
- hl_style = 'background-color:#aaaaff;color:#000000;'
224
- else:
225
- hl_style = 'background-color:#000000;color:#ffffff;'
226
-
227
- src_diff_ranges = []
228
- dst_diff_ranges = []
229
-
230
- if src_active != '' or dst_active != '':
231
- matcher = difflib.SequenceMatcher(None, src_active, dst_active, autojunk=False)
232
- opcodes = matcher.get_opcodes()
233
- result_src, result_dst = filter_opcodes(opcodes)
234
-
235
- for pos in result_src:
236
- orig = src_map[pos]
237
- src_diff_ranges.append((orig, orig + 1))
238
-
239
- for pos in result_dst:
240
- orig = dst_map[pos]
241
- dst_diff_ranges.append((orig, orig + 1))
242
-
243
- has_diff = bool(src_diff_ranges or dst_diff_ranges)
244
-
245
- def build_html(text, highlight_ranges):
246
- merged = merge_ranges(highlight_ranges)
247
- if not merged:
248
- return '<span>' + html.escape(text).replace('\n', '<br>') + '</span>'
249
- parts = []
250
- prev = 0
251
- for s, e in merged:
252
- s = max(s, prev)
253
- e = min(e, len(text))
254
- if s >= e: continue
255
- if prev < s:
256
- parts.append('<span>' + html.escape(text[prev:s]).replace('\n', '<br>') + '</span>')
257
- parts.append(
258
- f'<span style="{hl_style}">'
259
- + html.escape(text[s:e]).replace('\n', '<br>')
260
- + '</span>'
261
- )
262
- prev = e
263
- if prev < len(text):
264
- parts.append('<span>' + html.escape(text[prev:]).replace('\n', '<br>') + '</span>')
265
- return ''.join(parts)
266
-
267
- src_html = build_html(src, merge_ranges(src_diff_ranges))
268
- dst_html = build_html(dst, merge_ranges(dst_diff_ranges))
269
-
270
- return src_html, dst_html, has_diff
271
-
272
- # ============================================================
273
- # ヘルパー
274
- # ============================================================
275
-
276
- def extract_highlighted(text, html_result):
277
- pattern = re.compile(r'<span style="[^"]*">(.+?)</span>', re.DOTALL)
278
- results = []
279
- for m in pattern.finditer(html_result):
280
- inner = m.group(1).replace('<br>', '\n')
281
- results.append(inner)
282
- return results
283
-
284
- def run_test(name, src, dst, settings, expect_src_hl, expect_dst_hl, expect_has_diff):
285
- src_html, dst_html, has_diff = compare_texts(src, dst, settings)
286
- src_hl = extract_highlighted(src, src_html)
287
- dst_hl = extract_highlighted(dst, dst_html)
288
-
289
- ok_src = (expect_src_hl is None or src_hl == expect_src_hl)
290
- ok_dst = (expect_dst_hl is None or dst_hl == expect_dst_hl)
291
- ok_diff = (has_diff == expect_has_diff)
292
- ok_all = ok_src and ok_dst and ok_diff
293
-
294
- status = "✅ PASS" if ok_all else "❌ FAIL"
295
- print(f"{status} {name}")
296
- if not ok_src:
297
- print(f" [src_hl ] 期待: {expect_src_hl}")
298
- print(f" [src_hl ] 実際: {src_hl}")
299
- if not ok_dst:
300
- print(f" [dst_hl ] 期待: {expect_dst_hl}")
301
- print(f" [dst_hl ] 実際: {dst_hl}")
302
- if not ok_diff:
303
- print(f" [has_diff] 期待: {expect_has_diff} / 実際: {has_diff}")
304
-
305
- # ============================================================
306
- # テストデータ
307
- # ============================================================
308
-
309
- OFF = {}
310
- ALL_ON = {
311
- 'ignore_alpha': True, 'ignore_digit': True, 'ignore_kana': True,
312
- 'ignore_yakumono': True, 'ignore_ligature': True, 'ignore_kangxi': True,
313
- 'ignore_brackets_with_content': True, 'ignore_kanjinum': True,
314
- 'ignore_hyphen': True, 'ignore_kumimoji': True,
315
- 'ignore_space': True, 'ignore_newline': True,
316
  }
317
 
318
- SRC25 = (
319
- "第1条(契約期間)本契約の有効期間は、2024年4月1日[開始日]からAプラン月額¥5,000\u2010を適用する。"
320
- "担当はアイウ部門とし、\uFB01gure\u2F00の基準、組み文字\u336B\u2460\u339Dによる。費用 区切り\n"
321
- "第2条〔備考〕詳細は\u4E00\u30002024年12月31日までに別途定める。\uFB03\u2F05\uFF71\uFF72\uFF73\u2011\u2014\n"
322
- "第3条 担当者はABC123とする。"
323
- )
324
-
325
- DST25 = (
326
- "第1条[契約期間]本契約の有効期間は、2025年5月1日からBプラン月額¥8,000+を適用する。"
327
- "担当はカキク部門とし、\uFB02oor\u2F01の基準、組み文字\u336C\u2461\u339Eによる。費用 区切り\n"
328
- "第2条〔備考〕詳細は\u4E8C\u4E09\u30002026年1月1日までに別途定める。\uFF74\uFF75\uFF76\u2012\u2F16\uFB04\n"
329
- "第4条 担当者はXYZ456とする。"
330
- )
331
-
332
- # ============================================================
333
- # テストケース
334
- # ============================================================
335
-
336
- print("=" * 60)
337
- print("バグ2 検証スクリプト ver8")
338
- print("=" * 60)
339
-
340
- # ---- 全設定オフ ----
341
- run_test("TEST-01 相違なし",
342
- "吾輩は猫である。名前はまだない。",
343
- "吾輩は猫である。名前はまだない。",
344
- OFF, [], [], False)
345
-
346
- run_test("TEST-02 相違あり基本",
347
- "吾輩は猫である。", "吾輩は犬である。",
348
- OFF, ["猫"], ["犬"], True)
349
-
350
- run_test("TEST-03 比較先に追加",
351
- "abcdef", "abcXYZdef",
352
- OFF, [], ["XYZ"], True)
353
-
354
- run_test("TEST-04 比較元に削除",
355
- "abcXYZdef", "abcdef",
356
- OFF, ["XYZ"], [], True)
357
-
358
- run_test("TEST-27 空文字",
359
- "", "", OFF, [], [], False)
360
-
361
- run_test("TEST-28 片方空文字",
362
- "abcdef", "", OFF, ["abcdef"], [], True)
363
-
364
- run_test("TEST-29 改行のみ相違 設定オフ",
365
- "\n\n", "\n", OFF, ["\n"], [], True)
366
-
367
- run_test("TEST-30 スペースのみ相違 設定オフ",
368
- "abc def", "abcdef",
369
- OFF, [" "], [], True)
370
-
371
- run_test("TEST-35 比較元削除箇所ハイライト",
372
- "abcXYZdef", "abcdef",
373
- OFF, ["XYZ"], [], True)
374
-
375
- # ---- 約物無視 ----
376
- run_test("TEST-08 約物無視 設定オン",
377
- "吾輩は、猫である。「名前」は(まだ)ない!",
378
- "吾輩は猫である名前はまだない",
379
- {'ignore_yakumono': True}, [], [], False)
380
-
381
- run_test("TEST-08 約物無視 設定オフ",
382
- "吾輩は、猫である。「名前」は(まだ)ない!",
383
- "吾輩は猫である名前はまだない",
384
- OFF,
385
- ["、", "。「", "」", "(", ")", "!"],
386
- [], True)
387
-
388
- run_test("TEST-09 約物無視 設定オン",
389
- "(abc)テスト", "abcテスト",
390
- {'ignore_yakumono': True}, [], [], False)
391
-
392
- run_test("TEST-09 英字無視 設定オン",
393
- "(abc)テスト", "abcテスト",
394
- {'ignore_alpha': True},
395
- ["(", ")"], [], True)
396
-
397
- run_test("TEST-16 約物無視 設定オン",
398
- "〔brackets〕《brackets》〈brackets〉【brackets】[brackets]「brackets」『brackets』",
399
- "テスト確認",
400
- {'ignore_yakumono': True},
401
- ["brackets", "brackets", "brackets", "brackets", "brackets", "brackets", "brackets"],
402
- ["テスト確認"],
403
- True)
404
-
405
- # ---- 括弧付き文字無視 ----
406
- run_test("TEST-12 括弧付き文字無視 設定オン",
407
- "���スト(abc)文字列です", "テスト文字列です",
408
- {'ignore_brackets_with_content': True}, [], [], False)
409
-
410
- run_test("TEST-12 英字無視 設定オン",
411
- "テスト(abc)文字列です", "テスト文字列です",
412
- {'ignore_alpha': True},
413
- ["(", ")"], [], True)
414
-
415
- run_test("TEST-13 括弧付き文字無視 全角 設定オン",
416
- "テスト(abc)文字列です", "テスト文字列です",
417
- {'ignore_brackets_with_content': True}, [], [], False)
418
-
419
- run_test("TEST-13 英字無視 設定オン",
420
- "テスト(abc)文字列です", "テスト文字列です",
421
- {'ignore_alpha': True},
422
- ["(", ")"], [], True)
423
-
424
- run_test("TEST-14 括弧付き文字無視 入れ子 設定オン",
425
- "テスト(abc(def)ghi)文字列です", "テスト文字列です",
426
- {'ignore_brackets_with_content': True}, [], [], False)
427
-
428
- run_test("TEST-14 英字無視 設定オン",
429
- "テスト(abc(def)ghi)文字列です", "テスト文字列です",
430
- {'ignore_alpha': True},
431
- ["(", "(", ")", ")"], [], True)
432
-
433
- # ---- ハイフン無視 ----
434
- run_test("TEST-19 ハイフン無視 設定オン",
435
- "abc-def\uFF0Dghi\u2010jkl\u2011mno\u2013pqr\u2014stu",
436
- "abcdefghijklmnopqrstu",
437
- {'ignore_hyphen': True}, [], [], False)
438
-
439
- run_test("TEST-19 ハイフン無視 設定オフ",
440
- "abc-def\uFF0Dghi\u2010jkl\u2011mno\u2013pqr\u2014stu",
441
- "abcdefghijklmnopqrstu",
442
- OFF,
443
- ["-", "\uFF0D", "\u2010", "\u2011", "\u2013", "\u2014"],
444
- [], True)
445
-
446
- run_test("TEST-37 ハイフン全種 設定オン",
447
- "abc-def\uFF0Dghi\u2010jkl\u2011mno\u2013pqr\u2014stu",
448
- "abcdefghijklmnopqrstu",
449
- {'ignore_hyphen': True}, [], [], False)
450
-
451
- run_test("TEST-37 ハイフン全種 設定オフ",
452
- "abc-def\uFF0Dghi\u2010jkl\u2011mno\u2013pqr\u2014stu",
453
- "abcdefghijklmnopqrstu",
454
- OFF,
455
- ["-", "\uFF0D", "\u2010", "\u2011", "\u2013", "\u2014"],
456
- [], True)
457
-
458
- # ---- スペース・改行 ----
459
- run_test("TEST-21 スペース削除 設定オン",
460
- "abc def ghi", "abcdefghi",
461
- {'ignore_space': True}, [], [], False)
462
-
463
- run_test("TEST-21 スペース削除 設定オフ",
464
- "abc def ghi", "abcdefghi",
465
- OFF, [" ", " "], [], True)
466
-
467
- run_test("TEST-21 英字無視 設定オン",
468
- "abc def ghi", "abcdefghi",
469
- {'ignore_alpha': True},
470
- [" ", " "], [], True)
471
-
472
- run_test("TEST-22 改行削除 設定オン",
473
- "abc\ndef\nghi", "abcdefghi",
474
- {'ignore_newline': True}, [], [], False)
475
-
476
- run_test("TEST-22 改行削除 設定オフ",
477
- "abc\ndef\nghi", "abcdefghi",
478
- OFF, ["\n", "\n"], [], True)
479
-
480
- run_test("TEST-22 英字無視 設定オン",
481
- "abc\ndef\nghi", "abcdefghi",
482
- {'ignore_alpha': True},
483
- ["\n", "\n"], [], True)
484
-
485
- # ---- 約物と括弧付き文字両方 ----
486
- run_test("TEST-23 約物と括弧付き文字両方 設定オン",
487
- "吾輩は(猫)である。名前は【まだ】ない!",
488
- "吾輩はである。名前はない!",
489
- {'ignore_yakumono': True, 'ignore_brackets_with_content': True},
490
- [], [], False)
491
-
492
- # ---- 空括弧 ----
493
- run_test("TEST-39 カナ無視 設定オン",
494
- "()テスト()文字", "テスト文字",
495
- {'ignore_kana': True},
496
- ["(", ")", "(", ")"], [], True)
497
-
498
- run_test("TEST-39 約物無視 設定オン",
499
- "()テスト()文字", "テスト文字",
500
- {'ignore_yakumono': True}, [], [], False)
501
-
502
- # ---- TEST-25 ----
503
- print()
504
- print("--- TEST-25 ---")
505
-
506
- run_test("TEST-25 全設定OFF",
507
- SRC25, DST25, OFF, None, None, True)
508
-
509
- run_test("TEST-25 全設定ON",
510
- SRC25, DST25, ALL_ON, [], [], False)
511
-
512
- run_test("TEST-25 英字のみON",
513
- SRC25, DST25, {'ignore_alpha': True}, None, None, True)
514
-
515
- run_test("TEST-25 数字のみON",
516
- SRC25, DST25, {'ignore_digit': True}, None, None, True)
517
-
518
- run_test("TEST-25 カナのみON",
519
- SRC25, DST25, {'ignore_kana': True}, None, None, True)
520
-
521
- run_test("TEST-25 約物のみON",
522
- SRC25, DST25, {'ignore_yakumono': True}, None, None, True)
523
-
524
- run_test("TEST-25 括弧付き文字のみON",
525
- SRC25, DST25, {'ignore_brackets_with_content': True}, None, None, True)
526
-
527
- run_test("TEST-25 漢数字のみON",
528
- SRC25, DST25, {'ignore_kanjinum': True}, None, None, True)
529
-
530
- run_test("TEST-25 ハイフンのみON",
531
- SRC25, DST25, {'ignore_hyphen': True}, None, None, True)
532
-
533
- run_test("TEST-25 組み文字のみON",
534
- SRC25, DST25, {'ignore_kumimoji': True}, None, None, True)
535
-
536
- run_test("TEST-25 リガチャのみON",
537
- SRC25, DST25, {'ignore_ligature': True}, None, None, True)
538
-
539
- run_test("TEST-25 康煕部首のみON",
540
- SRC25, DST25, {'ignore_kangxi': True}, None, None, True)
541
-
542
- run_test("TEST-25 スペースのみON",
543
- SRC25, DST25, {'ignore_space': True}, None, None, True)
544
-
545
- run_test("TEST-25 改行のみON",
546
- SRC25, DST25, {'ignore_newline': True}, None, None, True)
547
-
548
- print("=" * 60)
549
- print("完了")
 
1
+ # verify_highlight_annotation.py
2
+ # 実行: python verify_highlight_annotation.py
3
+ # 出力: output_highlighted.pdf (カレントディレクトリ)
4
+ # 確認: Adobe Acrobat等で開き、ハイライトが「注釈」として認識されるか確認
5
+
6
+ import io
7
+ import os
8
+ from reportlab.lib.pagesizes import A4
9
+ from reportlab.pdfgen import canvas as rl_canvas
10
+ from reportlab.pdfbase import pdfmetrics
11
+ from reportlab.pdfbase.ttfonts import TTFont
12
+ import fitz # PyMuPDF
13
+
14
+ # =====================
15
+ # 設定
16
+ # =====================
17
+ FONT_NAME = "NotoSansJP"
18
+ FONT_FILE = "NotoSansJP-Regular.ttf" # カレントディレクトリに配置すること
19
+ OUTPUT_FILE = "output_highlighted.pdf"
20
+
21
+ # ハイライト色 (RGB 0.0-1.0)
22
+ COLORS = {
23
+ "color1": (1.0, 0.6, 0.6), # 赤系
24
+ "color2": (0.6, 0.8, 1.0), # 青系
25
+ "mono": (0.5, 0.5, 0.5), # グレー(モノクロ代替)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
+ # =====================
29
+ # Step1: reportlabでベースPDF生成
30
+ # =====================
31
+ def build_base_pdf(font_file: str) -> bytes:
32
+ """テスト用テキストを含むPDF���reportlabで生成してbytesで返す"""
33
+
34
+ pdfmetrics.registerFont(TTFont(FONT_NAME, font_file))
35
+
36
+ buffer = io.BytesIO()
37
+ PAGE_W, PAGE_H = A4
38
+ MARGIN = 20
39
+ c = rl_canvas.Canvas(buffer, pagesize=A4)
40
+ c.setFont(FONT_NAME, 12)
41
+
42
+ # テスト文字列(差分あり想定)
43
+ lines = [
44
+ "吾輩は猫である。名前はまだない。", # 行1: 差分なし想定
45
+ "吾輩は犬である。名前はもうある。", # 行2: 差分あり想定(「犬」「もうある」)
46
+ "abcXYZdef", # 行3: 差分あり(「XYZ」)
47
+ "テスト スペース あり", # 行4: 差分なし想定
48
+ ]
49
+
50
+ y = PAGE_H - MARGIN - 20
51
+ for line in lines:
52
+ c.drawString(MARGIN, y, line)
53
+ y -= 20
54
+
55
+ c.save()
56
+ return buffer.getvalue()
57
+
58
+
59
+ # =====================
60
+ # Step2: PyMuPDFでHighlight Annotation付与
61
+ # =====================
62
+ def add_highlight_annotations(pdf_bytes: bytes, color_mode: str = "color1") -> bytes:
63
+ """
64
+ PyMuPDFを使い、指定テキストにHighlight Annotationを付与する。
65
+ テキスト検索 → quads取得 → add_highlight_annot の流れを検証。
66
+ """
67
+ hl_color = COLORS.get(color_mode, COLORS["color1"])
68
+
69
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
70
+
71
+ # 検証用: ハイライトしたいテキストのリスト
72
+ highlight_targets = [
73
+ "犬",
74
+ "もうある",
75
+ "XYZ",
76
+ ]
77
+
78
+ page = doc[0] # 1ページ目
79
+
80
+ for target in highlight_targets:
81
+ # テキスト検索でquadsを取得
82
+ areas = page.search_for(target, quads=True)
83
+ if not areas:
84
+ print(f"[WARNING] テキストが見つかりません: '{target}'")
85
+ continue
86
+
87
+ for quad in areas:
88
+ annot = page.add_highlight_annot(quad)
89
+ annot.set_colors(stroke=hl_color)
90
+ annot.update()
91
+ print(f"[OK] ハイライト注釈追加: '{target}' at {quad}")
92
+
93
+ result = io.BytesIO()
94
+ doc.save(result)
95
+ doc.close()
96
+ return result.getvalue()
97
+
98
+
99
+ # =====================
100
+ # Step3: 文字座標から直接Annotationを付与する方式の検証
101
+ # =====================
102
+ def add_highlight_by_position(pdf_bytes: bytes, color_mode: str = "color1") -> bytes:
103
+ """
104
+ reportlabで描画した文字位置を座標で指定してHighlight Annotationを付与。
105
+ (テキスト検索不要の方式 - 本番実装候補)
106
+ reportlabのy座標(下原点) → PyMuPDFのy座標(上原点)変換を検証。
107
+ """
108
+ hl_color = COLORS.get(color_mode, COLORS["color1"])
109
+ PAGE_W, PAGE_H = A4 # (595.27, 841.89)
110
+ MARGIN = 20
111
+ FONT_SIZE = 12
112
+ LINE_HEIGHT = 20
113
+
114
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
115
+ page = doc[0]
116
+
117
+ # reportlabで描画した行2の「犬」の座標を手動計算
118
+ # reportlab: y = PAGE_H - MARGIN - 20 - LINE_HEIGHT * 1 = 841.89 - 20 - 20 - 20 = 781.89
119
+ # PyMuPDF: y_mupdf = PAGE_H - y_rl = 841.89 - 781.89 = 60.0 ← ベースライン
120
+ # Highlight用rect: x0, y0(上端), x1, y1(下端)
121
+
122
+ # 行インデックス1(0始まり)= 「吾輩は犬である...」行
123
+ line_idx = 1
124
+ y_rl = PAGE_H - MARGIN - 20 - LINE_HEIGHT * line_idx # reportlab y(ベースライン)
125
+ y_mupdf_base = PAGE_H - y_rl # PyMuPDF y(上原点換算)
126
+
127
+ # 「犬」は「吾輩は」(3文字)の後 → x方向はフォント幅が必要だが近似で指定
128
+ # 実際はreportlab側でstringWidthを使って正確に計算する
129
+ x0 = MARGIN + 3 * FONT_SIZE * 0.9 # 近似(全角文字)
130
+ x1 = x0 + FONT_SIZE
131
+
132
+ rect = fitz.Rect(x0, y_mupdf_base - FONT_SIZE, x1, y_mupdf_base + 2)
133
+ quad = rect.quad
134
+
135
+ annot = page.add_highlight_annot(quad)
136
+ annot.set_colors(stroke=hl_color)
137
+ annot.update()
138
+ print(f"[OK] 座標指定ハイライト注釈追加: rect={rect}")
139
+
140
+ result = io.BytesIO()
141
+ doc.save(result)
142
+ doc.close()
143
+ return result.getvalue()
144
+
145
+
146
+ # =====================
147
+ # メイン
148
+ # =====================
149
+ if __name__ == "__main__":
150
+ # フォントファイル確認
151
+ if not os.path.isfile(FONT_FILE):
152
+ print(f"[ERROR] フォントファイルが見つかりません: {FONT_FILE}")
153
+ print("カレントディレクトリに NotoSansJP-Regular.ttf を配置してください。")
154
+ exit(1)
155
+
156
+ print("=== Step1: reportlabでベースPDF生成 ===")
157
+ base_pdf = build_base_pdf(FONT_FILE)
158
+ print(f"[OK] ベースPDF生成完了 ({len(base_pdf)} bytes)")
159
+
160
+ print("\n=== Step2: テキスト検索によるHighlight Annotation ===")
161
+ highlighted_pdf = add_highlight_annotations(base_pdf, color_mode="color1")
162
+ print(f"[OK] Annotation付与完了 ({len(highlighted_pdf)} bytes)")
163
+
164
+ print("\n=== Step3: 座標指定によるHighlight Annotation ===")
165
+ highlighted_pdf2 = add_highlight_by_position(highlighted_pdf, color_mode="color1")
166
+ print(f"[OK] 座標指定Annotation付与完了 ({len(highlighted_pdf2)} bytes)")
167
+
168
+ with open(OUTPUT_FILE, "wb") as f:
169
+ f.write(highlighted_pdf2)
170
+ print(f"\n=== 出力完了: {OUTPUT_FILE} ===")
171
+ print("Adobe Acrobat等で開き、以下を確認してください:")
172
+ print(" 1. 「犬」「もうある」「XYZ」がハイライト注釈として表示される")
173
+ print(" 2. 注釈パネルでHighlight Annotationとして認識されている")
174
+ print(" 3. 座標指定のハイライトも正しい位置に表示される")