vivlio-starter-pdf 1.1.2 → 1.2.0

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.
@@ -21,8 +21,13 @@ module VivlioStarter
21
21
  Line = Data.define(:y, :text)
22
22
  # PDF 内画像の出現位置(座標・サイズ・XObject 参照)
23
23
  ImageOccurrence = Data.define(:x, :top, :bottom, :center_y, :left, :right, :width, :height, :object)
24
- # ページのテキスト・行・画像出現をまとめたコンテンツ
25
- PageContent = Data.define(:text, :lines, :image_occurrences)
24
+ # ページのテキスト・行・画像出現をまとめたコンテンツ。words は OCR で読んだ語(OCR したページだけ)
25
+ PageContent = Data.define(:text, :lines, :image_occurrences, :words) do
26
+ def initialize(text:, lines:, image_occurrences:, words: []) = super
27
+ end
28
+ # OCR で読んだ 1 語。座標は PDF のページ(左下が原点・pt)。y はベースライン、size は行の高さ。
29
+ # 本体(vivlio-starter)はこの語から見出しや段落を推定する
30
+ OcrWord = Data.define(:text, :x, :y, :width, :size, :conf)
26
31
  # 抽出済み画像アセット(ページ番号・ファイル名・座標情報を保持)
27
32
  ImageAsset = Data.define(:page, :index, :filename, :output_path, :reference_path, :x, :top, :bottom, :center_y, :left, :right, :width, :height)
28
33
  # OCR で検出したイラスト領域(ピクセル座標)
@@ -34,24 +39,105 @@ module VivlioStarter
34
39
  # ページ解析の最終結果(コンテンツ + OCR 適用有無 + 一時ディレクトリ)
35
40
  ResolvedPage = Data.define(:content, :ocr_applied, :ocr_temp_dir)
36
41
  # OCR 実行結果(テキスト・行・ブロック・画像情報を保持)
37
- OcrResult = Data.define(:text, :lines, :blocks, :source_image_path, :image_width, :image_height, :temp_dir)
38
-
39
- # --- イラスト検出用定数 ---
40
- # イラスト領域とみなす最小面積比
41
- MIN_ILLUSTRATION_AREA_RATIO = 0.02
42
- # テキスト領域と区別するアスペクト比上限
43
- ILLUSTRATION_TEXT_ASPECT_MAX = 6.0
44
- # 前景検出の輝度閾値(この値未満のピクセルを前景とする)
45
- FOREGROUND_THRESHOLD = 210
46
- # 行プロファイル閾値の標準偏差スケール係数
47
- ROW_ACTIVITY_STDDEV_SCALE = 0.5
48
- # 列単位の前景活性度閾値
49
- COLUMN_ACTIVITY_THRESHOLD = 0.05
50
- # ガウシアン平滑化のシグマ比率(画像高さに対する割合)
51
- PROFILE_SMOOTHING_SIGMA_RATIO = 0.012
52
- # シグマの下限・上限
53
- MIN_PROFILE_SMOOTHING_SIGMA = 3
54
- MAX_PROFILE_SMOOTHING_SIGMA = 25
42
+ OcrResult = Data.define(:text, :lines, :blocks, :source_image_path, :image_width, :image_height, :temp_dir, :words) do
43
+ def initialize(text:, lines:, blocks:, source_image_path:, image_width:, image_height:, temp_dir:, words: []) = super
44
+ end
45
+ # Tesseract の TSV の 1 行。fields は書き戻すための元の列で、index は列名から列の位置を引く
46
+ TsvRow = Data.define(:fields, :index) do
47
+ def [](name) = fields[index.fetch(name)]
48
+ def level = self["level"]
49
+ def text = self["text"].to_s.strip
50
+ def left = self["left"].to_f
51
+ def top = self["top"].to_f
52
+ def width = self["width"].to_f
53
+ def height = self["height"].to_f
54
+ def right = left + width
55
+ def bottom = top + height
56
+ def center_x = left + (width / 2)
57
+ def center_y = top + (height / 2)
58
+ def conf = self["conf"].to_f
59
+ def line_key = LINE_KEY.map { self[it] }
60
+ # 英字の語か。空の語は、前後の英字の語の連なりを切らないよう英字の側に入れる
61
+ def latin? = text.empty? || text.match?(LATIN_WORD)
62
+
63
+ def with_fields(changes)
64
+ changed = fields.dup
65
+ changes.each { |name, value| changed[index.fetch(name)] = value.to_s }
66
+ with(fields: changed)
67
+ end
68
+ end
69
+
70
+ # --- 二度読み ---
71
+ # 囲みの地の割合がこれ以上のページだけ二度読みする
72
+ TWO_PASS_TINT = 0.08
73
+ # 読みを選ぶ基準にする確信度
74
+ CONFIDENT_WORD = 80
75
+
76
+ # --- 文字が検出されなかった範囲の読み直し ---
77
+ # 読み直す範囲の大きさの下限(ページの幅を 595 とした単位。A4 なら pt)
78
+ REREAD_MIN_WIDTH = 60
79
+ REREAD_MIN_HEIGHT = 18
80
+ # すでに読めた語がこれを超えて覆う範囲は読み直さない。地の色の付いた囲みの中の文やコードは別の
81
+ # ブロックで読めていて 15〜30% を覆い、表や色の帯の見出しは 0〜9% を覆う
82
+ REREAD_MAX_COVERAGE = 0.1
83
+ # 切り出すときの余白(画像の幅に対する割合。300dpi の A4 で 24px)
84
+ REREAD_PADDING = 24 / 2480.0
85
+
86
+ # --- 傾きの補正 ---
87
+ # 測る傾きの範囲と刻み(度)
88
+ DESKEW_RANGE = 3.0
89
+ DESKEW_STEP = 0.05
90
+ # これより小さい傾きは直さない
91
+ DESKEW_MIN_ANGLE = 0.1
92
+ # 傾きを測る画像の幅(px)。縮めて測っても 0.05° の刻みで決まる
93
+ DESKEW_WORK_WIDTH = 620
94
+
95
+ # --- 斜体の行の読み直し ---
96
+ # 読み直す行の確信度の平均の上限。斜体の行は 0〜40 の意味の無い英字になるか、60〜70 の崩れた和文になる
97
+ SLANT_MAX_CONFIDENCE = 75
98
+ # 読み直す行の字数の下限(図の中の短い札を除く)
99
+ SLANT_MIN_CHARACTERS = 8
100
+ # 戻す傾き(度)。Chrome は斜体の無い和文の書体を傾けて描く
101
+ SLANT_ANGLE = 12
102
+
103
+ # --- 英語での読み直し ---
104
+ # 英字の語がこれ以上あるページだけ、英語の学習データでも読む
105
+ ENGLISH_PAGE_WORDS = 20
106
+ # 英語の読みを採るのに要る確信度。日本語+英語の読みと食い違う語の平均で見る。
107
+ # 「11月」の「11」を英語の読みは「lL」(確信度 35)と読む
108
+ ENGLISH_MIN_CONFIDENCE = 60
109
+ # TSV で 1 行を表す列(ブロック・段落・行の番号)
110
+ LINE_KEY = %w[page_num block_num par_num line_num].freeze
111
+ # 英字の語(ASCII とラテン文字、英文の引用符とダッシュ)
112
+ LATIN_WORD = /\A(?:\p{ASCII}|\p{Latin}|[‘’“”–—])+\z/
113
+
114
+ # --- 図版の検出 ---
115
+ # ページを縮めて調べるときの幅(px)。A4 で約 37dpi、1 画素が約 2pt になる
116
+ FIGURE_WORK_WIDTH = 310
117
+ # 紙の明るさよりこれだけ暗い画素と、彩度(LCh の C)がこれを超える画素を、図の候補にする。
118
+ # 淡い黄色の表紙が候補に入るよう、彩度は低めにする
119
+ FIGURE_DARKNESS = 50
120
+ FIGURE_CHROMA = 15
121
+ # 確信度がこれ以上の語と、語の確信度の平均がこれ以上の行は、文字として図の候補から消す
122
+ FIGURE_TEXT_CONFIDENCE = 60
123
+ # 図とみなす大きさの下限。辺はページの幅に、面積はページの面積に対する割合(A4 で 40pt・6,000pt²)。
124
+ # 見出しの色の札や、会話文の話者のアイコン(32pt 角)を除く
125
+ FIGURE_MIN_SIDE = 40 / 595.0
126
+ FIGURE_MIN_AREA = 6000 / (595.0 * 842.0)
127
+ # 図の枠を削るときに残す、図の画素の埋まった列・行の割合
128
+ FIGURE_DENSE = 0.3
129
+ # 埋まった列・行のあいだで続きとみなす、少しは埋まった列・行の割合の下限と本数の上限(trim_to_dense)。
130
+ # 本数は縮めた画像の画素で、8 本は約 15pt。ほぼ空の列・行(写真の横の字を消した跡・図の題との空き)はつながない
131
+ FIGURE_SPARSE = 0.1
132
+ FIGURE_BRIDGE = 8
133
+
134
+ # --- OCR に回す画像 ---
135
+ # 埋め込みのスキャン画像がこの幅(px)に届かなければ、vips で拡大してから読む。
136
+ # A5 の本を約 230dpi、A4 を約 165dpi で取り込んだ幅にあたる
137
+ MIN_OCR_IMAGE_WIDTH = 1600
138
+ # これを超える解像度のスキャンは、TARGET_OCR_DPI へ縮めてから読む
139
+ MAX_OCR_DPI = 400
140
+ TARGET_OCR_DPI = 300.0
55
141
 
56
142
  # HexaPDF のコンテンツストリームを走査し、版面内のテキスト断片と画像出現を収集する
57
143
  class PageTextCollector < HexaPDF::Content::Processor
@@ -68,6 +154,15 @@ module VivlioStarter
68
154
  @image_occurrences = []
69
155
  end
70
156
 
157
+ # 1 つの命令の失敗で、ページの読み取りを止めない。HexaPDF は、資源に無い色空間(`/CS0 cs`)や
158
+ # 縦書きの字(「Not yet implemented」)で例外を出す。色空間も縦書きの字の位置も、画像の位置と
159
+ # 横書きの文字の取得には使わないので、失敗した命令だけを飛ばす
160
+ def process(operator, operands = [])
161
+ super
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
71
166
  # PDF オペレータ Tj: テキスト表示
