vivlio-starter-pdf 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "tmpdir"
7
+ require "yaml"
8
+
9
+ module Vivlio
10
+ module Starter
11
+ module PDF
12
+ # HexaPDF ベースの高精度 PDF リーダー
13
+ #
14
+ # テキスト座標解析・画像抽出・OCR 連携・イラスト領域自動検出を統合し、
15
+ # PDF → Markdown 変換パイプラインを提供する。
16
+ class Reader
17
+
18
+ # --- Data 構造体 ---
19
+ # テキスト断片(座標付き)
20
+ Fragment = Data.define(:x, :right, :y, :text)
21
+ # 行(Y 座標 + テキスト)
22
+ Line = Data.define(:y, :text)
23
+ # PDF 内画像の出現位置(座標・サイズ・XObject 参照)
24
+ ImageOccurrence = Data.define(:x, :top, :bottom, :center_y, :left, :right, :width, :height, :object)
25
+ # ページのテキスト・行・画像出現をまとめたコンテンツ
26
+ PageContent = Data.define(:text, :lines, :image_occurrences)
27
+ # 抽出済み画像アセット(ページ番号・ファイル名・座標情報を保持)
28
+ ImageAsset = Data.define(:page, :index, :filename, :output_path, :reference_path, :x, :top, :bottom, :center_y, :left, :right, :width, :height)
29
+ # OCR で検出したイラスト領域(ピクセル座標)
30
+ IllustrationRegion = Data.define(:left, :top, :width, :height)
31
+ # OCR 実行パラメータ
32
+ OcrSettings = Data.define(:mode, :languages, :dpi, :psm, :inline_image_text)
33
+ # OCR ページ画像からの切り出し領域
34
+ RenderedPageCrop = Data.define(:source_image_path, :left, :top, :width, :height)
35
+ # ページ解析の最終結果(コンテンツ + OCR 適用有無 + 一時ディレクトリ)
36
+ ResolvedPage = Data.define(:content, :ocr_applied, :ocr_temp_dir)
37
+ # OCR 実行結果(テキスト・行・ブロック・画像情報を保持)
38
+ OcrResult = Data.define(:text, :lines, :blocks, :source_image_path, :image_width, :image_height, :temp_dir)
39
+
40
+ # --- イラスト検出用定数 ---
41
+ # イラスト領域とみなす最小面積比
42
+ MIN_ILLUSTRATION_AREA_RATIO = 0.02
43
+ # テキスト領域と区別するアスペクト比上限
44
+ ILLUSTRATION_TEXT_ASPECT_MAX = 6.0
45
+ # 前景検出の輝度閾値(この値未満のピクセルを前景とする)
46
+ FOREGROUND_THRESHOLD = 210
47
+ # 行プロファイル閾値の標準偏差スケール係数
48
+ ROW_ACTIVITY_STDDEV_SCALE = 0.5
49
+ # 列単位の前景活性度閾値
50
+ COLUMN_ACTIVITY_THRESHOLD = 0.05
51
+ # ガウシアン平滑化のシグマ比率(画像高さに対する割合)
52
+ PROFILE_SMOOTHING_SIGMA_RATIO = 0.012
53
+ # シグマの下限・上限
54
+ MIN_PROFILE_SMOOTHING_SIGMA = 3
55
+ MAX_PROFILE_SMOOTHING_SIGMA = 25
56
+
57
+ # HexaPDF のコンテンツストリームを走査し、版面内のテキスト断片と画像出現を収集する
58
+ class PageTextCollector < HexaPDF::Content::Processor
59
+ attr_reader :fragments, :image_occurrences
60
+
61
+ # @param resources [HexaPDF::Type::Resources] ページリソース
62
+ # @param bounds [Hash, nil] テキスト抽出領域の座標境界
63
+ # @param line_merge_tolerance [Float] 同一行とみなす Y 座標差の閾値(pt)
64
+ def initialize(resources, bounds:, line_merge_tolerance:)
65
+ super(resources)
66
+ @bounds = bounds
67
+ @line_merge_tolerance = line_merge_tolerance.to_f
68
+ @fragments = []
69
+ @image_occurrences = []
70
+ end
71
+
72
+ # PDF オペレータ Tj: テキスト表示
73
+ def show_text(data)
74
+ collect_text_box(decode_text_with_positioning(data))
75
+ end
76
+
77
+ # PDF オペレータ TJ: 位置調整付きテキスト表示
78
+ def show_text_with_positioning(data)
79
+ collect_text_box(decode_text_with_positioning(data))
80
+ end
81
+
82
+ # 収集した断片を Y 座標でグループ化し、Line 配列を返す
83
+ def lines
84
+ build_lines
85
+ end
86
+
87
+ # 全行を改行で結合したテキストを返す
88
+ def text
89
+ lines.map(&:text).join("\n")
90
+ end
91
+
92
+ # PDF オペレータ Do: XObject 描画。画像なら出現位置を記録する
93
+ def paint_xobject(name)
94
+ xobject = resources.xobject(name)
95
+ collect_image_occurrence(xobject) if image_object?(xobject)
96
+ super
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ # 断片を Y 降順・X 昇順でソートし、同一 Y の断片を結合して Line 配列を構築する
104
+ def build_lines
105
+ sorted = fragments.sort_by { [-it.y, it.x] }
106
+ current_y = nil
107
+ buffer = +""
108
+ lines = []
109
+ previous_fragment = nil
110
+
111
+ sorted.each do |fragment|
112
+ if current_y && (current_y - fragment.y).abs <= @line_merge_tolerance
113
+ buffer << separator_between(previous_fragment, fragment)
114
+ buffer << fragment.text
115
+ previous_fragment = fragment
116
+ next
117
+ end
118
+
119
+ lines << Line.new(y: current_y, text: normalize_line(buffer)) unless buffer.empty?
120
+ buffer = +fragment.text
121
+ current_y = fragment.y
122
+ previous_fragment = fragment
123
+ end
124
+
125
+ lines << Line.new(y: current_y, text: normalize_line(buffer)) unless buffer.empty?
126
+ lines.reject { it.text.empty? }
127
+ end
128
+
129
+ # テキストボックスが版面内なら Fragment として記録する
130
+ def collect_text_box(box)
131
+ return unless within_bounds?(box)
132
+
133
+ text = normalize_fragment_text(box.string)
134
+ return if text.empty?
135
+
136
+ x, y = box.lower_left
137
+ right, = box.lower_right
138
+ @fragments << Fragment.new(x:, right:, y:, text:)
139
+ end
140
+
141
+ # XObject の CTM からページ座標を算出し、ImageOccurrence として記録する
142
+ def collect_image_occurrence(xobject)
143
+ llx, lly = graphics_state.ctm.evaluate(0, 0)
144
+ lrx, lry = graphics_state.ctm.evaluate(1, 0)
145
+ ulx, uly = graphics_state.ctm.evaluate(0, 1)
146
+ urx, ury = graphics_state.ctm.evaluate(1, 1)
147
+
148
+ xs = [llx, lrx, ulx, urx]
149
+ ys = [lly, lry, uly, ury]
150
+ return unless within_points?(xs, ys)
151
+
152
+ top = ys.max
153
+ bottom = ys.min
154
+ left = xs.min
155
+ right = xs.max
156
+ @image_occurrences << ImageOccurrence.new(
157
+ x: xs.sum / xs.length,
158
+ top:,
159
+ bottom:,
160
+ center_y: (top + bottom) / 2.0,
161
+ left:,
162
+ right:,
163
+ width: right - left,
164
+ height: top - bottom,
165
+ object: xobject
166
+ )
167
+ end
168
+
169
+ # テキスト断片の空白を正規化する
170
+ def normalize_fragment_text(text)
171
+ text.to_s.gsub(/[ \t\u00A0]+/, " ").strip
172
+ end
173
+
174
+ # 隣接する断片間の区切り文字を決定する(空白 / 改行 / なし)
175
+ # 断片間の X 座標ギャップから判定する
176
+ def separator_between(previous_fragment, fragment)
177
+ return "" unless previous_fragment
178
+
179
+ gap = fragment.x - previous_fragment.right
180
+ return "" if gap <= 6
181
+ return "\n" if gap >= 24 && strong_break_boundary?(previous_fragment.text, fragment.text)
182
+
183
+ " "
184
+ end
185
+
186
+ # 前後のテキストが強い改行境界(文末句読点・章見出し等)を持つか判定する
187
+ def strong_break_boundary?(previous_text, current_text)
188
+ previous_text.match?(/[。..!!??::]\z/) || current_text.match?(/\A(?:[♣◆■●]+\s*)?(?:第[一二三四五六七八九十百千0-9]+章|\d+(?:[.\-]\d+)*|[0-9]+[.)])/) || current_text.match?(/\A(?:主題|文法|道具)[::]/)
189
+ end
190
+
191
+ # 行テキストの正規化: 空白圧縮・CJK 文字間の不要スペース除去
192
+ def normalize_line(line)
193
+ line
194
+ .to_s
195
+ .gsub(/[ \t\u00A0]+/, " ")
196
+ .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ]) (?=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ])/, "")
197
+ .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ]) (?=[、。,.:;!?)】』」])/, "")
198
+ .gsub(/(?<=[(【『「]) (?=[一-龯ぁ-ゖァ-ヶー々〆ヵヶA-Za-z0-9])/, "")
199
+ .strip
200
+ end
201
+
202
+ # テキストボックスが版面境界内に収まっているか
203
+ def within_bounds?(box)
204
+ return true unless @bounds
205
+
206
+ llx, lly = box.lower_left
207
+ urx, ury = box.upper_right
208
+
209
+ !(urx < @bounds[:left] || llx > @bounds[:right] || ury < @bounds[:bottom] || lly > @bounds[:top])
210
+ end
211
+
212
+ # 座標群が版面境界内に収まっているか
213
+ def within_points?(xs, ys)
214
+ return true unless @bounds
215
+
216
+ !(xs.max < @bounds[:left] || xs.min > @bounds[:right] || ys.max < @bounds[:bottom] || ys.min > @bounds[:top])
217
+ end
218
+
219
+ # XObject が画像オブジェクトかどうかを判定する
220
+ def image_object?(object)
221
+ object.is_a?(HexaPDF::Type::Image) || object[:Subtype] == :Image
222
+ rescue StandardError
223
+ false
224
+ end
225
+ end
226
+
227
+ # @param pdf_path [String] 入力 PDF のパス
228
+ # @param page_separator [Boolean] ページ間に "---" を挿入するか
229
+ # @param text_area [Hash, nil] テキスト抽出領域のマージン(pt 単位)
230
+ # @param line_merge_tolerance [Float] 同一行とみなす Y 座標差の閾値(pt)
231
+ # @param images_dir [String, nil] 画像の保存先ディレクトリ
232
+ # @param image_reference_dir [String, nil] Markdown 内の画像参照パスの基底
233
+ # @param ocr [Hash, nil] OCR 設定
234
+ def initialize(pdf_path, page_separator: true, text_area: nil, line_merge_tolerance: 2.0, images_dir: nil, image_reference_dir: nil, ocr: nil)
235
+ @pdf_path = pdf_path
236
+ @page_separator = page_separator != false
237
+ @text_area = normalize_text_area(text_area)
238
+ @line_merge_tolerance = line_merge_tolerance.to_f
239
+ @images_dir = images_dir&.to_s&.strip
240
+ @image_reference_dir = image_reference_dir&.to_s&.strip
241
+ @ocr = normalize_ocr(ocr)
242
+ end
243
+
244
+ # PDF を解析し、Markdown テキスト・画像アセット・メタデータを含む Hash を返す
245
+ # @return [Hash] :markdown, :page_texts, :page_chunks, :pages, :images
246
+ def execute
247
+ document = HexaPDF::Document.open(pdf_path)
248
+ page_texts = []
249
+ page_chunks = []
250
+ images = []
251
+
252
+ document.pages.each_with_index do |page, index|
253
+ resolution = nil
254
+
255
+ begin
256
+ content = extract_page_content(page, index)
257
+ resolution = resolve_page_content(page, index, content)
258
+ page_images = extract_page_images(page, resolution.content.image_occurrences, index,
259
+ suppress_full_page_scans: resolution.ocr_applied)
260
+ page_lines, image_captions = apply_inline_image_text_policy(resolution.content.lines, page_images)
261
+ page_text = build_page_text(page_lines, resolution.content.text, image_captions)
262
+ page_texts << page_text
263
+ page_chunks << build_page_chunk(page_lines, page_images, page_text, image_captions:)
264
+ images.concat(page_images)
265
+ ensure
266
+ cleanup_ocr_temp_dir(resolution&.ocr_temp_dir)
267
+ end
268
+ end
269
+
270
+ {
271
+ markdown: build_markdown(page_chunks),
272
+ page_texts:,
273
+ page_chunks:,
274
+ pages: document.pages.count,
275
+ images: images.map(&:to_h)
276
+ }
277
+ end
278
+
279
+ private
280
+
281
+ attr_reader :pdf_path
282
+
283
+ # OCR 用の一時ディレクトリを安全に削除する
284
+ def cleanup_ocr_temp_dir(path)
285
+ return if path.to_s.empty?
286
+
287
+ FileUtils.rm_rf(path)
288
+ end
289
+
290
+ # HexaPDF のコンテンツストリームを走査し、ページのテキスト・行・画像出現を抽出する
291
+ def extract_page_content(page, index)
292
+ collector = PageTextCollector.new(
293
+ page.resources,
294
+ bounds: text_area_bounds(page, index),
295
+ line_merge_tolerance:
296
+ )
297
+ page.process_contents(collector)
298
+ PageContent.new(
299
+ text: sanitize(collector.text),
300
+ lines: collector.lines,
301
+ image_occurrences: collector.image_occurrences
302
+ )
303
+ rescue StandardError
304
+ PageContent.new(text: "", lines: [], image_occurrences: [])
305
+ end
306
+
307
+ # ページ内容を確定する。OCR が必要なら実行し、不要ならそのまま返す
308
+ def resolve_page_content(page, index, content)
309
+ return ResolvedPage.new(content:, ocr_applied: false, ocr_temp_dir: nil) unless ocr_required_for_page?(page, content)
310
+
311
+ resolved_content, ocr_temp_dir = extract_page_ocr_content(page, index, content.image_occurrences)
312
+ ResolvedPage.new(content: resolved_content, ocr_applied: true, ocr_temp_dir:)
313
+ rescue StandardError
314
+ raise if ocr_mode == :always
315
+
316
+ ResolvedPage.new(content:, ocr_applied: false, ocr_temp_dir: nil)
317
+ end
318
+
319
+ # OCR を実行してページコンテンツを再構築する
320
+ # テキスト後処理(空白圧縮・括弧正規化・prh 辞書置換)を適用する
321
+ def extract_page_ocr_content(page, index, image_occurrences)
322
+ result = ocr_page_result(page, index)
323
+ lines = normalize_ocr_lines(result.lines)
324
+ text = if lines.empty?
325
+ postprocess_ocr_text(result.text)
326
+ else
327
+ sanitize(lines.map(&:text).join("\n"))
328
+ end
329
+
330
+ resolved_image_occurrences = ocr_image_occurrences(page, image_occurrences, result)
331
+ page_content = if text.empty?
332
+ PageContent.new(text: "", lines: [], image_occurrences: resolved_image_occurrences)
333
+ else
334
+ PageContent.new(text:, lines:, image_occurrences: resolved_image_occurrences)
335
+ end
336
+
337
+ [page_content, result.temp_dir]
338
+ rescue StandardError
339
+ cleanup_ocr_temp_dir(result&.temp_dir)
340
+ raise
341
+ end
342
+
343
+ # このページで OCR が必要かどうかを判定する
344
+ # mode が auto の場合はテキスト品質やスキャン画像の有無で判断する
345
+ def ocr_required_for_page?(page, content)
346
+ return false if ocr_mode == :never
347
+ return ocr_dependencies_ready? if ocr_mode == :always
348
+
349
+ return false unless ocr_dependencies_ready?
350
+ return true if content.text.to_s.strip.empty?
351
+ return true if poor_text_extraction?(content.text)
352
+
353
+ scanned_page_image?(page, content.image_occurrences)
354
+ end
355
+
356
+ # ページ内の画像を WebP 形式で抽出し、ImageAsset の配列として返す
357
+ def extract_page_images(page, image_occurrences, index, suppress_full_page_scans: false)
358
+ return [] if @images_dir.to_s.empty?
359
+
360
+ occurrences = filtered_image_occurrences(page, image_occurrences, suppress_full_page_scans:)
361
+ return [] if occurrences.empty?
362
+
363
+ image_converter
364
+ FileUtils.mkdir_p(@images_dir)
365
+ assets = []
366
+
367
+ occurrences.each_with_index do |occurrence, image_index|
368
+ filename = format("page-%03d-image-%02d.webp", index + 1, image_index + 1)
369
+ output_path = File.join(@images_dir, filename)
370
+ write_image_as_webp(occurrence.object, output_path)
371
+ assets << build_image_asset(index, image_index + 1, filename, output_path, occurrence)
372
+ rescue StandardError
373
+ next
374
+ end
375
+
376
+ assets
377
+ end
378
+
379
+ # テキスト行と画像参照を Y 座標順に統合し、ページチャンク文字列を構築する
380
+ def build_page_chunk(lines, images, fallback_text, image_captions: {})
381
+ fallback = fallback_text.to_s.strip
382
+ return fallback if images.empty?
383
+
384
+ ordered_lines = Array(lines).reject { it.text.to_s.strip.empty? }
385
+ if ordered_lines.empty?
386
+ image_block = image_blocks(Array(images), image_captions, "")
387
+ return [image_block, fallback].reject(&:empty?).join("\n\n")
388
+ end
389
+
390
+ references_by_index = Array(images)
391
+ .sort_by { [-it.center_y.to_f, it.x.to_f] }
392
+ .group_by { image_insertion_index(ordered_lines, it) }
393
+
394
+ blocks = []
395
+
396
+ (0..ordered_lines.length).each do |index|
397
+ Array(references_by_index[index]).each do |image|
398
+ blocks << "![](#{image.reference_path})"
399
+ caption = image_captions[image.reference_path].to_s.strip
400
+ blocks << "> #{caption}" unless caption.empty?
401
+ end
402
+ next if index == ordered_lines.length
403
+
404
+ blocks << ordered_lines[index].text
405
+ end
406
+
407
+ body = blocks.reject(&:empty?).join("\n")
408
+ body.empty? ? fallback : body
409
+ end
410
+
411
+ # 画像の center_y が挿入されるべき行位置のインデックスを返す
412
+ def image_insertion_index(lines, image)
413
+ ordered_lines = Array(lines)
414
+ return 0 if ordered_lines.empty?
415
+
416
+ ordered_lines.take_while { it.y.to_f > image.center_y.to_f }.length
417
+ end
418
+
419
+ # ImageOccurrence から ImageAsset を構築する
420
+ def build_image_asset(page_index, image_index, filename, output_path, occurrence)
421
+ ImageAsset.new(
422
+ page: page_index + 1,
423
+ index: image_index,
424
+ filename:,
425
+ output_path:,
426
+ reference_path: image_reference_path(filename),
427
+ x: occurrence.x,
428
+ top: occurrence.top,
429
+ bottom: occurrence.bottom,
430
+ center_y: occurrence.center_y,
431
+ left: occurrence.left,
432
+ right: occurrence.right,
433
+ width: occurrence.width,
434
+ height: occurrence.height
435
+ )
436
+ end
437
+
438
+ # PDF 内画像オブジェクトを WebP 形式でファイルに書き出す
439
+ # RenderedPageCrop の場合は vips で切り出し、それ以外は ImageMagick で変換
440
+ def write_image_as_webp(object, output_path)
441
+ if object in RenderedPageCrop
442
+ write_cropped_image_as_webp(object, output_path)
443
+ return
444
+ end
445
+
446
+ source_path = output_path.sub(/\.webp\z/, source_image_extension(object))
447
+ object.write(source_path)
448
+
449
+ stdout, status = Open3.capture2e(*image_convert_command(source_path, output_path))
450
+ raise Error, stdout.strip unless status.success?
451
+ ensure
452
+ FileUtils.rm_f(source_path) if source_path
453
+ end
454
+
455
+ # PDF 画像オブジェクトの圧縮フィルタから元画像の拡張子を推定する
456
+ def source_image_extension(object)
457
+ filter = Array(object[:Filter]).compact.last
458
+
459
+ case filter
460
+ in :DCTDecode then ".jpg"
461
+ in :JPXDecode then ".jp2"
462
+ in :JBIG2Decode then ".jb2"
463
+ in :CCITTFaxDecode then ".tif"
464
+ else ".png"
465
+ end
466
+ end
467
+
468
+ # ImageMagick による画像変換コマンドを組み立てる(最大 1600px・品質 85)
469
+ def image_convert_command(source_path, output_path)
470
+ [
471
+ image_converter,
472
+ source_path,
473
+ "-resize", "1600x1600>",
474
+ "-strip",
475
+ "-quality", "85",
476
+ "-define", "webp:method=6",
477
+ output_path
478
+ ]
479
+ end
480
+
481
+ # vips で OCR ページ画像の指定領域を切り出し、WebP で保存する
482
+ def write_cropped_image_as_webp(crop, output_path)
483
+ vips = require_vips!
484
+ image = vips::Image.new_from_file(crop.source_image_path)
485
+ image.crop(crop.left, crop.top, crop.width, crop.height).write_to_file(output_path, Q: 85, strip: true)
486
+ rescue StandardError => e
487
+ raise Error, e.message
488
+ end
489
+
490
+ # ImageMagick の実行コマンド名を検出する(magick または convert)
491
+ def image_converter
492
+ @image_converter ||= if command_in_path?("magick")
493
+ "magick"
494
+ elsif command_in_path?("convert")
495
+ "convert"
496
+ else
497
+ raise Error, "画像を WebP に変換するには ImageMagick (magick または convert) が必要です"
498
+ end
499
+ end
500
+
501
+ # 1 ページ分の OCR を実行し、OcrResult を返す
502
+ # pdftoppm でページ画像を生成し、Tesseract でテキスト認識する
503
+ def ocr_page_result(page, index)
504
+ temp_dir = Dir.mktmpdir("vivlio-pdf-ocr")
505
+ prefix = File.join(temp_dir, "page")
506
+ stdout, status = Open3.capture2e(*ocr_render_command(prefix, index + 1))
507
+ raise Error, stdout.strip unless status.success?
508
+
509
+ source_image_path = Dir.glob("#{prefix}-*.png").sort.first
510
+ raise Error, "OCR 用のページ画像を生成できませんでした" unless source_image_path
511
+
512
+ tsv = capture_ocr_output(*ocr_tsv_command(source_image_path))
513
+ image_width, image_height = png_dimensions(source_image_path)
514
+
515
+ OcrResult.new(
516
+ text: capture_ocr_output(*ocr_text_command(source_image_path)),
517
+ lines: extract_ocr_lines(tsv, page),
518
+ blocks: [],
519
+ source_image_path:,
520
+ image_width:,
521
+ image_height:,
522
+ temp_dir:
523
+ )
524
+ rescue StandardError
525
+ cleanup_ocr_temp_dir(temp_dir)
526
+ raise
527
+ end
528
+
529
+ # pdftoppm によるページ画像レンダリングコマンドを組み立てる
530
+ def ocr_render_command(prefix, page_number)
531
+ [
532
+ "pdftoppm",
533
+ "-f", page_number.to_s,
534
+ "-l", page_number.to_s,
535
+ "-r", @ocr.dpi.to_s,
536
+ "-png",
537
+ pdf_path,
538
+ prefix
539
+ ]
540
+ end
541
+
542
+ # Tesseract によるプレーンテキスト出力コマンドを組み立てる
543
+ def ocr_text_command(image_path)
544
+ [
545
+ "tesseract",
546
+ image_path,
547
+ "stdout",
548
+ "-l", @ocr.languages.join("+"),
549
+ "--psm", @ocr.psm.to_s
550
+ ]
551
+ end
552
+
553
+ # Tesseract による TSV 出力コマンドを組み立てる(座標情報取得用)
554
+ def ocr_tsv_command(image_path)
555
+ ocr_text_command(image_path) + ["tsv"]
556
+ end
557
+
558
+ # 外部コマンドを実行し、stdout を返す。失敗時は Error を送出する
559
+ def capture_ocr_output(*command)
560
+ stdout, stderr, status = Open3.capture3(*command)
561
+ raise Error, [stderr, stdout].find { !it.to_s.strip.empty? }.to_s.strip unless status.success?
562
+
563
+ stdout
564
+ end
565
+
566
+ # Tesseract TSV 出力を解析し、行単位の Line 配列を構築する
567
+ # TSV のワード座標を PDF ページ座標に変換して Y 位置を算出する
568
+ def extract_ocr_lines(tsv, page)
569
+ rows = tsv.to_s.lines.map { it.rstrip }
570
+ return [] if rows.empty?
571
+
572
+ header = rows.shift.to_s.split("\t")
573
+ index = header.each_with_index.to_h
574
+ box = media_box(page)
575
+ return [] unless box
576
+
577
+ page_top = box[3]
578
+ page_height = page_top - box[1]
579
+ return [] unless page_height.positive?
580
+
581
+ image_height = 0.0
582
+ grouped = {}
583
+
584
+ rows.each do |row|
585
+ columns = row.split("\t", -1)
586
+ next if columns.empty?
587
+
588
+ columns.fill("", columns.length...header.length) if columns.length < header.length
589
+
590
+ level = columns[index.fetch("level")].to_i
591
+ image_height = columns[index.fetch("height")].to_f if level == 1
592
+ next unless level == 5
593
+
594
+ text = columns[index.fetch("text")].to_s.strip
595
+ next if text.empty?
596
+
597
+ left = columns[index.fetch("left")].to_f
598
+ top = columns[index.fetch("top")].to_f
599
+ height = columns[index.fetch("height")].to_f
600
+ key = %w[page_num block_num par_num line_num].map { columns[index.fetch(it)] }
601
+ entry = grouped[key] ||= { left:, top:, bottom: top + height, texts: [] }
602
+ entry[:left] = [entry[:left], left].min
603
+ entry[:top] = [entry[:top], top].min
604
+ entry[:bottom] = [entry[:bottom], top + height].max
605
+ entry[:texts] << text
606
+ end
607
+
608
+ return [] unless image_height.positive?
609
+
610
+ grouped.values.map do |entry|
611
+ center_y = (entry[:top] + entry[:bottom]) / 2.0
612
+ y = page_top - ((center_y / image_height) * page_height)
613
+ Line.new(y:, text: entry[:texts].join(" "))
614
+ end.sort_by { [-it.y.to_f, it.text] }
615
+ rescue KeyError
616
+ []
617
+ end
618
+
619
+ # PNG ファイルの幅と高さをヘッダから読み取る
620
+ def png_dimensions(path)
621
+ header = File.binread(path, 24)
622
+ raise Error, "OCR 用ページ画像のサイズを取得できませんでした" unless header.bytesize >= 24
623
+ raise Error, "OCR 用ページ画像が PNG ではありません" unless header.start_with?("\x89PNG\r\n\x1A\n".b)
624
+
625
+ header.byteslice(16, 8).unpack("N2")
626
+ end
627
+
628
+ # OCR 結果からイラスト領域を検出し、既存の画像出現と統合する
629
+ # スキャンページ画像は除外し、vips ベースのイラスト検出結果を追加する
630
+ def ocr_image_occurrences(page, image_occurrences, result)
631
+ inline_occurrences = Array(image_occurrences).reject { scanned_page_image_occurrence?(page, it) }
632
+ return inline_occurrences unless scanned_page_image?(page, image_occurrences) || inline_occurrences.empty?
633
+
634
+ inline_occurrences + extract_illustration_regions_vips(result.source_image_path).map { build_ocr_image_occurrence(page, it, result) }
635
+ rescue StandardError
636
+ inline_occurrences
637
+ end
638
+
639
+ # vips を使って OCR ページ画像からイラスト領域を自動検出する
640
+ # 前景マスク → 行プロファイル → ガウシアン平滑化 → 領域分割 の流れで処理する
641
+ def extract_illustration_regions_vips(source_image_path)
642
+ vips = require_vips!
643
+ image = vips::Image.new_from_file(source_image_path)
644
+ mask = vips_foreground_mask(image)
645
+ row_profile = smooth_profile(build_row_profile(mask), sigma: profile_smoothing_sigma(image.height))
646
+ stats = profile_statistics(row_profile)
647
+ threshold = stats.fetch(:average) + (stats.fetch(:stddev) * ROW_ACTIVITY_STDDEV_SCALE)
648
+
649
+ regions = find_regions_from_profile(
650
+ row_profile,
651
+ image.height,
652
+ threshold:,
653
+ max_gap: [20, (image.height * 0.006).round].max,
654
+ min_height: [100, (image.height * 0.03).round].max
655
+ )
656
+
657
+ regions.filter_map { build_foreground_region(mask, it, image.width, image.height) }
658
+ end
659
+
660
+ # 画像をグレースケール化し、前景ピクセルの二値マスクを生成する
661
+ def vips_foreground_mask(image)
662
+ gray = image.colourspace("b-w")
663
+ gray
664
+ .relational_const(:less, [FOREGROUND_THRESHOLD])
665
+ .cast(:uchar)
666
+ .linear([255.0], [0])
667
+ .cast(:uchar)
668
+ end
669
+
670
+ # 各行の前景ピクセル密度(0.0〜1.0)を算出して行プロファイルを構築する
671
+ def build_row_profile(mask)
672
+ (0...mask.height).map do |y|
673
+ row = mask.crop(0, y, mask.width, 1)
674
+ row.avg / 255.0
675
+ end
676
+ end
677
+
678
+ # 行プロファイルにガウシアン平滑化を適用してノイズを抑制する
679
+ def smooth_profile(values, sigma:)
680
+ sigma = [[sigma.to_f, MIN_PROFILE_SMOOTHING_SIGMA].max, MAX_PROFILE_SMOOTHING_SIGMA].min
681
+ radius = [((sigma * 2).ceil), 1].max
682
+ denom = 2.0 * sigma**2
683
+
684
+ values.map.with_index do |_, index|
685
+ total = 0.0
686
+ weights = 0.0
687
+
688
+ (-radius..radius).each do |offset|
689
+ position = index + offset
690
+ next if position.negative? || position >= values.length
691
+
692
+ weight = Math.exp(-(offset**2) / denom)
693
+ weights += weight
694
+ total += values[position] * weight
695
+ end
696
+
697
+ weights.positive? ? total / weights : values[index]
698
+ end
699
+ end
700
+
701
+ # 画像高さに応じたガウシアン平滑化のシグマ値を算出する
702
+ def profile_smoothing_sigma(image_height)
703
+ (image_height * PROFILE_SMOOTHING_SIGMA_RATIO).clamp(MIN_PROFILE_SMOOTHING_SIGMA, MAX_PROFILE_SMOOTHING_SIGMA)
704
+ end
705
+
706
+ # プロファイル値の平均と標準偏差を算出する
707
+ def profile_statistics(values)
708
+ return { average: 0.0, stddev: 0.0 } if values.empty?
709
+
710
+ average = values.sum / values.length.to_f
711
+ variance = values.sum { (it - average)**2 } / values.length.to_f
712
+ { average:, stddev: Math.sqrt(variance) }
713
+ end
714
+
715
+ # 行プロファイルから閾値を超える連続領域を検出する
716
+ # max_gap 以内のギャップは同一領域として結合し、min_height 未満の領域は除外する
717
+ def find_regions_from_profile(profile, image_height, threshold: 0.5, max_gap:, min_height:)
718
+ regions = []
719
+ in_region = false
720
+ start_y = 0
721
+ gap = 0
722
+
723
+ Array(profile).each_with_index do |value, y|
724
+ if value.to_f >= threshold
725
+ start_y = y unless in_region
726
+ in_region = true
727
+ gap = 0
728
+ elsif in_region
729
+ gap += 1
730
+ next unless gap > max_gap
731
+
732
+ height = y - start_y - gap + 1
733
+ regions << { y: start_y, height: } if height >= min_height
734
+ in_region = false
735
+ gap = 0
736
+ end
737
+ end
738
+
739
+ if in_region
740
+ height = image_height - start_y
741
+ regions << { y: start_y, height: } if height >= min_height
742
+ end
743
+
744
+ regions
745
+ end
746
+
747
+ # 検出した行領域から列活性度を分析し、IllustrationRegion を構築する
748
+ # 面積比やアスペクト比で候補をフィルタリングする
749
+ def build_foreground_region(mask, group, image_width, image_height)
750
+ top = group.fetch(:y)
751
+ height = group.fetch(:height)
752
+ slice = mask.crop(0, top, image_width, height)
753
+ active_columns = column_activity(slice, image_width, height)
754
+ return nil if active_columns.empty?
755
+
756
+ left = [active_columns.min - 8, 0].max
757
+ right = [active_columns.max + 9, image_width].min
758
+ width = right - left
759
+ candidate = IllustrationRegion.new(left:, top: [top - 8, 0].max, width:, height: [height + 16, image_height - top].min)
760
+ expanded = expand_illustration_region(candidate, image_width, image_height)
761
+ return nil unless illustration_region_candidate?(expanded, image_width, image_height)
762
+
763
+ area_ratio = (expanded.width * expanded.height).to_f / (image_width * image_height)
764
+ return nil if area_ratio < MIN_ILLUSTRATION_AREA_RATIO
765
+
766
+ expanded
767
+ rescue StandardError
768
+ nil
769
+ end
770
+
771
+ # マスク画像のスライスから前景活性のある列インデックスを収集する
772
+ def column_activity(slice, image_width, height)
773
+ (0...image_width).each_with_object([]) do |x, active|
774
+ column = slice.crop(x, 0, 1, height)
775
+ activity = column.avg / 255.0
776
+ active << x if activity >= COLUMN_ACTIVITY_THRESHOLD
777
+ end
778
+ end
779
+
780
+ # イラスト領域にパディングを追加して拡張する
781
+ def expand_illustration_region(region, image_width, image_height)
782
+ padding_x = [((region.width * 0.04).round), ((image_width * 0.01).round)].max
783
+ padding_y = [((region.height * 0.02).round), ((image_height * 0.005).round)].max
784
+ left = [region.left - padding_x, 0].max
785
+ top = [region.top - padding_y, 0].max
786
+ right = [region.left + region.width + padding_x, image_width].min
787
+ bottom = [region.top + region.height + padding_y, image_height].min
788
+
789
+ IllustrationRegion.new(left:, top:, width: [right - left, 1].max, height: [bottom - top, 1].max)
790
+ end
791
+
792
+ # 領域がイラストの候補として妥当か(サイズ・アスペクト比)を判定する
793
+ def illustration_region_candidate?(region, image_width, image_height)
794
+ return false unless region.width.positive? && region.height.positive?
795
+ return false if region.width < image_width * 0.35
796
+ return false if region.height < image_height * 0.08
797
+ return false if region.width * region.height < image_width * image_height * 0.035
798
+
799
+ aspect_ratio = region.width.to_f / region.height
800
+ aspect_ratio >= 1.05 && aspect_ratio <= 2.4
801
+ end
802
+
803
+ # イラスト領域のピクセル座標を PDF ページ座標に変換し、ImageOccurrence を構築する
804
+ def build_ocr_image_occurrence(page, region, result)
805
+ box = media_box(page)
806
+ page_width = box[2] - box[0]
807
+ page_height = box[3] - box[1]
808
+ right_edge = region.left + region.width
809
+ bottom_edge = region.top + region.height
810
+ left = box[0] + ((region.left.to_f / result.image_width) * page_width)
811
+ right = box[0] + ((right_edge.to_f / result.image_width) * page_width)
812
+ top = box[3] - ((region.top.to_f / result.image_height) * page_height)
813
+ bottom = box[3] - ((bottom_edge.to_f / result.image_height) * page_height)
814
+
815
+ ImageOccurrence.new(
816
+ x: (left + right) / 2.0,
817
+ top:,
818
+ bottom:,
819
+ center_y: (top + bottom) / 2.0,
820
+ left:,
821
+ right:,
822
+ width: right - left,
823
+ height: top - bottom,
824
+ object: RenderedPageCrop.new(
825
+ source_image_path: result.source_image_path,
826
+ left: region.left.round,
827
+ top: region.top.round,
828
+ width: region.width.round,
829
+ height: region.height.round
830
+ )
831
+ )
832
+ end
833
+
834
+ # ruby-vips を安全にロードし、Vips モジュールを返す
835
+ def require_vips!
836
+ require "vips"
837
+ Vips
838
+ rescue LoadError => e
839
+ raise Error, "画像抽出には ruby-vips と libvips が必要です: #{e.message}"
840
+ rescue StandardError => e
841
+ raise Error, "libvips の初期化に失敗しました: #{e.message}"
842
+ end
843
+
844
+ # OCR に必要な外部コマンド(pdftoppm, tesseract)と言語データが揃っているか
845
+ def ocr_dependencies_ready?
846
+ return false unless command_in_path?("pdftoppm") && command_in_path?("tesseract")
847
+
848
+ missing_languages = @ocr.languages.reject { available_ocr_languages.include?(it) }
849
+ if missing_languages.empty?
850
+ true
851
+ elsif ocr_mode == :always
852
+ raise Error, "OCR 用の Tesseract 言語データが不足しています: #{missing_languages.join(', ')}"
853
+ else
854
+ false
855
+ end
856
+ end
857
+
858
+ # Tesseract にインストール済みの言語一覧を取得する(メモ化)
859
+ def available_ocr_languages
860
+ @available_ocr_languages ||= begin
861
+ stdout, status = Open3.capture2e("tesseract", "--list-langs")
862
+ if status.success?
863
+ stdout.lines.map(&:strip).reject { it.empty? || it.start_with?("List of available languages") }
864
+ else
865
+ []
866
+ end
867
+ rescue StandardError
868
+ []
869
+ end
870
+ end
871
+
872
+ # テキスト抽出品質が低い(空白過多・断片化)かどうかを判定する
873
+ def poor_text_extraction?(text)
874
+ body = text.to_s.gsub(/\s+/, " ").strip
875
+ return false if body.length < 24
876
+
877
+ whitespace_ratio = body.count(" ").to_f / body.length
878
+ fragmented_words = body.scan(/(?:\p{Han}|\p{Hiragana}|\p{Katakana}|[A-Za-z])(?:\s+(?:\p{Han}|\p{Hiragana}|\p{Katakana}|[A-Za-z])){3,}/).length
879
+
880
+ whitespace_ratio >= 0.18 || fragmented_words.positive?
881
+ end
882
+
883
+ # ページ全体を覆うスキャン画像が存在するかどうか
884
+ def scanned_page_image?(page, image_occurrences)
885
+ Array(image_occurrences).any? { scanned_page_image_occurrence?(page, it) }
886
+ end
887
+
888
+ # 画像出現がページ全体を覆うスキャン画像かどうかを面積比で判定する
889
+ def scanned_page_image_occurrence?(page, occurrence)
890
+ box = media_box(page)
891
+ return false unless box
892
+
893
+ page_width = box[2] - box[0]
894
+ page_height = box[3] - box[1]
895
+ return false unless page_width.positive? && page_height.positive?
896
+
897
+ height_ratio = occurrence.height.to_f / page_height
898
+ width_ratio = occurrence.width.to_f / page_width
899
+ area_ratio = (occurrence.width.to_f * occurrence.height.to_f) / (page_width * page_height)
900
+
901
+ (height_ratio >= 0.72 && width_ratio >= 0.72) || area_ratio >= 0.55
902
+ end
903
+
904
+ # 指定コマンドが PATH 上に存在し実行可能かを確認する
905
+ def command_in_path?(command)
906
+ return false if command.to_s.empty?
907
+
908
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
909
+ path = File.join(dir, command)
910
+ File.executable?(path) && !File.directory?(path)
911
+ end
912
+ end
913
+
914
+ # Markdown 内で使用する画像参照パスを組み立てる
915
+ def image_reference_path(filename)
916
+ base = @image_reference_dir.to_s.strip
917
+ return filename if base.empty?
918
+
919
+ File.join(base, filename)
920
+ end
921
+
922
+ # ページチャンクを結合して最終 Markdown を組み立てる
923
+ def build_markdown(chunks)
924
+ body = if @page_separator
925
+ chunks.join("\n\n---\n\n")
926
+ else
927
+ chunks.reject(&:empty?).join("\n")
928
+ end
929
+ body = body.strip
930
+ body.empty? ? body : "#{body}\n"
931
+ end
932
+
933
+ # テキストの汎用サニタイズ: NBSP 除去・改行正規化・末尾空白除去・連続空行の圧縮
934
+ def sanitize(text)
935
+ text
936
+ .to_s
937
+ .gsub("\u00A0", " ")
938
+ .gsub(/\r\n?/, "\n")
939
+ .gsub(/[ \t]+$/, "")
940
+ .gsub(/\n{3,}/, "\n\n")
941
+ .strip
942
+ end
943
+
944
+ # スキャンページ画像をフィルタリングした画像出現リストを返す
945
+ def filtered_image_occurrences(page, image_occurrences, suppress_full_page_scans: false)
946
+ occurrences = Array(image_occurrences)
947
+ return occurrences unless suppress_full_page_scans
948
+
949
+ occurrences.reject { scanned_page_image_occurrence?(page, it) }
950
+ end
951
+
952
+ # 画像配列を Markdown 画像参照ブロック(+ キャプション)に変換する
953
+ def image_blocks(images, image_captions, fallback)
954
+ blocks = Array(images).sort_by { [-it.center_y.to_f, it.x.to_f] }.flat_map do |image|
955
+ caption = image_captions[image.reference_path].to_s.strip
956
+ next ["![](#{image.reference_path})"] if caption.empty?
957
+
958
+ ["![](#{image.reference_path})", "> #{caption}"]
959
+ end
960
+
961
+ body = blocks.reject(&:empty?).join("\n")
962
+ body.empty? ? fallback : body
963
+ end
964
+
965
+ # 行テキストとキャプションを結合してページテキストを構築する
966
+ def build_page_text(lines, fallback_text, image_captions)
967
+ body = Array(lines).map(&:text).reject { it.to_s.strip.empty? }.join("\n")
968
+ body = fallback_text.to_s.strip if body.empty?
969
+ captions = image_captions.values.map(&:to_s).reject { it.strip.empty? }
970
+ text = [body, *captions].reject(&:empty?).join("\n")
971
+ sanitize(text)
972
+ end
973
+
974
+ # inline_image_text ポリシーに従い、画像領域内のテキスト行を処理する
975
+ # :include ならそのまま、:exclude なら除外、:captionize ならキャプション化
976
+ def apply_inline_image_text_policy(lines, images)
977
+ ordered_lines = Array(lines).reject { it.text.to_s.strip.empty? }
978
+ return [ordered_lines, {}] if inline_image_text == :include || ordered_lines.empty? || images.empty?
979
+
980
+ captions = Hash.new { |hash, key| hash[key] = [] }
981
+ kept_lines = []
982
+
983
+ ordered_lines.each do |line|
984
+ image = overlapping_image(images, line)
985
+ if image
986
+ captions[image.reference_path] << line.text if inline_image_text == :captionize
987
+ else
988
+ kept_lines << line
989
+ end
990
+ end
991
+
992
+ normalized_captions = captions.transform_values do |texts|
993
+ sanitize(texts.reject(&:empty?).join(" "))
994
+ end.reject { _2.empty? }
995
+
996
+ [kept_lines, normalized_captions]
997
+ end
998
+
999
+ # 行の Y 座標と重なる画像を検索する(許容誤差 12pt)
1000
+ def overlapping_image(images, line)
1001
+ tolerance = 12.0
1002
+
1003
+ Array(images)
1004
+ .select { line.y.to_f.between?(it.bottom.to_f - tolerance, it.top.to_f + tolerance) }
1005
+ .min_by { (it.center_y.to_f - line.y.to_f).abs }
1006
+ end
1007
+
1008
+ # OCR 行テキストに後処理を適用し、空行を除外した Line 配列を返す
1009
+ def normalize_ocr_lines(lines)
1010
+ Array(lines).filter_map do |line|
1011
+ text = postprocess_ocr_text(line.text)
1012
+ next if text.empty?
1013
+
1014
+ Line.new(y: line.y, text:)
1015
+ end
1016
+ end
1017
+
1018
+ # OCR テキストの後処理パイプライン
1019
+ # 日本語スペース除去 → 断片結合 → 括弧正規化 → prh 辞書置換
1020
+ def postprocess_ocr_text(text)
1021
+ sanitized = sanitize(text)
1022
+ return "" if sanitized.empty?
1023
+
1024
+ sanitized = collapse_ocr_japanese_spaces(sanitized)
1025
+ sanitized = collapse_fragmented_words(sanitized)
1026
+ sanitized = normalize_ocr_brackets(sanitized)
1027
+ sanitized = apply_prh_replacements(sanitized)
1028
+ sanitize(sanitized)
1029
+ end
1030
+
1031
+ # --- OCR テキスト補正用の文字クラス正規表現 ---
1032
+ CJK_CHAR = /[\p{Han}\p{Hiragana}\p{Katakana}ー々〆ヵヶ]/.freeze
1033
+ JP_PUNCT = /[、。,.:;!?!?…]/.freeze
1034
+ JP_OPEN = /[「『(【《〈]/.freeze
1035
+ JP_CLOSE = /[」』)】》〉]/.freeze
1036
+
1037
+ # CJK 文字間・句読点前後の不要スペースを除去する
1038
+ def collapse_ocr_japanese_spaces(text)
1039
+ result = text.to_s
1040
+ result = result.gsub(/(?<=#{CJK_CHAR}) +(?=#{CJK_CHAR})/, "")
1041
+ result = result.gsub(/(?<=#{CJK_CHAR}) +(?=#{JP_PUNCT})/, "")
1042
+ result = result.gsub(/(?<=#{JP_PUNCT}) +(?=#{CJK_CHAR})/, "")
1043
+ result = result.gsub(/(?<=#{JP_OPEN}) +/, "")
1044
+ result = result.gsub(/ +(?=#{JP_CLOSE})/, "")
1045
+ result = result.gsub(/(?<=#{JP_CLOSE}) +(?=#{CJK_CHAR})/, "")
1046
+ result = result.gsub(/(?<=#{CJK_CHAR}) +(?=#{JP_OPEN})/, "")
1047
+ result = result.gsub(/\( +/, "(")
1048
+ result = result.gsub(/ +\)/, ")")
1049
+ result
1050
+ end
1051
+
1052
+ # CJK 文字を含む半角括弧を全角括弧に変換する
1053
+ def normalize_ocr_brackets(text)
1054
+ text.to_s.gsub(/\(([^)]*#{CJK_CHAR}[^)]*)\)/) { "(#{$1.strip})" }
1055
+ end
1056
+
1057
+ # OCR で断片化した単語(1文字ずつ空白区切り)を結合する
1058
+ def collapse_fragmented_words(text)
1059
+ text.to_s.gsub(/(?:\p{Han}|\p{Hiragana}|\p{Katakana}|[A-Za-z])(?:\s+(?:\p{Han}|\p{Hiragana}|\p{Katakana}|[A-Za-z])){2,}/) do
1060
+ it.gsub(/\s+/, "")
1061
+ end
1062
+ end
1063
+
1064
+ # prh 辞書の置換ルールをテキストに適用する
1065
+ def apply_prh_replacements(text)
1066
+ prh_replacements.reduce(text.to_s) do |memo, (matcher, expected)|
1067
+ memo.gsub(matcher, expected)
1068
+ end
1069
+ end
1070
+
1071
+ # config/textlint_prh.yml から prh 置換ルールを読み込む(メモ化)
1072
+ # 各ルールの patterns を Regexp にコンパイルし、[matcher, expected] の配列を返す
1073
+ def prh_replacements
1074
+ @prh_replacements ||= begin
1075
+ path = File.join(Dir.pwd, "config", "textlint_prh.yml")
1076
+ unless File.file?(path)
1077
+ []
1078
+ else
1079
+ data = YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: true) || {}
1080
+ Array(data["rules"]).flat_map do |rule|
1081
+ expected = rule["expected"].to_s
1082
+ next [] if expected.empty?
1083
+
1084
+ Array(rule["patterns"]).filter_map do |pattern|
1085
+ matcher = compile_prh_pattern(pattern)
1086
+ matcher ? [matcher, expected] : nil
1087
+ end
1088
+ end
1089
+ end
1090
+ rescue StandardError
1091
+ []
1092
+ end
1093
+ end
1094
+
1095
+ # prh パターンを Regexp にコンパイルする
1096
+ # "/pattern/" 形式は正規表現、それ以外はリテラルマッチ
1097
+ def compile_prh_pattern(pattern)
1098
+ case pattern
1099
+ in Regexp then pattern
1100
+ else
1101
+ value = pattern.to_s.strip
1102
+ return nil if value.empty?
1103
+
1104
+ if value.start_with?("/") && value.end_with?("/") && value.length > 2
1105
+ Regexp.new(value[1...-1])
1106
+ else
1107
+ Regexp.new(Regexp.escape(value))
1108
+ end
1109
+ end
1110
+ rescue StandardError
1111
+ nil
1112
+ end
1113
+
1114
+ # ページの MediaBox とマージン設定からテキスト抽出領域の座標境界を算出する
1115
+ # 奇数/偶数ページで綴じ側と小口側を反転する
1116
+ def text_area_bounds(page, index)
1117
+ return unless @text_area
1118
+
1119
+ box = media_box(page)
1120
+ return unless box
1121
+
1122
+ x_min, y_min, x_max, y_max = box
1123
+ parity = (index + 1).odd?
1124
+ inner = @text_area[:inner]
1125
+ outer = @text_area[:outer]
1126
+
1127
+ {
1128
+ top: y_max - @text_area[:top],
1129
+ bottom: y_min + @text_area[:bottom],
1130
+ left: x_min + (parity ? inner : outer),
1131
+ right: x_max - (parity ? outer : inner)
1132
+ }
1133
+ end
1134
+
1135
+ # ページの MediaBox(用紙サイズ座標)を安全に取得する
1136
+ def media_box(page)
1137
+ box = page[:MediaBox]
1138
+ values = Array(box).map { Float(it) }
1139
+ return values if values.length >= 4
1140
+
1141
+ nil
1142
+ rescue StandardError
1143
+ nil
1144
+ end
1145
+
1146
+ # text_area パラメータを {:top, :bottom, :inner, :outer} の Hash に正規化する
1147
+ def normalize_text_area(text_area)
1148
+ return unless text_area
1149
+
1150
+ {
1151
+ top: fetch_value(text_area, :top),
1152
+ bottom: fetch_value(text_area, :bottom),
1153
+ inner: fetch_value(text_area, :inner),
1154
+ outer: fetch_value(text_area, :outer)
1155
+ }
1156
+ end
1157
+
1158
+ # OCR パラメータを OcrSettings 構造体に正規化する
1159
+ def normalize_ocr(ocr)
1160
+ OcrSettings.new(
1161
+ mode: normalize_ocr_mode(setting_value(ocr, :mode)),
1162
+ languages: normalize_ocr_languages(setting_value(ocr, :languages)),
1163
+ dpi: normalize_positive_integer(setting_value(ocr, :dpi), 300),
1164
+ psm: normalize_positive_integer(setting_value(ocr, :psm), 3),
1165
+ inline_image_text: normalize_inline_image_text(setting_value(ocr, :inline_image_text))
1166
+ )
1167
+ end
1168
+
1169
+ # OCR モードを :auto / :always / :never に正規化する
1170
+ def normalize_ocr_mode(value)
1171
+ case value.to_s.strip.downcase
1172
+ in "" | "auto" then :auto
1173
+ in "always" | "force" | "true" then :always
1174
+ in "never" | "false" | "off" then :never
1175
+ else
1176
+ :auto
1177
+ end
1178
+ end
1179
+
1180
+ # OCR 言語指定を正規化する(配列化 + エイリアス解決 + 重複排除、既定は jpn)
1181
+ def normalize_ocr_languages(value)
1182
+ raw = case value
1183
+ in Array then value
1184
+ else value.to_s.split(/[+,]/)
1185
+ end
1186
+ languages = raw.map { normalize_ocr_language_alias(it) }.reject(&:empty?)
1187
+ languages = %w[jpn] if languages.empty?
1188
+ languages.uniq
1189
+ end
1190
+
1191
+ # "japanese" → "jpn" など、エイリアスを Tesseract 言語コードに変換する
1192
+ def normalize_ocr_language_alias(value)
1193
+ case value.to_s.strip.downcase.tr("-", "_")
1194
+ in "" then ""
1195
+ in "japanese" then "jpn"
1196
+ in "japanese_vertical" then "jpn_vert"
1197
+ else value.to_s.strip
1198
+ end
1199
+ end
1200
+
1201
+ # inline_image_text を :include / :exclude / :captionize に正規化する
1202
+ def normalize_inline_image_text(value)
1203
+ case value.to_s.strip.downcase
1204
+ in "" | "include" then :include
1205
+ in "exclude" | "remove" then :exclude
1206
+ in "captionize" | "caption_only" | "caption" then :captionize
1207
+ else :include
1208
+ end
1209
+ end
1210
+
1211
+ # 正の整数に変換する。不正値なら default を返す
1212
+ def normalize_positive_integer(value, default)
1213
+ integer = Integer(value)
1214
+ integer.positive? ? integer : default
1215
+ rescue StandardError
1216
+ default
1217
+ end
1218
+
1219
+ # 設定オブジェクトから安全にキー値を取得する(メソッド呼び出し or Hash アクセス)
1220
+ def setting_value(source, key)
1221
+ return nil unless source
1222
+
1223
+ if source.respond_to?(key)
1224
+ source.public_send(key)
1225
+ else
1226
+ source[key] || source[key.to_s]
1227
+ end
1228
+ rescue StandardError
1229
+ nil
1230
+ end
1231
+
1232
+ # 設定オブジェクトから数値を取得し、Float に変換する(失敗時は 0.0)
1233
+ def fetch_value(source, key)
1234
+ raw = if source.respond_to?(key)
1235
+ source.public_send(key)
1236
+ else
1237
+ source[key] || source[key.to_s]
1238
+ end
1239
+ raw.to_f
1240
+ rescue StandardError
1241
+ 0.0
1242
+ end
1243
+
1244
+ # OCR モード設定値のアクセサ
1245
+ def ocr_mode = @ocr.mode
1246
+
1247
+ # イラスト内テキスト処理ポリシーのアクセサ
1248
+ def inline_image_text = @ocr.inline_image_text
1249
+
1250
+ # 同一行とみなす Y 座標差の閾値のアクセサ
1251
+ def line_merge_tolerance = @line_merge_tolerance
1252
+ end
1253
+ end
1254
+ end
1255
+ end