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
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# vivlio-starter-pdf
|
|
2
|
+
|
|
3
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
4
|
+
[](https://www.ruby-lang.org/)
|
|
5
|
+
|
|
6
|
+
**vivlio-starter** の AGPL 拡張プラグイン。HexaPDF を活用した高度な PDF 解析・後処理機能を提供します。
|
|
7
|
+
|
|
8
|
+
## 概要
|
|
9
|
+
|
|
10
|
+
vivlio-starter 本体(MIT)が提供する Standard Mode を拡張し、以下の出版向け機能を追加します。
|
|
11
|
+
|
|
12
|
+
| 機能 | 説明 |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **PDF → Markdown 変換** | HexaPDF でテキスト・画像を高精度に抽出し、Markdown に変換 |
|
|
15
|
+
| **画像抽出(WebP 化)** | PDF 内の XObject を解析し、WebP 形式で書き出し |
|
|
16
|
+
| **OCR 連携** | スキャン PDF を自動検出し、Tesseract で日本語 OCR を実行 |
|
|
17
|
+
| **OCR テキスト補正** | 日本語空白圧縮、括弧正規化、prh 辞書による誤読修正 |
|
|
18
|
+
| **隠しノンブル** | 入稿用 PDF の塗り足し領域にページ番号をオーバーレイ |
|
|
19
|
+
| **PDF アウトライン** | HTML 見出しを解析し、PDF のブックマークツリーを構築 |
|
|
20
|
+
|
|
21
|
+
## インストール
|
|
22
|
+
|
|
23
|
+
### gem としてインストール
|
|
24
|
+
|
|
25
|
+
```zsh
|
|
26
|
+
gem install vivlio-starter-pdf
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
インストール完了時に、OCR 機能を利用するために必要な外部ツールも案内されます。
|
|
30
|
+
|
|
31
|
+
### 外部ツール(OCR 利用時)
|
|
32
|
+
|
|
33
|
+
```zsh
|
|
34
|
+
brew install tesseract tesseract-lang poppler vips
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`vs pdf:read` を Standard Mode で実行した際にも、Enhanced Mode へ切り替えるためのインストール手順が自動案内されます。
|
|
38
|
+
|
|
39
|
+
## 使い方
|
|
40
|
+
|
|
41
|
+
### vivlio-starter との連携(プラグイン専用)
|
|
42
|
+
|
|
43
|
+
`vivlio-starter-pdf` は `vivlio-starter` の Enhanced Mode プラグインとしてのみ動作します。gem をインストールすると、`vs pdf:read` 実行時に HexaPDF / OCR ベースの拡張パイプラインへ自動切り替えされます。
|
|
44
|
+
|
|
45
|
+
```zsh
|
|
46
|
+
vs pdf:read document.pdf
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Ruby API
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "vivlio/starter/pdf"
|
|
53
|
+
|
|
54
|
+
# PDF → Markdown 変換
|
|
55
|
+
result = Vivlio::Starter::PDF::Reader.new("input.pdf",
|
|
56
|
+
ocr: { mode: "auto", languages: ["jpn"], dpi: 300 }
|
|
57
|
+
).execute
|
|
58
|
+
|
|
59
|
+
# アウトライン付与
|
|
60
|
+
provider = Vivlio::Starter::Pdf::EnhancedProvider.new
|
|
61
|
+
provider.add_outline!(pdf_path, items, max_level: 3)
|
|
62
|
+
|
|
63
|
+
# 隠しノンブル
|
|
64
|
+
provider.stamp_nombre!(pdf_path, bleed_pt: 8.5)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 設定
|
|
68
|
+
|
|
69
|
+
`config/book.yml` の `pdf_read` セクションで挙動を制御できます。
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
pdf_read:
|
|
73
|
+
text_area:
|
|
74
|
+
top_margin: 18
|
|
75
|
+
bottom_margin: 20
|
|
76
|
+
inner_margin: 15
|
|
77
|
+
outer_margin: 12
|
|
78
|
+
page_separator: false
|
|
79
|
+
ocr:
|
|
80
|
+
mode: auto
|
|
81
|
+
languages:
|
|
82
|
+
- japanese
|
|
83
|
+
dpi: 300
|
|
84
|
+
psm: 3
|
|
85
|
+
inline_image_text: include
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 依存ライブラリ
|
|
89
|
+
|
|
90
|
+
| ライブラリ | バージョン | 用途 |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| [HexaPDF](https://hexapdf.gettalong.org/) | ~> 1.0 | PDF 解析・編集 |
|
|
93
|
+
| [ruby-vips](https://github.com/libvips/ruby-vips) | ~> 2.2 | 高速画像処理 |
|
|
94
|
+
|
|
95
|
+
### 外部ツール(任意)
|
|
96
|
+
|
|
97
|
+
| ツール | 用途 |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| Tesseract | OCR エンジン |
|
|
100
|
+
| poppler(pdftoppm) | PDF → 画像変換 |
|
|
101
|
+
| libvips | 画像処理バックエンド |
|
|
102
|
+
|
|
103
|
+
## 開発
|
|
104
|
+
|
|
105
|
+
```zsh
|
|
106
|
+
git clone https://github.com/Atelier-Mirai/vivlio-starter-pdf.git
|
|
107
|
+
cd vivlio-starter-pdf
|
|
108
|
+
bundle install
|
|
109
|
+
bundle exec rake test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## ライセンス
|
|
113
|
+
|
|
114
|
+
[GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE)
|
|
115
|
+
|
|
116
|
+
vivlio-starter 本体(MIT)とは異なるライセンスです。HexaPDF の AGPL ライセンスに準拠しています。
|
|
117
|
+
|
|
118
|
+
## 作者
|
|
119
|
+
|
|
120
|
+
[Atelier Mirai](https://github.com/Atelier-Mirai)
|
data/RELEASE_NOTE.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# vivlio-starter-pdf 1.0.0 Release Note
|
|
2
|
+
|
|
3
|
+
## 🎉 最初のメジャーリリース
|
|
4
|
+
|
|
5
|
+
vivlio-starter-pdf 1.0.0 をリリースできることを大変嬉しく思います。これは **vivlio-starter** の AGPL 拡張プラグインで、HexaPDF を活用した高度な PDF 解析・後処理機能を提供します。
|
|
6
|
+
|
|
7
|
+
## 🚀 主要機能
|
|
8
|
+
|
|
9
|
+
### PDF → Markdown 変換
|
|
10
|
+
- HexaPDF でテキスト・画像を高精度に抽出
|
|
11
|
+
- 精密な座標解析による行・段落の再構成
|
|
12
|
+
- Markdown 形式での構造化出力
|
|
13
|
+
|
|
14
|
+
### OCR 連携と日本語対応 🆕
|
|
15
|
+
- スキャン PDF を自動検出
|
|
16
|
+
- Tesseract による日本語 OCR 実行
|
|
17
|
+
- OCR テキストの空白圧縮と括弧正規化
|
|
18
|
+
- prh 辞書による誤読修正
|
|
19
|
+
|
|
20
|
+
### 画像抽出と位置合わせ
|
|
21
|
+
- PDF 内の XObject を解析し、WebP 形式で書き出し
|
|
22
|
+
- テキスト行と画像の精密な座標マッピング
|
|
23
|
+
- イラスト領域の自動検出と除外
|
|
24
|
+
|
|
25
|
+
### 出版向け機能
|
|
26
|
+
- **隠しノンブル**: 入稿用 PDF の塗り足し領域にページ番号をオーバーレイ
|
|
27
|
+
- **PDF アウトライン**: HTML 見出しを解析し、PDF のブックマークツリーを構築
|
|
28
|
+
|
|
29
|
+
## 📦 インストール
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# Gemfile
|
|
33
|
+
gem 'vivlio-starter-pdf', '~> 1.0.0'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 外部ツール(OCR 利用時)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
brew install tesseract tesseract-lang poppler vips
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 💡 使用例
|
|
47
|
+
|
|
48
|
+
### vivlio-starter との連携(プラグイン専用)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Enhanced Mode で自動的に HexaPDF/OCR パイプラインを使用
|
|
52
|
+
vs pdf:read document.pdf
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Ruby API
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
require "vivlio/starter/pdf"
|
|
59
|
+
|
|
60
|
+
# PDF → Markdown 変換
|
|
61
|
+
result = Vivlio::Starter::PDF::Reader.new("input.pdf",
|
|
62
|
+
ocr: { mode: "auto", languages: ["jpn"], dpi: 300 }
|
|
63
|
+
).execute
|
|
64
|
+
|
|
65
|
+
# アウトライン付与
|
|
66
|
+
provider = Vivlio::Starter::Pdf::EnhancedProvider.new
|
|
67
|
+
provider.add_outline!(pdf_path, items, max_level: 3)
|
|
68
|
+
|
|
69
|
+
# 隠しノンブル
|
|
70
|
+
provider.stamp_nombre!(pdf_path, bleed_pt: 8.5)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 🔧 設定
|
|
74
|
+
|
|
75
|
+
`config/book.yml` の `pdf_read` セクションで挙動を制御:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
pdf_read:
|
|
79
|
+
text_area:
|
|
80
|
+
top_margin: 18
|
|
81
|
+
bottom_margin: 20
|
|
82
|
+
inner_margin: 15
|
|
83
|
+
outer_margin: 12
|
|
84
|
+
page_separator: false
|
|
85
|
+
ocr:
|
|
86
|
+
mode: auto
|
|
87
|
+
languages:
|
|
88
|
+
- japanese
|
|
89
|
+
dpi: 300
|
|
90
|
+
psm: 3
|
|
91
|
+
inline_image_text: include
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## ✅ テスト品質
|
|
95
|
+
|
|
96
|
+
- **網羅的なテストスイート**
|
|
97
|
+
- **実環境での動作検証**
|
|
98
|
+
- **外部ツール連携のテスト**
|
|
99
|
+
|
|
100
|
+
## 🔄 0.1.0 からの変更点
|
|
101
|
+
|
|
102
|
+
### 新機能
|
|
103
|
+
- 完全な OCR 連携と日本語対応
|
|
104
|
+
- 画像位置合わせの精密化
|
|
105
|
+
- PDF アウトライン生成機能
|
|
106
|
+
- 隠しノンブル機能
|
|
107
|
+
|
|
108
|
+
### 改善
|
|
109
|
+
- gemspec の RubyGems 公開設定
|
|
110
|
+
- ドキュメントの整備
|
|
111
|
+
- 外部ツールの自動案内
|
|
112
|
+
|
|
113
|
+
## 🎯 実績
|
|
114
|
+
|
|
115
|
+
- **vivlio-starter** Enhanced Mode で実稼働
|
|
116
|
+
- **日本語出版** 向けの最適化完了
|
|
117
|
+
- **HexaPDF** との高度な連携実現
|
|
118
|
+
- **AGPL-3.0** ライセンス準拠
|
|
119
|
+
|
|
120
|
+
## 📋 互換性
|
|
121
|
+
|
|
122
|
+
- **Ruby**: 4.0+
|
|
123
|
+
- **依存**: hexapdf (~> 1.0), ruby-vips (~> 2.2)
|
|
124
|
+
- **外部ツール**: Tesseract, poppler, libvips(OCR 使用時)
|
|
125
|
+
- **セマンティックバージョニング**: 1.x.x 系は後方互換性を保証
|
|
126
|
+
|
|
127
|
+
## 🙏 感謝
|
|
128
|
+
|
|
129
|
+
vivlio-starter-pdf の開発にあたり、HexaPDF の強力な PDF 処理能力と、日本語出版の現場でのニーズが大きな助けとなりました。特に OCR 連携と日本語テキスト処理についての知見は、本プラグインをより実用的なものにする上で不可欠でした。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
**vivlio-starter-pdf 1.0.0**: 高度な PDF 処理のための強力な拡張プラグイン
|
data/Rakefile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'vivlio/starter/pdf'
|
|
5
|
+
|
|
6
|
+
# vivlio-starter-pdf CLI
|
|
7
|
+
# Provides basic version information for debugging and user confirmation
|
|
8
|
+
|
|
9
|
+
module Vivlio
|
|
10
|
+
module Starter
|
|
11
|
+
module PDF
|
|
12
|
+
class CLI
|
|
13
|
+
def self.run(args = [])
|
|
14
|
+
case args[0]
|
|
15
|
+
when '--version', '-v'
|
|
16
|
+
puts "vivlio-starter-pdf #{VERSION}"
|
|
17
|
+
when '--help', '-h', nil
|
|
18
|
+
puts <<~HELP
|
|
19
|
+
vivlio-starter-pdf #{VERSION} - Advanced PDF processor for vivlio-starter
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
vivlio-starter-pdf [options]
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--version, -v Print version and exit
|
|
26
|
+
--help, -h Print this help and exit
|
|
27
|
+
|
|
28
|
+
Description:
|
|
29
|
+
vivlio-starter-pdf is an AGPL-licensed plugin for vivlio-starter
|
|
30
|
+
that provides advanced PDF processing capabilities including:
|
|
31
|
+
- PDF to Markdown conversion using HexaPDF
|
|
32
|
+
- Japanese OCR support with Tesseract
|
|
33
|
+
- Image extraction and precise positioning
|
|
34
|
+
- PDF outline generation and hidden pagination
|
|
35
|
+
|
|
36
|
+
This is a plugin and should be used through vivlio-starter:
|
|
37
|
+
vs pdf:read document.pdf
|
|
38
|
+
|
|
39
|
+
For more information, visit: https://github.com/Atelier-Mirai/vivlio-starter-pdf
|
|
40
|
+
HELP
|
|
41
|
+
else
|
|
42
|
+
puts "Error: Unknown option '#{args[0]}'"
|
|
43
|
+
puts "Use 'vivlio-starter-pdf --help' for usage information."
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Vivlio::Starter::PDF::CLI.run(ARGV)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hexapdf"
|
|
4
|
+
require_relative "utilities"
|
|
5
|
+
require_relative "log_helper"
|
|
6
|
+
require_relative "outline_writer"
|
|
7
|
+
|
|
8
|
+
module Vivlio
|
|
9
|
+
module Starter
|
|
10
|
+
module Pdf
|
|
11
|
+
# vivlio-starter 本体から呼び出される HexaPDF ベースのプロバイダ
|
|
12
|
+
#
|
|
13
|
+
# 隠しノンブル書き込み・PDF アウトライン付与など、
|
|
14
|
+
# Enhanced Mode 固有の PDF 操作を提供する。
|
|
15
|
+
class EnhancedProvider
|
|
16
|
+
# 隠しノンブルに使用するフォント名
|
|
17
|
+
FONT_NAME = "Helvetica"
|
|
18
|
+
# 隠しノンブルのフォントサイズ(pt)
|
|
19
|
+
FONT_SIZE_PT = 6
|
|
20
|
+
|
|
21
|
+
# PDF のページ数を取得する
|
|
22
|
+
def page_count(pdf_path)
|
|
23
|
+
Utilities.page_count(pdf_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 空白ページ PDF が存在しなければ生成する
|
|
27
|
+
def ensure_blank_page_pdf(path, width_pt, height_pt)
|
|
28
|
+
Utilities.ensure_blank_page_pdf(path, width_pt, height_pt)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# PDF の各ページに隠しノンブル(ページ番号)を書き込む
|
|
32
|
+
# 奇数ページは左端、偶数ページは右端に 90° 回転して配置する
|
|
33
|
+
# @param pdf_path [String] 対象 PDF のパス
|
|
34
|
+
# @param bleed_pt [Float] 塗り足し幅(pt)
|
|
35
|
+
# @return [Boolean] 成功なら true
|
|
36
|
+
def stamp_nombre!(pdf_path, bleed_pt:)
|
|
37
|
+
return false unless File.exist?(pdf_path)
|
|
38
|
+
|
|
39
|
+
document = HexaPDF::Document.open(pdf_path)
|
|
40
|
+
total = document.pages.count
|
|
41
|
+
return false if total.zero?
|
|
42
|
+
|
|
43
|
+
LogHelper.log_action("[NombreStamper] 隠しノンブルを書き込みます(#{total} ページ)[Enhanced Mode]…")
|
|
44
|
+
|
|
45
|
+
document.pages.each_with_index do |page, idx|
|
|
46
|
+
stamp_page(page, idx + 1, bleed_pt: bleed_pt.to_f)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
document.write(pdf_path, optimize: true)
|
|
50
|
+
LogHelper.log_success("[NombreStamper] 隠しノンブル書き込み完了(#{total} ページ)")
|
|
51
|
+
true
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
LogHelper.log_error("[NombreStamper] 隠しノンブル書き込みに失敗: #{e.message}")
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# PDF にアウトライン(しおり)を付与する
|
|
58
|
+
# OutlineWriter を使い、階層構造を HexaPDF のアウトラインツリーに変換する
|
|
59
|
+
# @param pdf_path [String] 対象 PDF のパス
|
|
60
|
+
# @param items [Array<Hash>] アウトライン項目(:level, :text, :page)
|
|
61
|
+
# @param max_level [Integer] アウトラインの最大階層深度
|
|
62
|
+
# @return [Boolean] 成功なら true
|
|
63
|
+
def add_outline!(pdf_path, items, max_level:)
|
|
64
|
+
return false unless File.exist?(pdf_path)
|
|
65
|
+
|
|
66
|
+
document = HexaPDF::Document.open(pdf_path)
|
|
67
|
+
writer = OutlineWriter.new(document, max_level:, on_skip: method(:log_outline_skip))
|
|
68
|
+
inserted = writer.write(items)
|
|
69
|
+
if inserted.zero?
|
|
70
|
+
LogHelper.log_warn("[OutlineWriter] 有効なアウトライン項目が存在しないためスキップしました")
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
document.write(pdf_path, optimize: true)
|
|
75
|
+
LogHelper.log_success("[OutlineWriter] PDF にアウトラインを #{inserted} 件追加しました")
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
LogHelper.log_error("[OutlineWriter] PDF アウトライン付与に失敗: #{e.message}")
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# 1 ページに隠しノンブルを描画する
|
|
85
|
+
# 奇数ページは左端 90°、偶数ページは右端 -90° に回転配置する
|
|
86
|
+
def stamp_page(page, page_number, bleed_pt:)
|
|
87
|
+
canvas = page.canvas(type: :overlay)
|
|
88
|
+
box = page.box(:media)
|
|
89
|
+
|
|
90
|
+
canvas.font(FONT_NAME, size: FONT_SIZE_PT)
|
|
91
|
+
canvas.fill_color(0)
|
|
92
|
+
|
|
93
|
+
x_offset = bleed_pt / 2.0
|
|
94
|
+
y_center = box.height / 2.0
|
|
95
|
+
|
|
96
|
+
if page_number.odd?
|
|
97
|
+
draw_rotated_text(canvas, page_number.to_s, x: x_offset, y: y_center, angle: 90)
|
|
98
|
+
else
|
|
99
|
+
draw_rotated_text(canvas, page_number.to_s, x: box.width - x_offset, y: y_center, angle: -90)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# キャンバス上の指定座標にテキストを回転描画する
|
|
104
|
+
def draw_rotated_text(canvas, text, x:, y:, angle:)
|
|
105
|
+
canvas
|
|
106
|
+
.save_graphics_state
|
|
107
|
+
.translate(x, y)
|
|
108
|
+
.rotate(angle)
|
|
109
|
+
.text(text, at: [0, 0])
|
|
110
|
+
.restore_graphics_state
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# OutlineWriter のスキップ通知をログに出力するコールバック
|
|
114
|
+
def log_outline_skip(message)
|
|
115
|
+
LogHelper.log_warn("[OutlineWriter] #{message}")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vivlio
|
|
4
|
+
module Starter
|
|
5
|
+
module Pdf
|
|
6
|
+
# Logging helper that integrates with vivlio-starter CLI when available
|
|
7
|
+
module LogHelper
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def log_action(message)
|
|
11
|
+
dispatch(:log_action, message) { puts(message) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def log_info(message)
|
|
15
|
+
dispatch(:log_info, message) { puts(message) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def log_success(message)
|
|
19
|
+
dispatch(:log_success, message) { puts(message) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_warn(message)
|
|
23
|
+
dispatch(:log_warn, message) { warn(message) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def log_error(message)
|
|
27
|
+
dispatch(:log_error, message) { warn(message) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dispatch(method, message)
|
|
31
|
+
if defined?(Vivlio::Starter::CLI::Common)
|
|
32
|
+
Vivlio::Starter::CLI::Common.public_send(method, message)
|
|
33
|
+
else
|
|
34
|
+
yield if block_given?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hexapdf"
|
|
4
|
+
|
|
5
|
+
module Vivlio
|
|
6
|
+
module Starter
|
|
7
|
+
module Pdf
|
|
8
|
+
# Internal helper responsible for translating outline entries into HexaPDF structures.
|
|
9
|
+
class OutlineWriter
|
|
10
|
+
# @param doc [HexaPDF::Document] target document
|
|
11
|
+
# @param max_level [Integer] maximum depth allowed in the outline tree
|
|
12
|
+
# @param on_skip [Proc,nil] callback triggered when an entry is ignored
|
|
13
|
+
def initialize(doc, max_level:, on_skip: nil)
|
|
14
|
+
@doc = doc
|
|
15
|
+
@max_level = max_level.to_i.clamp(1, 6)
|
|
16
|
+
@page_count = @doc.pages.count
|
|
17
|
+
@on_skip = on_skip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Writes the given +items+ into the document outline.
|
|
21
|
+
# @param items [Array<Hash>] entries with :level, :text, :page
|
|
22
|
+
# @return [Integer] number of inserted outline nodes
|
|
23
|
+
def write(items)
|
|
24
|
+
return 0 if @page_count.zero?
|
|
25
|
+
|
|
26
|
+
normalized = Array(items).filter_map { normalize_entry(it) }
|
|
27
|
+
return 0 if normalized.empty?
|
|
28
|
+
|
|
29
|
+
outline_root = rebuild_outline_root
|
|
30
|
+
stack = [{ level: 0, node: outline_root }]
|
|
31
|
+
inserted = 0
|
|
32
|
+
|
|
33
|
+
normalized.each do |entry|
|
|
34
|
+
parent = parent_for(entry[:level], stack)
|
|
35
|
+
destination = resolve_destination(entry[:page])
|
|
36
|
+
unless destination
|
|
37
|
+
warn_skip("page #{entry[:page]} is outside 1-#{@page_count}")
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
item = parent.add_item(entry[:text], destination:, open: entry[:level] < @max_level)
|
|
42
|
+
stack << { level: entry[:level], node: item }
|
|
43
|
+
inserted += 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if inserted.zero?
|
|
47
|
+
@doc.catalog.delete(:Outlines)
|
|
48
|
+
return 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
inserted
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Normalizes a raw hash into a consistent entry structure.
|
|
57
|
+
def normalize_entry(raw)
|
|
58
|
+
text = fetch(raw, :text, fetch(raw, :title, nil)).to_s.strip
|
|
59
|
+
warn_skip("missing title for outline entry: #{raw.inspect}") if text.empty?
|
|
60
|
+
return nil if text.empty?
|
|
61
|
+
|
|
62
|
+
page = fetch(raw, :page, nil).to_i
|
|
63
|
+
if page <= 0
|
|
64
|
+
warn_skip("invalid page for outline entry: #{raw.inspect}")
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
level = fetch(raw, :level, 1).to_i
|
|
69
|
+
level = 1 if level < 1
|
|
70
|
+
level = @max_level if level > @max_level
|
|
71
|
+
|
|
72
|
+
{ text:, page:, level: }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Fetches a value from the entry by symbol or string key.
|
|
76
|
+
def fetch(raw, key, fallback)
|
|
77
|
+
if raw.respond_to?(:[]) && (raw[key] || raw[key.to_s])
|
|
78
|
+
raw[key] || raw[key.to_s]
|
|
79
|
+
else
|
|
80
|
+
fallback
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the proper parent node for the requested level.
|
|
85
|
+
def parent_for(level, stack)
|
|
86
|
+
while stack.last[:level] >= level
|
|
87
|
+
break if stack.size == 1
|
|
88
|
+
stack.pop
|
|
89
|
+
end
|
|
90
|
+
stack.last[:node]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Resolves the destination page for an entry.
|
|
94
|
+
def resolve_destination(page_number)
|
|
95
|
+
index = page_number.to_i - 1
|
|
96
|
+
return nil unless index.between?(0, @page_count - 1)
|
|
97
|
+
|
|
98
|
+
@doc.pages[index]
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Recreates an empty outline root before inserting new nodes.
|
|
104
|
+
def rebuild_outline_root
|
|
105
|
+
@doc.catalog.delete(:Outlines)
|
|
106
|
+
outline_root = @doc.add({ Type: :Outlines }, type: :Outlines)
|
|
107
|
+
@doc.catalog[:Outlines] = outline_root
|
|
108
|
+
outline_root
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Emits skip notifications through the provided callback.
|
|
112
|
+
def warn_skip(message)
|
|
113
|
+
@on_skip&.call(message)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hexapdf"
|
|
4
|
+
|
|
5
|
+
module Vivlio
|
|
6
|
+
module Starter
|
|
7
|
+
module Pdf
|
|
8
|
+
# HexaPDF を使った PDF 共通ユーティリティ
|
|
9
|
+
module Utilities
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# PDF のページ数を取得する
|
|
13
|
+
# @param file [String]
|
|
14
|
+
# @return [Integer, nil]
|
|
15
|
+
def page_count(file)
|
|
16
|
+
return nil unless File.exist?(file)
|
|
17
|
+
|
|
18
|
+
if system("which pdfinfo >/dev/null 2>&1")
|
|
19
|
+
info = `pdfinfo "#{file}" 2>/dev/null`
|
|
20
|
+
pages = info[/^Pages:\s+(\d+)/i, 1]
|
|
21
|
+
return pages.to_i if pages
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
doc = HexaPDF::Document.open(file)
|
|
25
|
+
doc.pages.count
|
|
26
|
+
rescue StandardError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 空白ページ PDF を生成する
|
|
31
|
+
# @param path [String]
|
|
32
|
+
# @param width_pt [Float]
|
|
33
|
+
# @param height_pt [Float]
|
|
34
|
+
def ensure_blank_page_pdf(path, width_pt, height_pt)
|
|
35
|
+
return path if File.exist?(path)
|
|
36
|
+
|
|
37
|
+
doc = HexaPDF::Document.new
|
|
38
|
+
doc.pages.add([0, 0, width_pt, height_pt])
|
|
39
|
+
doc.write(path, optimize: true)
|
|
40
|
+
path
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|