72
167
  def show_text(data)
73
168
  collect_text_box(decode_text_with_positioning(data))
@@ -116,7 +211,9 @@ module VivlioStarter
116
211
  end
117
212
 
118
213
  lines << Line.new(y: current_y, text: normalize_line(buffer)) unless buffer.empty?
119
- buffer = +fragment.text
214
+ # 複製して受ける。`+fragment.text` は凍結されていない文字列をそのまま返すので、
215
+ # 複製しないと下の `<<` が断片の文字列自体を書き換える
216
+ buffer = fragment.text.dup
120
217
  current_y = fragment.y
121
218
  previous_fragment = fragment
122
219
  end
@@ -192,9 +289,10 @@ module VivlioStarter
192
289
  line
193
290
  .to_s
194
291
  .gsub(/[ \t\u00A0]+/, " ")
195
- .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ]) (?=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ])/, "")
196
- .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆ヵヶ]) (?=[、。,.:;!?)】』」])/, "")
197
- .gsub(/(?<=[(【『「]) (?=[一-龯ぁ-ゖァ-ヶー々〆ヵヶA-Za-z0-9])/, "")
292
+ # ヵヶ は ァ-ヶ の範囲に含まれるので並べない(重複範囲の警告が出る)
293
+ .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆]) (?=[一-龯ぁ-ゖァ-ヶー々〆])/, "")
294
+ .gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆]) (?=[、。,.:;!?)】』」])/, "")
295
+ .gsub(/(?<=[(【『「]) (?=[一-龯ぁ-ゖァ-ヶー々〆A-Za-z0-9])/, "")
198
296
  .strip
199
297
  end
200
298
 
@@ -230,7 +328,11 @@ module VivlioStarter
230
328
  # @param images_dir [String, nil] 画像の保存先ディレクトリ
231
329
  # @param image_reference_dir [String, nil] Markdown 内の画像参照パスの基底
232
330
  # @param ocr [Hash, nil] OCR 設定
233
- def initialize(pdf_path, page_separator: true, text_area: nil, line_merge_tolerance: 2.0, images_dir: nil, image_reference_dir: nil, ocr: nil)
331
+ # @param progress [#call, nil] OCR するページごとに (何ページ目か, 読むページの数) で呼ぶ。OCR は 1 ページ数秒かかる
332
+ # @param pages [Array<Integer>, nil] 読むページ(1 から数える)。省けば全ページ。読まないページは、文字も画像も OCR も
333
+ # 取らず、結果の配列には空の値を置く(ページの添字はそのまま)
334
+ def initialize(pdf_path, page_separator: true, text_area: nil, line_merge_tolerance: 2.0, images_dir: nil, image_reference_dir: nil, ocr: nil,
335
+ progress: nil, pages: nil)
234
336
  @pdf_path = pdf_path
235
337
  @page_separator = page_separator != false
236
338
  @text_area = normalize_text_area(text_area)
@@ -238,18 +340,31 @@ module VivlioStarter
238
340
  @images_dir = images_dir&.to_s&.strip
239
341
  @image_reference_dir = image_reference_dir&.to_s&.strip
240
342
  @ocr = normalize_ocr(ocr)
343
+ @progress = progress
344
+ @pages = pages&.map(&:to_i)
241
345
  end
242
346
 
243
347
  # PDF を解析し、Markdown テキスト・画像アセット・メタデータを含む Hash を返す
244
- # @return [Hash] :markdown, :page_texts, :page_chunks, :pages, :images
348
+ # @return [Hash] :markdown, :page_texts, :page_chunks, :pages, :images, :page_words, :page_sizes
349
+ # page_words は、OCR したページなら語の一覧(OcrWord#to_h)、しなかったページなら nil
245
350
  def execute
246
351
  document = HexaPDF::Document.open(pdf_path)
247
352
  page_texts = []
248
353
  page_chunks = []
249
354
  images = []
355
+ page_words = []
356
+ page_sizes = []
250
357
 
358
+ @page_count = @pages ? @pages.size : document.pages.count
251
359
  document.pages.each_with_index do |page, index|
252
360
  resolution = nil
361
+ unless read_page?(index)
362
+ page_texts << ""
363
+ page_chunks << ""
364
+ page_words << nil
365
+ page_sizes << page_size(page)
366
+ next
367
+ end
253
368
 
254
369
  begin
255
370
  content = extract_page_content(page, index)
@@ -261,6 +376,8 @@ module VivlioStarter
261
376
  page_texts << page_text
262
377
  page_chunks << build_page_chunk(page_lines, page_images, page_text, image_captions:)
263
378
  images.concat(page_images)
379
+ page_words << (resolution.ocr_applied ? resolution.content.words.map(&:to_h) : nil)
380
+ page_sizes << page_size(page)
264
381
  ensure
265
382
  cleanup_ocr_temp_dir(resolution&.ocr_temp_dir)
266
383
  end
@@ -271,14 +388,20 @@ module VivlioStarter
271
388
  page_texts:,
272
389
  page_chunks:,
273
390
  pages: document.pages.count,
274
- images: images.map(&:to_h)
391
+ images: images.map(&:to_h),
392
+ page_words:,
393
+ page_sizes:
275
394
  }
395
+ ensure
396
+ FileUtils.rm_rf(@poppler_dir) if @poppler_dir
276
397
  end
277
398
 
278
399
  private
279
400
 
280
401
  attr_reader :pdf_path
281
402
 
403
+ def read_page?(index) = @pages.nil? || @pages.include?(index + 1)
404
+
282
405
  # OCR 用の一時ディレクトリを安全に削除する
283
406
  def cleanup_ocr_temp_dir(path)
284
407
  return if path.to_s.empty?
@@ -307,6 +430,7 @@ module VivlioStarter
307
430
  def resolve_page_content(page, index, content)
308
431
  return ResolvedPage.new(content:, ocr_applied: false, ocr_temp_dir: nil) unless ocr_required_for_page?(page, content)
309
432
 
433
+ @progress&.call(@pages ? @pages.index(index + 1).to_i + 1 : index + 1, @page_count)
310
434
  resolved_content, ocr_temp_dir = extract_page_ocr_content(page, index, content.image_occurrences)
311
435
  ResolvedPage.new(content: resolved_content, ocr_applied: true, ocr_temp_dir:)
312
436
  rescue StandardError
@@ -316,9 +440,9 @@ module VivlioStarter
316
440
  end
317
441
 
318
442
  # OCR を実行してページコンテンツを再構築する
319
- # テキスト後処理(空白圧縮・括弧正規化・prh 辞書置換)を適用する
443
+ # テキスト後処理(空白圧縮・括弧正規化・誤認識の補正)を適用する
320
444
  def extract_page_ocr_content(page, index, image_occurrences)
321
- result = ocr_page_result(page, index)
445
+ result = ocr_page_result(page, index, image_occurrences)
322
446
  lines = normalize_ocr_lines(result.lines)
323
447
  text = if lines.empty?
324
448
  postprocess_ocr_text(result.text)
@@ -328,9 +452,9 @@ module VivlioStarter
328
452
 
329
453
  resolved_image_occurrences = ocr_image_occurrences(page, image_occurrences, result)
330
454
  page_content = if text.empty?
331
- PageContent.new(text: "", lines: [], image_occurrences: resolved_image_occurrences)
455
+ PageContent.new(text: "", lines: [], image_occurrences: resolved_image_occurrences, words: result.words)
332
456
  else
333
- PageContent.new(text:, lines:, image_occurrences: resolved_image_occurrences)
457
+ PageContent.new(text:, lines:, image_occurrences: resolved_image_occurrences, words: result.words)
334
458
  end
335
459
 
336
460
  [page_content, result.temp_dir]
@@ -366,7 +490,8 @@ module VivlioStarter
366
490
  occurrences.each_with_index do |occurrence, image_index|
367
491
  filename = format("page-%03d-image-%02d.webp", index + 1, image_index + 1)
368
492
  output_path = File.join(@images_dir, filename)
369
- write_image_as_webp(occurrence.object, output_path)
493
+ next unless write_extracted_image(occurrence.object, index + 1, output_path)
494
+
370
495
  assets << build_image_asset(index, image_index + 1, filename, output_path, occurrence)
371
496
  rescue StandardError
372
497
  next
@@ -375,6 +500,44 @@ module VivlioStarter
375
500
  assets
376
501
  end
377
502
 
503
+ # 画像を書き出し、書けたかを返す。HexaPDF で書けない画像(色の表 Indexed を別のオブジェクトに置いた
504
+ # 画像など)は、poppler の pdfimages で同じオブジェクトを書き出す
505
+ def write_extracted_image(object, page_number, output_path)
506
+ write_image_as_webp(object, output_path)
507
+ true
508
+ rescue StandardError
509
+ write_poppler_image(object, page_number, output_path)
510
+ end
511
+
512
+ def write_poppler_image(object, page_number, output_path)
513
+ return false unless object.respond_to?(:oid) && object.oid.to_i.positive? && command_in_path?("pdfimages")
514
+
515
+ source = poppler_page_images(page_number)[object.oid]
516
+ return false unless source
517
+
518
+ _stdout, status = Open3.capture2e(*image_convert_command(source, output_path))
519
+ status.success?
520
+ end
521
+
522
+ # pdfimages で 1 ページの画像を PNG に書き出し、{オブジェクトの番号 => PNG のパス} を返す(ページごとに 1 回)。
523
+ # ファイル名の番号は -list の num と同じ。透明度(smask)の行は除く
524
+ def poppler_page_images(page_number)
525
+ @poppler_images ||= {}
526
+ @poppler_images[page_number] ||= begin
527
+ @poppler_dir ||= Dir.mktmpdir("vivlio-pdf-images")
528
+ prefix = File.join(@poppler_dir, format("page-%03d", page_number))
529
+ range = ["-f", page_number.to_s, "-l", page_number.to_s]
530
+ list, = Open3.capture2e("pdfimages", "-list", *range, pdf_path)
531
+ Open3.capture2e("pdfimages", "-png", *range, pdf_path, prefix)
532
+ list.lines.drop(2).filter_map do |line|
533
+ columns = line.split
534
+ next unless columns[2] == "image"
535
+
536
+ [columns[10].to_i, format("%s-%03d.png", prefix, columns[1].to_i)]
537
+ end.to_h.select { |_oid, file| File.file?(file) }
538
+ end
539
+ end
540
+
378
541
  # テキスト行と画像参照を Y 座標順に統合し、ページチャンク文字列を構築する
