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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +661 -0
- data/README.md +120 -0
- data/RELEASE_NOTE.md +133 -0
- data/Rakefile +12 -0
- data/exe/vivlio-starter-pdf +52 -0
- data/lib/vivlio/starter/cli/pdf/enhanced_provider.rb +120 -0
- data/lib/vivlio/starter/cli/pdf/log_helper.rb +40 -0
- data/lib/vivlio/starter/cli/pdf/outline_writer.rb +118 -0
- data/lib/vivlio/starter/cli/pdf/utilities.rb +45 -0
- data/lib/vivlio/starter/pdf/reader.rb +1255 -0
- data/lib/vivlio/starter/pdf/utilities.rb +11 -0
- data/lib/vivlio/starter/pdf/version.rb +11 -0
- data/lib/vivlio/starter/pdf.rb +15 -0
- data/vivlio-starter-pdf.gemspec +49 -0
- metadata +136 -0
|
@@ -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 << ""
|
|
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 [""] if caption.empty?
|
|
957
|
+
|
|
958
|
+
["", "> #{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
|