379
542
  def build_page_chunk(lines, images, fallback_text, image_captions: {})
380
543
  fallback = fallback_text.to_s.strip
@@ -498,33 +661,498 @@ module VivlioStarter
498
661
  end
499
662
 
500
663
  # 1 ページ分の OCR を実行し、OcrResult を返す
501
- # pdftoppm でページ画像を生成し、Tesseract でテキスト認識する
502
- def ocr_page_result(page, index)
664
+ # ページ画像を用意し(ocr_source_image)、Tesseract でテキストと座標を一度に読む。
665
+ # 囲みの多いページは二値化した画像でも読み、英字の多いページは英語の学習データでも読み直す
666
+ def ocr_page_result(page, index, image_occurrences = [])
503
667
  temp_dir = Dir.mktmpdir("vivlio-pdf-ocr")
504
- prefix = File.join(temp_dir, "page")
505
- stdout, status = Open3.capture2e(*ocr_render_command(prefix, index + 1))
506
- raise Error, stdout.strip unless status.success?
507
-
508
- source_image_path = Dir.glob("#{prefix}-*.png").sort.first
509
- raise Error, "OCR 用のページ画像を生成できませんでした" unless source_image_path
510
-
511
- tsv = capture_ocr_output(*ocr_tsv_command(source_image_path))
512
- image_width, image_height = png_dimensions(source_image_path)
668
+ source_image_path, image_width, image_height = ocr_source_image(page, index, image_occurrences, temp_dir)
669
+ text, tsv = run_tesseract(source_image_path, File.join(temp_dir, "ocr"))
670
+ text, tsv, read_image_path = second_reading(source_image_path, temp_dir, text, tsv)
671
+ tsv = reread_skipped_regions(read_image_path, temp_dir, tsv, page)
672
+ tsv = reread_slanted_lines(read_image_path, temp_dir, tsv)
673
+ tsv = english_reading(read_image_path, temp_dir, tsv)
674
+ source_image_path, tsv = straighten(source_image_path, tsv, temp_dir)
513
675
 
514
676
  OcrResult.new(
515
- text: capture_ocr_output(*ocr_text_command(source_image_path)),
677
+ text:,
516
678
  lines: extract_ocr_lines(tsv, page),
517
679
  blocks: [],
518
680
  source_image_path:,
519
681
  image_width:,
520
682
  image_height:,
521
- temp_dir:
683
+ temp_dir:,
684
+ words: extract_ocr_words(tsv, page)
522
685
  )
523
686
  rescue StandardError
524
687
  cleanup_ocr_temp_dir(temp_dir)
525
688
  raise
526
689
  end
527
690
 
691
+ # OCR にかけるページ画像を用意し、[パス, 幅, 高さ] を返す。
692
+ #
693
+ # スキャンしたページは、埋め込みの画像をそのまま書き出す。pdftoppm による描き直しは 300dpi・A4 の
694
+ # JPEG 1 枚で 23 秒かかる(書き出しは 0.05 秒)。埋め込みの画像を使えないページは描き直す
695
+ def ocr_source_image(page, index, image_occurrences, temp_dir)
696
+ embedded = embedded_scan_image(page, image_occurrences)
697
+ if embedded && (embedded.info.width >= MIN_OCR_IMAGE_WIDTH || vips_available?)
698
+ path = File.join(temp_dir, "page.#{embedded.info.extension}")
699
+ embedded.write(path)
700
+ dpi = scan_dpi(page, embedded.info.width)
701
+ return downscale_for_ocr(path, dpi, temp_dir) if dpi && dpi > MAX_OCR_DPI && vips_available?
702
+ return [path, embedded.info.width, embedded.info.height] if embedded.info.width >= MIN_OCR_IMAGE_WIDTH
703
+
704
+ return upscale_for_ocr(path, embedded.info.width, temp_dir)
705
+ end
706
+
707
+ prefix = File.join(temp_dir, "page")
708
+ stdout, status = Open3.capture2e(*ocr_render_command(prefix, index + 1))
709
+ raise Error, stdout.strip unless status.success?
710
+
711
+ path = Dir.glob("#{prefix}-*.png").sort.first
712
+ raise Error, "OCR 用のページ画像を生成できませんでした" unless path
713
+
714
+ [path, *png_dimensions(path)]
715
+ end
716
+
717
+ # ページの大きさから見たスキャンの解像度(dpi)。ページを実物より大きく作った PDF では低く出るが、
718
+ # 縮める判定(MAX_OCR_DPI を超えるか)にしか使わないので、そういう PDF は縮めないだけである
719
+ def scan_dpi(page, image_width)
720
+ box = media_box(page)
721
+ return nil unless box && (box[2] - box[0]).positive?
722
+
723
+ image_width / ((box[2] - box[0]) / 72.0)
724
+ end
725
+
726
+ # 解像度の高すぎるスキャンを 300dpi へ縮める。Tesseract は字が大きすぎても誤認識する
727
+ def downscale_for_ocr(path, dpi, temp_dir)
728
+ image = require_vips!::Image.new_from_file(path).resize(TARGET_OCR_DPI / dpi, kernel: :lanczos3)
729
+ downscaled = File.join(temp_dir, "page-downscaled.jpg")
730
+ # PNG で書くと、書き出しと読み込みで 1 ページ 1 秒ほど余計にかかる
731
+ image.write_to_file(downscaled, Q: 92)
732
+ [downscaled, image.width, image.height]
733
+ end
734
+
735
+ # 画素の足りない画像を vips で拡大し、[パス, 幅, 高さ] を返す。
736
+ # pdftoppm で 300dpi に描き直すのとほぼ同じ確信度が、0.5 秒で得られる
737
+ def upscale_for_ocr(path, width, temp_dir)
738
+ scale = (MIN_OCR_IMAGE_WIDTH.to_f / width).clamp(1.0, 3.0)
739
+ image = require_vips!::Image.new_from_file(path).resize(scale, kernel: :lanczos3)
740
+ upscaled = File.join(temp_dir, "page-upscaled.png")
741
+ image.write_to_file(upscaled)
742
+ [upscaled, image.width, image.height]
743
+ end
744
+
745
+ # スキャンの傾きを直す。傾いたままだと、切り出した図の縁に三角の余白が付き、コードの行番号の欄や
746
+ # 柱の高さがページの上と下で数 pt ずれる。
747
+ # 読み取りは傾いたままの画像で済ませ、ここでは語の位置だけを回す(回した画像を Tesseract に渡すと、
748
+ # 補間で字がにじんで認識率が下がる)。回した画像は図の検出と切り出しに使う。
749
+ # 大きさは変えない(OCR の座標を、画像がページを覆う前提でページの座標へ換算するため)。
750
+ # 回して空いた隅は紙の明るさで埋める
751
+ # @return [Array(String, String)] [図を切り出す画像のパス, 語の位置を回した TSV]
752
+ def straighten(path, tsv, temp_dir)
753
+ return [path, tsv] unless vips_available?
754
+
755
+ image = require_vips!::Image.new_from_file(path)
756
+ gray = image.colourspace(:b_w).extract_band(0)
757
+ angle = skew_angle(gray)
758
+ return [path, tsv] if angle.abs < DESKEW_MIN_ANGLE
759
+
760
+ rotated = image.similarity(angle:, background: Array.new(image.bands, gray.percent(50)))
761
+ deskewed = File.join(temp_dir, "page-deskewed.jpg")
762
+ rotated.crop((rotated.width - image.width) / 2, (rotated.height - image.height) / 2, image.width, image.height)
763
+ .write_to_file(deskewed, Q: 95)
764
+ [deskewed, rotate_tsv(tsv, angle, image.width, image.height)]
765
+ rescue StandardError
766
+ [path, tsv]
767
+ end
768
+
769
+ # TSV の枠を、画像を angle(度・時計回り)回したときの位置へ移す。枠の中心を画像の中心のまわりに回し、
770
+ # 大きさは保つ。ただし行・段落・ブロックの枠は、傾いた行を囲んだぶん(幅 × sin)だけ高く出ているので縮める
771
+ # (行の高さは字の大きさの見積もりになる)
772
+ def rotate_tsv(tsv, angle, width, height)
773
+ radians = angle * Math::PI / 180
774
+ cos = Math.cos(radians)
775
+ sin = Math.sin(radians)
776
+ rows = parse_tsv_rows(tsv).map do |row|
777
+ next row if row.level == "1"
778
+
779
+ dx = row.center_x - (width / 2.0)
780
+ dy = row.center_y - (height / 2.0)
781
+ box_height = row.level == "5" ? row.height : [row.height - (row.width * sin.abs), row.height / 4].max
782
+ x = (width / 2.0) + (dx * cos) - (dy * sin)
783
+ y = (height / 2.0) + (dx * sin) + (dy * cos)
784
+ row.with_fields("left" => (x - (row.width / 2)).round, "top" => (y - (box_height / 2)).round, "height" => box_height.round)
785
+ end
786
+ "#{[tsv.lines.first.chomp, *rows.map { it.fields.join("\t") }].join("\n")}\n"
787
+ end
788
+
789
+ # 傾き(度・時計回りに直す向きが正)。縮めた画像の濃い画素を角度ごとに回して行ごとに足し、行の並びが
790
+ # いちばん鋭く出る(行ごとの和の二乗和が最大の)角度を採る。範囲の端に当たったら、行の並びが
791
+ # 見つからない(絵だけのページなど)とみなして 0 を返す
792
+ def skew_angle(gray)
793
+ factor = [gray.width / DESKEW_WORK_WIDTH, 1].max
794
+ small = gray.shrink(factor, factor)
795
+ ink = (small < small.percent(50) - FIGURE_DARKNESS).ifthenelse(1.0, 0.0).copy_memory
796
+ best = (-DESKEW_RANGE..DESKEW_RANGE).step(DESKEW_STEP).max_by do |angle|
797
+ ink.similarity(angle:).project[1].to_a.flatten.sum { it * it }
798
+ end
799
+ best.abs >= DESKEW_RANGE - (DESKEW_STEP / 2) ? 0.0 : best
800
+ end
801
+
802
+ # そのまま OCR に回せる埋め込みの画像を返す(無ければ nil)。
803
+ #
804
+ # OCR の座標は「画像がページ全体に、縦横比を保って広がっている」前提で
805
+ # ページの座標へ換算する(extract_ocr_lines・build_ocr_image_occurrence)。
806
+ # それを満たし、Tesseract が読める形式の画像だけを選ぶ。
807
+ #
808
+ # 解像度はページの寸法から計らない。ページを実物より大きく作ったスキャン PDF を設定の dpi で
809
+ # 描き直すと、情報の増えない引き伸ばしになる。画素の多寡は画像の幅で見る
810
+ # (MIN_OCR_IMAGE_WIDTH・upscale_for_ocr)。
811
+ #
812
+ # 回転の指定があるページは描き直しに任せる。画像を上下逆に置いた PDF は見分けられないが、
813
+ # スキャナが作る PDF では起きない
814
+ def embedded_scan_image(page, image_occurrences)
815
+ return nil unless page[:Rotate].to_i.zero?
816
+
817
+ box = media_box(page)
818
+ return nil unless box
819
+
820
+ page_width = box[2] - box[0]
821
+ page_height = box[3] - box[1]
822
+
823
+ Array(image_occurrences).find do |occurrence|
824
+ object = occurrence.object
825
+ next false unless object.is_a?(HexaPDF::Type::Image)
826
+
827
+ info = object.info
828
+ next false unless info.writable && %i[jpeg png].include?(info.type) && [1, 3].include?(info.components)
829
+ next false unless occurrence.width >= page_width * 0.98 && occurrence.height >= page_height * 0.98
830
+
831
+ # to_f を忘れない。配置を整数で書いた PDF では座標も Integer で来て、割り算が 0 になる
832
+ ((info.width.to_f / info.height) - (occurrence.width.to_f / occurrence.height)).abs <= 0.02
833
+ end&.object
834
+ rescue StandardError
835
+ nil
836
+ end
837
+
838
+ # 囲みや絵の多いページだけ、大津の二値化をかけた画像でも読み、確信度の高い字の多いほうを選ぶ。
839
+ # 二値化は地の色の付いた囲みのページでは認識率を上げるが、どのページにもかけると下がる。
840
+ # 選ぶ基準は確信度の平均ではなく、確信度の高い語の字数にする。平均で選ぶと、字を落とした読みを選ぶことがある。
841
+ # 選んだ読みの画像も返す。英語での読み直し(english_reading)は同じ画像を読む
842
+ # @return [Array(String, String, String)] [テキスト, TSV, 読んだ画像のパス]
843
+ def second_reading(image_path, temp_dir, text, tsv)
844
+ return [text, tsv, image_path] unless vips_available?
845
+
846
+ image = require_vips!::Image.new_from_file(image_path).colourspace(:b_w).extract_band(0)
847
+ histogram = image.hist_find.to_a.flatten
848
+ return [text, tsv, image_path] if tint_ratio(histogram) < TWO_PASS_TINT
849
+
850
+ binarized = File.join(temp_dir, "page-otsu.png")
851
+ (image > otsu_threshold(histogram)).write_to_file(binarized)
852
+ other_text, other_tsv = run_tesseract(binarized, File.join(temp_dir, "ocr-otsu"))
853
+ confident_characters(other_tsv) > confident_characters(tsv) ? [other_text, other_tsv, binarized] : [text, tsv, image_path]
854
+ rescue StandardError
855
+ [text, tsv, image_path]
856
+ end
857
+
858
+ # 囲みの地の割合。紙の明るさ(中央値)より 10〜60 暗い面で、囲みや網掛けの背景がここに入る。
859
+ # スキャンした紙は真っ白ではない(中央値 226〜230)ので、紙を基準にする
860
+ def tint_ratio(histogram)
861
+ total = histogram.sum
862
+ return 0.0 unless total.positive?
863
+
864
+ cumulative = 0
865
+ median = histogram.index { (cumulative += it) * 2 >= total }.to_i
866
+ range = ([median - 60, 0].max..[median - 10, 0].max)
867
+ histogram[range].sum.fdiv(total)
868
+ end
869
+
870
+ # 大津の方法で、明るさの分布を 2 つに分けるしきい値を求める
871
+ def otsu_threshold(histogram)
872
+ total = histogram.sum.to_f
873
+ sum_all = histogram.each_with_index.sum { |count, level| count * level }
874
+ background = 0.0
875
+ sum_background = 0.0
876
+ histogram.each_with_index.max_by do |count, level|
877
+ background += count
878
+ sum_background += count * level
879
+ foreground = total - background
880
+ next 0.0 if background.zero? || foreground.zero?
881
+
882
+ mean_background = sum_background / background
883
+ mean_foreground = (sum_all - sum_background) / foreground
884
+ background * foreground * ((mean_background - mean_foreground)**2)
885
+ end.last
886
+ end
887
+
888
+ # 確信度が CONFIDENT_WORD 以上の語に含まれる字の数
889
+ def confident_characters(tsv)
890
+ rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
891
+ header = rows.shift
892
+ return 0 unless header
893
+
894
+ index = header.each_with_index.to_h
895
+ rows.sum do |row|
896
+ next 0 unless row[index["level"]] == "5" && row[index["conf"]].to_f >= CONFIDENT_WORD
897
+
898
+ row[index["text"]].to_s.strip.length
899
+ end
900
+ end
901
+
902
+ # Tesseract が文字の無い範囲と判定した所を、切り出して読み直す。ページ全体を読むと、Tesseract は先に
903
+ # 紙面の割り付けを推定し、罫線で囲んだ表や色の帯の中の見出しを「文字の無いブロック」として扱う。
904
+ # 切り出して渡すと、その推定の影響を受けずに読める。
905
+ # 読み直しの確信度の高い字が十分にあり、もとの読みより多いときだけ、その範囲の語を置き換える。
906
+ # 図の範囲(extract_illustration_regions_vips)と重なる所は読み直さない。図の中の文字が本文に入り、
907
+ # その字を消して図を探すので図も検出できなくなるため
908
+ def reread_skipped_regions(image_path, temp_dir, tsv, page)
909
+ rows = parse_tsv_rows(tsv)
910
+ words = rows.select { it.level == "5" && !it.text.empty? }
911
+ image = require_vips!::Image.new_from_file(image_path)
912
+ replaced = []
913
+ added = []
914
+ figures = extract_illustration_regions_vips(image_path, extract_ocr_words(tsv, page), page).map { [it.left, it.top, it.left + it.width, it.top + it.height] }
915
+ regions = skipped_regions(rows, words, image.width, image.height).reject do |box|
916
+ figures.any? { |figure| [[box[2], figure[2]].min - [box[0], figure[0]].max, 0].max * [[box[3], figure[3]].min - [box[1], figure[1]].max, 0].max > (box[2] - box[0]) * (box[3] - box[1]) * 0.3 }
917
+ end
918
+ regions.each_with_index do |box, number|
919
+ inside = words.select { it.center_x.between?(box[0], box[2]) && it.center_y.between?(box[1], box[3]) }
920
+ left, top, crop = crop_for_reread(image, box, temp_dir, number)
921
+ _text, region_tsv = run_tesseract(crop, File.join(temp_dir, "reread-#{number}"))
922
+ region_rows = parse_tsv_rows(region_tsv).reject { it.level == "1" }
923
+ next unless better_reading?(region_rows, inside)
924
+
925
+ replaced.concat(inside)
926
+ added.concat(region_rows.map do |row|
927
+ row.with_fields("left" => row.left.round + left, "top" => row.top.round + top, "block_num" => row["block_num"].to_i + (1000 * (number + 1)))
928
+ end)
929
+ end
930
+ return tsv if added.empty?
931
+
932
+ "#{[tsv.lines.first.chomp, *(rows - replaced).map { it.fields.join("\t") }, *added.map { it.fields.join("\t") }].join("\n")}\n"
933
+ rescue StandardError
934
+ tsv
935
+ end
936
+
937
+ # 読み直す範囲 [左, 上, 右, 下](画素)。語の無いブロックのうち、接し合うものを 1 つにまとめる(表は
938
+ # 罫線で区切られた幾つものブロックになり、別々に読むと切り口の字を読み違える)。小さいもの、ページの
939
+ # 半分を超えるもの、すでに読めた語が REREAD_MAX_COVERAGE を超えて覆うもの(地の色の付いた囲みの
940
+ # 中の文は別のブロックで読めている)は除く
941
+ def skipped_regions(rows, words, width, height)
942
+ word_blocks = words.map { it["block_num"] }.uniq
943
+ unit = width / 595.0
944
+ boxes = rows.select { it.level == "2" && !word_blocks.include?(it["block_num"]) }.map { [it.left, it.top, it.right, it.bottom] }
945
+ clusters = []
946
+ boxes.each do |box|
947
+ joined = clusters.select { |group| group.any? { touching?(it, box, unit * 2) } }
948
+ clusters = clusters - joined + [joined.flatten(1) + [box]]
949
+ end
950
+ clusters.map { |group| [group.map { it[0] }.min, group.map { it[1] }.min, group.map { it[2] }.max, group.map { it[3] }.max] }.select do |left, top, right, bottom|
951
+ area = (right - left) * (bottom - top)
952
+ covered = words.sum { [[it.right, right].min - [it.left, left].max, 0].max * [[it.bottom, bottom].min - [it.top, top].max, 0].max }
953
+ right - left >= REREAD_MIN_WIDTH * unit && bottom - top >= REREAD_MIN_HEIGHT * unit && area <= width * height * 0.5 && covered <= area * REREAD_MAX_COVERAGE
954
+ end
955
+ end
956
+
957
+ def touching?(a, b, gap) = a[0] - gap <= b[2] && b[0] - gap <= a[2] && a[1] - gap <= b[3] && b[1] - gap <= a[3]
958
+
959
+ # 範囲を余白つきで切り出して書き、[左, 上, パス] を返す。余白が狭いと字を誤認識し、広いと周りの本文が入る
960
+ def crop_for_reread(image, box, temp_dir, number)
961
+ padding = (image.width * REREAD_PADDING).round
962
+ left = [box[0].floor - padding, 0].max
963
+ top = [box[1].floor - padding, 0].max
964
+ right = [box[2].ceil + padding, image.width].min
965
+ bottom = [box[3].ceil + padding, image.height].min
966
+ path = File.join(temp_dir, "reread-#{number}.png")
967
+ image.crop(left, top, right - left, bottom - top).write_to_file(path)
968
+ [left, top, path]
969
+ end
970
+
971
+ # 読み直しを採るか。確信度 CONFIDENT_WORD 以上の字が 4 字以上で、読んだ字の半分以上を占め、
972
+ # もとの読みのその範囲の確信度の高い字より多いこと
973
+ def better_reading?(region_rows, inside)
974
+ region_words = region_rows.select { it.level == "5" && !it.text.empty? }
975
+ confident = ->(words) { words.select { it.conf >= CONFIDENT_WORD }.sum { it.text.length } }
976
+ confident.(region_words) >= 4 && confident.(region_words) * 2 >= region_words.sum { it.text.length } &&
977
+ confident.(region_words) > confident.(inside)
978
+ end
979
+
980
+ # 斜体の行を、傾きを戻して読み直す。和文に斜体の書体は無く、Chrome は字を傾けて描く。
981
+ # Tesseract は傾いた和文を認識できないので、確信度の低い行だけを切り出し、SLANT_ANGLE だけ逆に傾けて読む
982
+ def reread_slanted_lines(image_path, temp_dir, tsv)
983
+ return tsv unless @ocr.languages.include?("jpn")
984
+
985
+ rows = parse_tsv_rows(tsv)
986
+ heights = rows.select { it.level == "4" }.map(&:height).sort
987
+ return tsv if heights.empty?
988
+
989
+ line_height = heights[heights.size / 2]
990
+ candidates = rows.select { it.level == "5" && !it.text.empty? }.group_by(&:line_key).values.select do |words|
991
+ words.size >= 2 && words.sum { it.text.length } >= SLANT_MIN_CHARACTERS && mean_confidence(words) < SLANT_MAX_CONFIDENCE &&
992
+ words.map(&:bottom).max - words.map(&:top).min <= line_height * 2.5
993
+ end
994
+ return tsv if candidates.empty?
995
+
996
+ image = require_vips!::Image.new_from_file(image_path)
997
+ replaced = []
998
+ added = []
999
+ candidates.each_with_index do |words, number|
1000
+ reading = read_unslanted(image, words, temp_dir, number.to_s, SLANT_ANGLE, 100_000 * (number + 1))
1001
+ next unless better_unslanted?(reading, words)
1002
+ next unless better_unslanted?(reading, read_unslanted(image, words, temp_dir, "#{number}-upright", 0, 0))
1003
+
1004
+ replaced.concat(words)
1005
+ added.concat(reading)
1006
+ end
1007
+ return tsv if added.empty?
1008
+
1009
+ "#{[tsv.lines.first.chomp, *(rows - replaced).map { it.fields.join("\t") }, *added.map { it.fields.join("\t") }].join("\n")}\n"
1010
+ rescue StandardError
1011
+ tsv
1012
+ end
1013
+
1014
+ # 行を余白つきで切り出し、angle(度)だけ傾きを戻して読む。返す行(TSV の行と語)の位置はページの画素へ戻す。
1015
+ # 傾きを戻すと字は行の高さに応じて右へずれるので、行の中ほどのずれを引く。ブロックの番号は block_offset だけ
1016
+ # ずらす(読み飛ばした範囲の読み直しの 1000 単位と重ならないよう、100000 単位にする)
1017
+ def read_unslanted(image, words, temp_dir, name, angle, block_offset)
1018
+ top = words.map(&:top).min
1019
+ height = words.map(&:bottom).max - top
1020
+ left = [words.map(&:left).min.floor - height.ceil, 0].max
1021
+ crop_top = [top.floor - (height / 3).ceil, 0].max
1022
+ right = [words.map(&:right).max.ceil + height.ceil, image.width].min
1023
+ bottom = [(top + height).ceil + (height / 3).ceil, image.height].min
1024
+ shear = Math.tan(angle * Math::PI / 180)
1025
+ crop = image.crop(left, crop_top, right - left, bottom - crop_top)
1026
+ paper = crop.colourspace(:b_w).extract_band(0).percent(50)
1027
+ path = File.join(temp_dir, "slant-#{name}.png")
1028
+ crop.affine([1, shear, 0, 1], background: Array.new(crop.bands, paper)).write_to_file(path)
1029
+
1030
+ _text, line_tsv = run_tesseract(path, File.join(temp_dir, "slant-#{name}"))
1031
+ parse_tsv_rows(line_tsv).select { %w[4 5].include?(it.level) }.map do |row|
1032
+ row.with_fields("left" => (row.left - (shear * row.center_y) + left).round, "top" => row.top.round + crop_top,
1033
+ "block_num" => row["block_num"].to_i + block_offset)
1034
+ end
1035
+ end
1036
+
1037
+ # 傾きを戻した読みを採るか。確信度の高い字が 4 字以上、半分以上が和文で、比べる読み(もとの読みと、同じ所を
1038
+ # 傾けずに切り出した読み)より確信度の高い字が十分に多いこと。読めた字の半分以上が確信度の高い字であるか、
1039
+ # 20 字以上あること(引用の行の後ろに崩れた字が続くことがある)。傾けずに切り出した読みとも比べるのは、
1040
+ # 切り出すだけでよく読める行(和文の文字列を含むコードの行)を、崩れた読みで置き換えないため
1041
+ def better_unslanted?(reading, words)
1042
+ read = reading.select { it.level == "5" && !it.text.empty? }
1043
+ confident = ->(list) { list.select { it.level == "5" && it.conf >= CONFIDENT_WORD }.sum { it.text.length } }
1044
+ total = read.sum { it.text.length }
1045
+ gained = confident.(read)
1046
+ original = confident.(words)
1047
+ japanese = read.sum { |word| word.text.each_char.count { !it.ascii_only? } }
1048
+ gained >= 4 && japanese * 2 >= total && gained > original + [4, original * 0.3].max &&
1049
+ (gained * 2 >= total || gained >= 20)
1050
+ end
1051
+
1052
+ # 英字の多いページだけ、英語の学習データでも読み、英字の語を語の位置で突き合わせて採る。
1053
+ # 日本語+英語で読むと、コードの `@param` を `Gparam` のように誤認識する。英語だけで読むと和文を認識できない。
1054
+ # そこで和文の語は日本語+英語の読みのまま残し、英字の語の連なりだけを差し替える
1055
+ def english_reading(image_path, temp_dir, tsv)
1056
+ return tsv unless @ocr.languages.include?("eng") && @ocr.languages.size > 1
1057
+
1058
+ rows = parse_tsv_rows(tsv)
1059
+ return tsv if rows.count { it.level == "5" && it.latin? && it.text.match?(/[A-Za-z]/) } < ENGLISH_PAGE_WORDS
1060
+
1061
+ _text, english_tsv = run_tesseract(image_path, File.join(temp_dir, "ocr-eng"), languages: ["eng"])
1062
+ merge_english_words(tsv, english_tsv)
1063
+ rescue StandardError
1064
+ tsv
1065
+ end
1066
+
1067
+ # 日本語+英語の読みの各行で、英字の語の連なり(和文の語にはさまれた範囲)ごとに、英語の読みの語を
1068
+ # 当てはめる(english_replacement)。差し替えた語には元の行の番号を付けるので、本体に渡す語の
1069
+ # ベースラインと大きさは、日本語+英語の読みの行の枠から取られる(extract_ocr_words)
1070
+ def merge_english_words(tsv, english_tsv)
1071
+ rows = parse_tsv_rows(tsv)
1072
+ english_words = parse_tsv_rows(english_tsv).select { it.level == "5" && !it.text.empty? && it.latin? }
1073
+ lines = rows.select { it.level == "4" }.to_h { [it.line_key, it] }
1074
+
1075
+ merged = rows.slice_when { |a, b| a.level != "5" || b.level != "5" || a.line_key != b.line_key }.flat_map do |group|
1076
+ line = lines[group.first.line_key]
1077
+ next group unless group.first.level == "5" && line
1078
+
1079
+ group.chunk_while { |a, b| a.latin? && b.latin? }.flat_map do |run|
1080
+ replacement = english_replacement(run, group, line, english_words)
1081
+ next run unless replacement
1082
+
1083
+ english_words -= replacement
1084
+ replacement.map { relocate_english_word(it, run) }
1085
+ end
1086
+ end
1087
+ "#{[tsv.lines.first.chomp, *merged.map { it.fields.join("\t") }].join("\n")}\n"
1088
+ end
1089
+
1090
+ # 英字の語の連なり run に当てはめる英語の読みの語を返す(当てはめないなら nil)。
1091
+ # 中心が run の範囲(横は語の両端、縦は行の枠)に入る語を集め、次をすべて満たすときに採る。
1092
+ # - 2 つの読みで食い違う語について、英語の読みの確信度の平均が ENGLISH_MIN_CONFIDENCE 以上で、
1093
+ # 日本語+英語の読みより高い。run 全体の平均で比べると、確信度の高い共通の語(`showcase}`)に
1094
+ # 引っ張られて、「::」を「3:」と読んだ側を採ってしまう
1095
+ # - 英語の読みの語が run の幅の 8 割以上を覆い、字数が 0.6〜1.6 倍に収まる。端の「"」や「:」を
1096
+ # 落とした読みを採らない
1097
+ def english_replacement(run, line_words, line, english_words)
1098
+ return nil unless run.all?(&:latin?) && run.any? { it.text.match?(/[A-Za-z0-9]/) }
1099
+
1100
+ left = run.map(&:left).min
1101
+ right = run.map(&:right).max
1102
+ japanese = line_words.reject(&:latin?)
1103
+ candidates = english_words.select do |word|
1104
+ word.center_x.between?(left, right) && word.center_y.between?(line.top, line.bottom) &&
1105
+ japanese.none? { [word.right, it.right].min - [word.left, it.left].max > word.width / 2 }
1106
+ end
1107
+ return nil if candidates.empty?
1108
+
1109
+ differing = candidates.reject { |word| run.any? { it.text == word.text } }
1110
+ replaced = run.reject { |word| candidates.any? { it.text == word.text } }
1111
+ return nil if differing.empty?
1112
+ return nil unless mean_confidence(differing) >= ENGLISH_MIN_CONFIDENCE && mean_confidence(differing) > mean_confidence(replaced)
1113
+
1114
+ characters = run.sum { it.text.length }
1115
+ covered = [candidates.map(&:right).max, right].min - [candidates.map(&:left).min, left].max
1116
+ return nil unless candidates.sum { it.text.length }.between?(characters * 0.6, characters * 1.6)
1117
+ return nil if covered < (right - left) * 0.8
1118
+
1119
+ candidates
1120
+ end
1121
+
1122
+ # 英語の読みの語を、日本語+英語の読みの行へ移す。行の番号を合わせ、枠は run の範囲に収める。
1123
+ # 枠がはみ出すと、隣の和文の語との空白が消える(「方向に delta」→「方向にdelta」)
1124
+ def relocate_english_word(word, run)
1125
+ left = [word.left, run.map(&:left).min].max
1126
+ right = [word.right, run.map(&:right).max].min
1127
+ word.with_fields(LINE_KEY.to_h { [it, run.first[it]] }.merge("left" => left.round, "width" => [right - left, 1].max.round))
1128
+ end
1129
+
1130
+ # 字数で重みを付けた確信度の平均。語が無ければ 0
1131
+ def mean_confidence(words)
1132
+ characters = words.sum { it.text.length }
1133
+ characters.zero? ? 0.0 : words.sum { it.conf * it.text.length } / characters
1134
+ end
1135
+
1136
+ # Tesseract の TSV を TsvRow の並びにする(見出しの行は除く)
1137
+ def parse_tsv_rows(tsv)
1138
+ header, *rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
1139
+ return [] unless header
1140
+
1141
+ index = header.each_with_index.to_h
1142
+ rows.map { TsvRow.new(fields: it.fill("", it.length...header.length), index:) }
1143
+ end
1144
+
1145
+ # Tesseract を 1 回だけ起動し、プレーンテキストと TSV(座標)を同時に書き出す。
1146
+ # テキストは TSV から行を組めなかったときの予備
1147
+ # @return [Array(String, String)] [テキスト, TSV]
1148
+ def run_tesseract(image_path, output_base, languages: @ocr.languages)
1149
+ command = ["tesseract", image_path, output_base, "-l", languages.join("+"), "--psm", @ocr.psm.to_s, "txt", "tsv"]
1150
+ stdout, stderr, status = Open3.capture3(*command)
1151
+ raise Error, [stderr, stdout].find { !it.to_s.strip.empty? }.to_s.strip unless status.success?
1152
+
1153
+ %w[txt tsv].map { File.read("#{output_base}.#{it}", encoding: "UTF-8") }
1154
+ end
1155
+
528
1156
  # pdftoppm によるページ画像レンダリングコマンドを組み立てる
529
1157
  def ocr_render_command(prefix, page_number)
530
1158
  [
@@ -538,30 +1166,6 @@ module VivlioStarter
538
1166
  ]
539
1167
  end
540
1168
 
541
- # Tesseract によるプレーンテキスト出力コマンドを組み立てる
542
- def ocr_text_command(image_path)
543
- [
544
- "tesseract",
545
- image_path,
546
- "stdout",
547
- "-l", @ocr.languages.join("+"),
548
- "--psm", @ocr.psm.to_s
549
- ]
550
- end
551
-
552
- # Tesseract による TSV 出力コマンドを組み立てる(座標情報取得用)
553
- def ocr_tsv_command(image_path)
554
- ocr_text_command(image_path) + ["tsv"]
555
- end
556
-
557
- # 外部コマンドを実行し、stdout を返す。失敗時は Error を送出する
558
- def capture_ocr_output(*command)
559
- stdout, stderr, status = Open3.capture3(*command)
560
- raise Error, [stderr, stdout].find { !it.to_s.strip.empty? }.to_s.strip unless status.success?
561
-
562
- stdout
563
- end
564
-
565
1169
  # Tesseract TSV 出力を解析し、行単位の Line 配列を構築する
566
1170
  # TSV のワード座標を PDF ページ座標に変換して Y 位置を算出する
567
1171
  def extract_ocr_lines(tsv, page)
@@ -615,6 +1219,51 @@ module VivlioStarter
615
1219
  []
616
1220
  end
617
1221
 
1222
+ # Tesseract TSV の語(level 5)を、PDF のページ座標の OcrWord にする。
1223
+ # ベースラインと大きさは、語ではなく行(level 4)の枠から取る。語ごとの枠は字の形
1224
+ # (句読点・小書き)で上下するので、同じ行の語がそろわなくなる
1225
+ def extract_ocr_words(tsv, page)
1226
+ rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
1227
+ header = rows.shift
1228
+ box = media_box(page)
1229
+ return [] unless header && box
1230
+
1231
+ index = header.each_with_index.to_h
1232
+ value = ->(row, name) { row[index.fetch(name)] }
1233
+ image = rows.find { value.(it, "level") == "1" }
1234
+ return [] unless image
1235
+
1236
+ scale_x = (box[2] - box[0]) / value.(image, "width").to_f
1237
+ scale_y = (box[3] - box[1]) / value.(image, "height").to_f
1238
+ line_key = ->(row) { %w[page_num block_num par_num line_num].map { value.(row, it) } }
1239
+ lines = rows.select { value.(it, "level") == "4" }.to_h { [line_key.(it), it] }
1240
+
1241
+ rows.filter_map do |row|
1242
+ text = value.(row, "text").to_s.strip
1243
+ line = lines[line_key.(row)]
1244
+ next if value.(row, "level") != "5" || text.empty? || line.nil?
1245
+
1246
+ line_top = value.(line, "top").to_f
1247
+ line_height = value.(line, "height").to_f
1248
+ OcrWord.new(
1249
+ text:,
1250
+ x: box[0] + (value.(row, "left").to_f * scale_x),
1251
+ y: box[3] - ((line_top + line_height) * scale_y),
1252
+ width: value.(row, "width").to_f * scale_x,
1253
+ size: line_height * scale_y,
1254
+ conf: value.(row, "conf").to_f
1255
+ )
1256
+ end
1257
+ rescue KeyError, ZeroDivisionError
1258
+ []
1259
+ end
1260
+
1261
+ # ページの幅と高さ(pt)
1262
+ def page_size(page)
1263
+ box = media_box(page)
1264
+ box ? [box[2] - box[0], box[3] - box[1]] : nil
1265
+ end
1266
+
618
1267
  # PNG ファイルの幅と高さをヘッダから読み取る
619
1268
  def png_dimensions(path)
620
1269
  header = File.binread(path, 24)
@@ -630,173 +1279,134 @@ module VivlioStarter
630
1279
  inline_occurrences = Array(image_occurrences).reject { scanned_page_image_occurrence?(page, it) }
631
1280
  return inline_occurrences unless scanned_page_image?(page, image_occurrences) || inline_occurrences.empty?
632
1281
 
633
- inline_occurrences + extract_illustration_regions_vips(result.source_image_path).map { build_ocr_image_occurrence(page, it, result) }
1282
+ inline_occurrences + extract_illustration_regions_vips(result.source_image_path, result.words, page).map { build_ocr_image_occurrence(page, it, result) }
634
1283
  rescue StandardError
635
1284
  inline_occurrences
636
1285
  end
637
1286
 
638
- # vips を使って OCR ページ画像からイラスト領域を自動検出する
639
- # 前景マスク → 行プロファイル → ガウシアン平滑化 → 領域分割 の流れで処理する
640
- def extract_illustration_regions_vips(source_image_path)
641
- vips = require_vips!
642
- image = vips::Image.new_from_file(source_image_path)
643
- mask = vips_foreground_mask(image)
644
- row_profile = smooth_profile(build_row_profile(mask), sigma: profile_smoothing_sigma(image.height))
645
- stats = profile_statistics(row_profile)
646
- threshold = stats.fetch(:average) + (stats.fetch(:stddev) * ROW_ACTIVITY_STDDEV_SCALE)
647
-
648
- regions = find_regions_from_profile(
649
- row_profile,
650
- image.height,
651
- threshold:,
652
- max_gap: [20, (image.height * 0.006).round].max,
653
- min_height: [100, (image.height * 0.03).round].max
654
- )
655
-
656
- regions.filter_map { build_foreground_region(mask, it, image.width, image.height) }
1287
+ # OCR したページの画像から図の範囲を見つける。
1288
+ # ページを縮め、紙より濃い画素と色の付いた画素を拾い、OCR で読めた文字(確信度の高い語と行)を
1289
+ # 消してから、残ったまとまりを図とみなす。白地の線だけの図は、文字を消すと線しか残らず、検出できない
1290
+ # @param words [Array<OcrWord>] このページの OCR の語(PDF のページの座標)
1291
+ # @return [Array<IllustrationRegion>] 元の画像の画素での範囲
1292
+ def extract_illustration_regions_vips(source_image_path, words, page)
1293
+ image = require_vips!::Image.new_from_file(source_image_path)
1294
+ factor = [(image.width / FIGURE_WORK_WIDTH.to_f).round, 1].max
1295
+ small = image.shrink(factor, factor)
1296
+ ink = figure_ink(small)
1297
+ erase_text(ink, small.width, small.height, words, page)
1298
+ figure_boxes(ink, small.width, small.height).map do |left, top, right, bottom|
1299
+ IllustrationRegion.new(left: left * factor, top: top * factor, width: (right - left) * factor, height: (bottom - top) * factor)
1300
+ end
657
1301
  end
658
1302
 
659
- # 画像をグレースケール化し、前景ピクセルの二値マスクを生成する
660
- def vips_foreground_mask(image)
661
- gray = image.colourspace("b-w")
662
- gray
663
- .relational_const(:less, [FOREGROUND_THRESHOLD])
664
- .cast(:uchar)
665
- .linear([255.0], [0])
666
- .cast(:uchar)
1303
+ # 図の候補の画素(左上から行ごとに並べた真偽)。紙の明るさ(中央値)より FIGURE_DARKNESS 以上
1304
+ # 暗いか、彩度が FIGURE_CHROMA を超える画素。地の色の付いた囲み(紙より 10〜40 暗い)は入らない
1305
+ def figure_ink(small)
1306
+ small = small.extract_band(0, n: 3) if small.bands > 3
1307
+ gray = small.colourspace(:b_w).extract_band(0)
1308
+ histogram = gray.hist_find.to_a.flatten
1309
+ total = histogram.sum
1310
+ cumulative = 0
1311
+ paper = histogram.index { (cumulative += it) * 2 >= total }
1312
+ mask = gray < (paper - FIGURE_DARKNESS)
1313
+ mask |= small.colourspace(:lch).extract_band(1) > FIGURE_CHROMA if small.bands >= 3
1314
+ mask.cast(:uchar).write_to_memory.unpack("C*").map(&:positive?)
667
1315
  end
668
1316
 
669
- # 各行の前景ピクセル密度(0.0〜1.0)を算出して行プロファイルを構築する
670
- def build_row_profile(mask)
671
- (0...mask.height).map do |y|
672
- row = mask.crop(0, y, mask.width, 1)
673
- row.avg / 255.0
1317
+ # 確信度の高い語と、語の確信度の平均が高い行の範囲を、図の候補から消す。行ごと消すのは、語の
1318
+ # あいだの隙間に残る字の切れ端で、本文の行が図の候補につながらないようにするため
1319
+ def erase_text(ink, width, height, words, page)
1320
+ box = media_box(page)
1321
+ scale_x = width / (box[2] - box[0])
1322
+ scale_y = height / (box[3] - box[1])
1323
+ lines = words.group_by { [it.y, it.size] }.values.select { |line| line.sum(&:conf) / line.size >= FIGURE_TEXT_CONFIDENCE }
1324
+ spans = lines.map { |line| [line.map(&:x).min, line.first.y, line.map { it.x + it.width }.max, line.first.y + line.first.size] } +
1325
+ words.select { it.conf >= FIGURE_TEXT_CONFIDENCE }.map { [it.x, it.y, it.x + it.width, it.y + it.size] }
1326
+ spans.each do |left, bottom, right, top|
1327
+ columns = ((left - box[0]) * scale_x).floor.clamp(0, width)...((right - box[0]) * scale_x).ceil.clamp(0, width)
1328
+ rows = ((box[3] - top) * scale_y).floor.clamp(0, height)...((box[3] - bottom) * scale_y).ceil.clamp(0, height)
1329
+ rows.each { |y| columns.each { |x| ink[(y * width) + x] = false } }
674
1330
  end
675
1331
  end
676
1332
 
677
- # 行プロファイルにガウシアン平滑化を適用してノイズを抑制する
678
- def smooth_profile(values, sigma:)
679
- sigma = [[sigma.to_f, MIN_PROFILE_SMOOTHING_SIGMA].max, MAX_PROFILE_SMOOTHING_SIGMA].min
680
- radius = [((sigma * 2).ceil), 1].max
681
- denom = 2.0 * sigma**2
682
-
683
- values.map.with_index do |_, index|
684
- total = 0.0
685
- weights = 0.0
686
-
687
- (-radius..radius).each do |offset|
688
- position = index + offset
689
- next if position.negative? || position >= values.length
690
-
691
- weight = Math.exp(-(offset**2) / denom)
692
- weights += weight
693
- total += values[position] * weight
1333
+ # 図の候補の画素のまとまりを、図の枠 [左, 上, 右, 下](縮めた画像の画素)にする。
1334
+ # 隣り合う画素を 1 画素膨らませてつなぎ、枠は膨らませる前の画素で取る。枠は、埋まった列・行の
1335
+ # 最も長い連なりへ削る(trim_to_dense)。ページの端に接するもの(ページ上端の飾りの帯・色を
1336
+ # 敷いた章扉)は図にしない。図にすると、その上の文字を本体が本文から除いてしまう
1337
+ def figure_boxes(ink, width, height)
1338
+ grown = ink.each_index.map { |cell| ink[cell] || neighbor_cells(cell, width, ink.size).any? { ink[it] } }
1339
+ seen = Array.new(ink.size, false)
1340
+ grown.each_index.filter_map do |start|
1341
+ next unless grown[start] && !seen[start]
1342
+
1343
+ # つながった画素を幅優先でたどる(each は、たどる途中で足した画素も回る)
1344
+ seen[start] = true
1345
+ cells = [start]
1346
+ cells.each do |cell|
1347
+ neighbor_cells(cell, width, ink.size).each do |neighbor|
1348
+ next if seen[neighbor] || !grown[neighbor]
1349
+
1350
+ seen[neighbor] = true
1351
+ cells << neighbor
1352
+ end
694
1353
  end
695
-
696
- weights.positive? ? total / weights : values[index]
1354
+ inked = cells.select { ink[it] }
1355
+ next if inked.empty?
1356
+
1357
+ whole = [inked.map { it % width }.min, inked.map { it / width }.min, inked.map { it % width }.max + 1, inked.map { it / width }.max + 1]
1358
+ box = trim_to_dense(ink, width, whole)
1359
+ next box if box && figure_box?(box, cells.size, width, height)
1360
+ # 白地の多い絵は、埋まった列・行がまばらで、削ると下限より小さくなる。
1361
+ # 削る前の枠が図の条件を満たせば、そちらを採る
1362
+ whole if figure_box?(whole, cells.size, width, height)
697
1363
  end
698
1364
  end
699
1365
 
700
- # 画像高さに応じたガウシアン平滑化のシグマ値を算出する
701
- def profile_smoothing_sigma(image_height)
702
- (image_height * PROFILE_SMOOTHING_SIGMA_RATIO).clamp(MIN_PROFILE_SMOOTHING_SIGMA, MAX_PROFILE_SMOOTHING_SIGMA)
1366
+ # 上下左右の画素の番号(左右の端では折り返さない)
1367
+ def neighbor_cells(cell, width, size)
1368
+ column = cell % width
1369
+ [cell - width, cell + width, (cell - 1 if column.positive?), (cell + 1 if column < width - 1)].compact.select { it.between?(0, size - 1) }
703
1370
  end
704
1371
 
705
- # プロファイル値の平均と標準偏差を算出する
706
- def profile_statistics(values)
707
- return { average: 0.0, stddev: 0.0 } if values.empty?
708
-
709
- average = values.sum / values.length.to_f
710
- variance = values.sum { (it - average)**2 } / values.length.to_f
711
- { average:, stddev: Math.sqrt(variance) }
712
- end
713
-
714
- # 行プロファイルから閾値を超える連続領域を検出する
715
- # max_gap 以内のギャップは同一領域として結合し、min_height 未満の領域は除外する
716
- def find_regions_from_profile(profile, image_height, threshold: 0.5, max_gap:, min_height:)
717
- regions = []
718
- in_region = false
719
- start_y = 0
720
- gap = 0
721
-
722
- Array(profile).each_with_index do |value, y|
723
- if value.to_f >= threshold
724
- start_y = y unless in_region
725
- in_region = true
726
- gap = 0
727
- elsif in_region
728
- gap += 1
729
- next unless gap > max_gap
730
-
731
- height = y - start_y - gap + 1
732
- regions << { y: start_y, height: } if height >= min_height
733
- in_region = false
734
- gap = 0
735
- end
1372
+ # 枠を、図の画素が FIGURE_DENSE 以上埋まった列の最も長い連なりへ、続いて同じく行の連なりへ削る。
1373
+ # 写真は埋まり、写真の横の消し残した字(小さなルビ・確信度の低い語)はまばらなので、写真に
1374
+ # つながった本文の字の列が枠から外れる。
1375
+ # 埋まった列・行のあいだに、少しは埋まった(FIGURE_SPARSE 以上の)列・行が FIGURE_BRIDGE 本以内はさまる
1376
+ # だけなら、続きとみなす。白地の多い絵や表紙は、区画の境目にそうした行がはさまるため
1377
+ def trim_to_dense(ink, width, box)
1378
+ left, top, right, bottom = box
1379
+ longest = lambda do |ratios|
1380
+ flags = bridge_sparse(ratios.map { it >= FIGURE_DENSE }, ratios)
1381
+ run = flags.each_with_index.chunk_while { |(a, i), (b, j)| a == b && j == i + 1 }.select { it.first.first }.max_by(&:size)
1382
+ run && [run.first.last, run.last.last + 1]
736
1383
  end
737
-
738
- if in_region
739
- height = image_height - start_y
740
- regions << { y: start_y, height: } if height >= min_height
741
- end
742
-
743
- regions
1384
+ column_ratios = (left...right).map { |x| (top...bottom).count { |y| ink[(y * width) + x] }.fdiv(bottom - top) }
1385
+ columns = longest.(column_ratios) or return nil
1386
+ left, right = left + columns[0], left + columns[1]
1387
+ row_ratios = (top...bottom).map { |y| (left...right).count { |x| ink[(y * width) + x] }.fdiv(right - left) }
1388
+ rows = longest.(row_ratios) or return nil
1389
+ [left, top + rows[0], right, top + rows[1]]
744
1390
  end
745
1391
 
746
- # 検出した行領域から列活性度を分析し、IllustrationRegion を構築する
747
- # 面積比やアスペクト比で候補をフィルタリングする
748
- def build_foreground_region(mask, group, image_width, image_height)
749
- top = group.fetch(:y)
750
- height = group.fetch(:height)
751
- slice = mask.crop(0, top, image_width, height)
752
- active_columns = column_activity(slice, image_width, height)
753
- return nil if active_columns.empty?
754
-
755
- left = [active_columns.min - 8, 0].max
756
- right = [active_columns.max + 9, image_width].min
757
- width = right - left
758
- candidate = IllustrationRegion.new(left:, top: [top - 8, 0].max, width:, height: [height + 16, image_height - top].min)
759
- expanded = expand_illustration_region(candidate, image_width, image_height)
760
- return nil unless illustration_region_candidate?(expanded, image_width, image_height)
761
-
762
- area_ratio = (expanded.width * expanded.height).to_f / (image_width * image_height)
763
- return nil if area_ratio < MIN_ILLUSTRATION_AREA_RATIO
1392
+ # 埋まった列・行にはさまれた、FIGURE_BRIDGE 本以内の少しは埋まった列・行を、埋まったものとみなす
1393
+ def bridge_sparse(flags, ratios)
1394
+ flags.each_index.chunk_while { |i, j| flags[i] == flags[j] }.each_with_object(flags.dup) do |run, bridged|
1395
+ next if flags[run.first] || run.size > FIGURE_BRIDGE || run.first.zero? || run.last == flags.size - 1
1396
+ next unless run.all? { ratios[it] >= FIGURE_SPARSE }
764
1397
 
765
- expanded
766
- rescue StandardError
767
- nil
768
- end
769
-
770
- # マスク画像のスライスから前景活性のある列インデックスを収集する
771
- def column_activity(slice, image_width, height)
772
- (0...image_width).each_with_object([]) do |x, active|
773
- column = slice.crop(x, 0, 1, height)
774
- activity = column.avg / 255.0
775
- active << x if activity >= COLUMN_ACTIVITY_THRESHOLD
1398
+ run.each { bridged[it] = true }
776
1399
  end
777
1400
  end
778
1401
 
779
- # イラスト領域にパディングを追加して拡張する
780
- def expand_illustration_region(region, image_width, image_height)
781
- padding_x = [((region.width * 0.04).round), ((image_width * 0.01).round)].max
782
- padding_y = [((region.height * 0.02).round), ((image_height * 0.005).round)].max
783
- left = [region.left - padding_x, 0].max
784
- top = [region.top - padding_y, 0].max
785
- right = [region.left + region.width + padding_x, image_width].min
786
- bottom = [region.top + region.height + padding_y, image_height].min
787
-
788
- IllustrationRegion.new(left:, top:, width: [right - left, 1].max, height: [bottom - top, 1].max)
789
- end
790
-
791
- # 領域がイラストの候補として妥当か(サイズ・アスペクト比)を判定する
792
- def illustration_region_candidate?(region, image_width, image_height)
793
- return false unless region.width.positive? && region.height.positive?
794
- return false if region.width < image_width * 0.35
795
- return false if region.height < image_height * 0.08
796
- return false if region.width * region.height < image_width * image_height * 0.035
1402
+ # 図とみなす枠か。ページの端に接せず、辺と面積が下限を超え、まとまりの画素が枠の半分以上を埋める
1403
+ def figure_box?(box, cells, width, height)
1404
+ left, top, right, bottom = box
1405
+ return false if left <= 1 || top <= 1 || right >= width - 1 || bottom >= height - 1
797
1406
 
798
- aspect_ratio = region.width.to_f / region.height
799
- aspect_ratio >= 1.05 && aspect_ratio <= 2.4
1407
+ side = FIGURE_MIN_SIDE * width
1408
+ area = (right - left) * (bottom - top)
1409
+ right - left >= side && bottom - top >= side && area >= FIGURE_MIN_AREA * width * height && cells >= area * 0.5
800
1410
  end
801
1411
 
802
1412
  # イラスト領域のピクセル座標を PDF ページ座標に変換し、ImageOccurrence を構築する
@@ -830,6 +1440,14 @@ module VivlioStarter
830
1440
  )
831
1441
  end
832
1442
 
1443
+ # ruby-vips と libvips が使えるか。使えなければ、画素の足りない画像は拡大せずに描き直しへ回す
1444
+ def vips_available?
1445
+ require_vips!
1446
+ true
1447
+ rescue Error
1448
+ false
1449
+ end
1450
+
833
1451
  # ruby-vips を安全にロードし、Vips モジュールを返す
834
1452
  def require_vips!
835
1453
  require "vips"
@@ -1015,7 +1633,7 @@ module VivlioStarter
1015
1633
  end
1016
1634
 
1017
1635
  # OCR テキストの後処理パイプライン
1018
- # 日本語スペース除去 → 断片結合 → 括弧正規化 → prh 辞書置換
1636
+ # 日本語スペース除去 → 断片結合 → 括弧正規化 → 誤認識の補正
1019
1637
  def postprocess_ocr_text(text)
1020
1638
  sanitized = sanitize(text)
1021
1639
  return "" if sanitized.empty?
@@ -1023,12 +1641,14 @@ module VivlioStarter
1023
1641
  sanitized = collapse_ocr_japanese_spaces(sanitized)
1024
1642
  sanitized = collapse_fragmented_words(sanitized)
1025
1643
  sanitized = normalize_ocr_brackets(sanitized)
1026
- sanitized = apply_prh_replacements(sanitized)
1644
+ sanitized = apply_ocr_corrections(sanitized)
1027
1645
  sanitize(sanitized)
1028
1646
  end
1029
1647
 
1030
1648
  # --- OCR テキスト補正用の文字クラス正規表現 ---
1031
- CJK_CHAR = /[\p{Han}\p{Hiragana}\p{Katakana}ー々〆ヵヶ]/.freeze
1649
+ # 足すのは `\p{...}` に入らない 2 文字だけ。々 は \p{Han}、ヵヶ は \p{Katakana} に
1650
+ # 含まれるため、並べると「character class has duplicated range」の警告が出る。
1651
+ CJK_CHAR = /[\p{Han}\p{Hiragana}\p{Katakana}ー〆]/.freeze
1032
1652
  JP_PUNCT = /[、。,.:;!?!?…]/.freeze
1033
1653
  JP_OPEN = /[「『(【《〈]/.freeze
1034
1654
  JP_CLOSE = /[」』)】》〉]/.freeze
@@ -1060,18 +1680,23 @@ module VivlioStarter
1060
1680
  end
1061
1681
  end
1062
1682
 
1063
- # prh 辞書の置換ルールをテキストに適用する
1064
- def apply_prh_replacements(text)
1065
- prh_replacements.reduce(text.to_s) do |memo, (matcher, expected)|
1683
+ # OCR の誤認識を補正する表を適用する。
1684
+ # 適用するのは OCR したページのテキストだけで、著者が書いた原稿には適用しない
1685
+ def apply_ocr_corrections(text)
1686
+ ocr_corrections.reduce(text.to_s) do |memo, (matcher, expected)|
1066
1687
  memo.gsub(matcher, expected)
1067
1688
  end
1068
1689
  end
1069
1690
 
1070
- # config/textlint_prh.yml から prh 置換ルールを読み込む(メモ化)
1071
- # 各ルールの patterns を Regexp にコンパイルし、[matcher, expected] の配列を返す
1072
- def prh_replacements
1073
- @prh_replacements ||= begin
1074
- path = File.join(Dir.pwd, "config", "textlint_prh.yml")
1691
+ # 補正表の既定の置き場。プロジェクトごとに編集するので gem には含めない
1692
+ OCR_CORRECTIONS_FILE = File.join("config", "ocr_corrections.yml")
1693
+
1694
+ # config/ocr_corrections.yml から補正ルールを読み込む(メモ化)。
1695
+ # 各ルールの patterns を Regexp にコンパイルし、[matcher, expected] の配列を返す。
1696
+ # ファイルが無ければ補正しない
1697
+ def ocr_corrections
1698
+ @ocr_corrections ||= begin
1699
+ path = File.join(Dir.pwd, OCR_CORRECTIONS_FILE)
1075
1700
  unless File.file?(path)
1076
1701
  []
1077
1702
  else
@@ -1081,7 +1706,7 @@ module VivlioStarter
1081
1706
  next [] if expected.empty?
1082
1707
 
1083
1708
  Array(rule["patterns"]).filter_map do |pattern|
1084
- matcher = compile_prh_pattern(pattern)
1709
+ matcher = compile_correction_pattern(pattern)
1085
1710
  matcher ? [matcher, expected] : nil
1086
1711
  end
1087
1712
  end
@@ -1091,9 +1716,9 @@ module VivlioStarter
1091
1716
  end
1092
1717
  end
1093
1718
 
1094
- # prh パターンを Regexp にコンパイルする
1719
+ # 補正パターンを Regexp にコンパイルする
1095
1720
  # "/pattern/" 形式は正規表現、それ以外はリテラルマッチ
1096
- def compile_prh_pattern(pattern)
1721
+ def compile_correction_pattern(pattern)
1097
1722
  case pattern
1098
1723
  in Regexp then pattern
1099
1724
  else
@@ -1165,25 +1790,26 @@ module VivlioStarter
1165
1790
  )
1166
1791
  end
1167
1792
 
1168
- # OCR モードを :auto / :always / :never に正規化する
1793
+ # OCR モードを :auto / :always / :never に正規化する。本体と book.yml は OCR を無効にする値を disable と書く
1169
1794
  def normalize_ocr_mode(value)
1170
1795
  case value.to_s.strip.downcase
1171
1796
  in "" | "auto" then :auto
1172
1797
  in "always" | "force" | "true" then :always
1173
- in "never" | "false" | "off" then :never
1798
+ in "never" | "disable" | "disabled" | "false" | "off" then :never
1174
1799
  else
1175
1800
  :auto
1176
1801
  end
1177
1802
  end
1178
1803
 
1179
- # OCR 言語指定を正規化する(配列化 + エイリアス解決 + 重複排除、既定は jpn)
1804
+ # OCR 言語指定を正規化する(配列化 + エイリアス解決 + 重複排除、既定は日本語と英語)
1180
1805
  def normalize_ocr_languages(value)
1181
1806
  raw = case value
1182
1807
  in Array then value
1183
1808
  else value.to_s.split(/[+,]/)
1184
1809
  end
1185
1810
  languages = raw.map { normalize_ocr_language_alias(it) }.reject(&:empty?)
1186
- languages = %w[jpn] if languages.empty?
1811
+ # 技術書はコードや英字の語を含むので、既定は日本語と英語にする
1812
+ languages = %w[jpn eng] if languages.empty?
1187
1813
  languages.uniq
1188
1814
  end
1189
1815
 
@@ -1193,6 +1819,7 @@ module VivlioStarter
1193
1819
  in "" then ""
1194
1820
  in "japanese" then "jpn"
1195
1821
  in "japanese_vertical" then "jpn_vert"
1822
+ in "english" then "eng"
1196
1823
  else value.to_s.strip
1197
1824
  end
1198
1825
  end
@@ -1240,13 +1867,10 @@ module VivlioStarter
1240
1867
  0.0
1241
1868
  end
1242
1869
 
1243
- # OCR モード設定値のアクセサ
1244
1870
  def ocr_mode = @ocr.mode
1245
1871
 
1246
- # イラスト内テキスト処理ポリシーのアクセサ
1247
1872
  def inline_image_text = @ocr.inline_image_text
1248
1873
 
1249
- # 同一行とみなす Y 座標差の閾値のアクセサ
1250
1874
  def line_merge_tolerance = @line_merge_tolerance
1251
1875
  end
1252
1876
  end