vivlio-starter-pdf 1.0.1 → 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/Rakefile +15 -0
- data/exe/vivlio-starter-pdf +37 -39
- data/lib/vivlio_starter/cli/pdf/enhanced_provider.rb +118 -0
- data/lib/vivlio_starter/cli/pdf/log_helper.rb +38 -0
- data/lib/vivlio_starter/cli/pdf/outline_writer.rb +116 -0
- data/lib/vivlio_starter/cli/pdf/reader.rb +1253 -0
- data/lib/vivlio_starter/cli/pdf/utilities.rb +43 -0
- data/lib/vivlio_starter/cli/pdf/version.rb +7 -0
- data/lib/{vivlio/starter → vivlio_starter}/pdf.rb +5 -7
- data/vivlio-starter-pdf.gemspec +2 -2
- metadata +9 -10
- data/lib/vivlio/starter/cli/pdf/enhanced_provider.rb +0 -120
- data/lib/vivlio/starter/cli/pdf/log_helper.rb +0 -40
- data/lib/vivlio/starter/cli/pdf/outline_writer.rb +0 -118
- data/lib/vivlio/starter/cli/pdf/utilities.rb +0 -45
- data/lib/vivlio/starter/pdf/reader.rb +0 -1255
- data/lib/vivlio/starter/pdf/utilities.rb +0 -11
- data/lib/vivlio/starter/pdf/version.rb +0 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e62753237dedde7804b194cc7a2c06e0ca662f3b7fe2e002478880908fd02357
|
|
4
|
+
data.tar.gz: fcc0002eb6f708ee15e15e9a641c0dadca9ace84b557eff8f47cae2a303dcc8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e4a64d5e1e49dea7345e0411621bc09ee9d0e89de0413d56f7e894443510bcb68a18a47083e56fdece15352ec29a087abcfb1830852ebd8ae5a9d4db673f351
|
|
7
|
+
data.tar.gz: 27f71113236fdec22d5982fccdcf66527a5b1e4b389344dadc8bf3893a82f4ae6d5305b216f45dde7230c412c08e6f719f84a43d59889aee20135e1764cff8b8
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- 本体側に合わせ、lib/vivlio_starter/cli/pdf/配下にファイル構成を調整した。
|
|
12
|
+
|
|
8
13
|
## [1.0.1] - 2026-03-21
|
|
9
14
|
|
|
10
15
|
### Added
|
data/Rakefile
CHANGED
|
@@ -10,3 +10,18 @@ Rake::TestTask.new(:test) do |t|
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task default: :test
|
|
13
|
+
|
|
14
|
+
# gem のアンインストール → ビルド → インストールを一括実行
|
|
15
|
+
task :reinstall do
|
|
16
|
+
gemspec = Dir['*.gemspec'].first
|
|
17
|
+
raise 'gemspec が見つかりません' unless gemspec
|
|
18
|
+
|
|
19
|
+
require_relative 'lib/vivlio_starter/pdf/version'
|
|
20
|
+
version = VivlioStarter::Pdf::VERSION
|
|
21
|
+
gem_name = 'vivlio-starter-pdf'
|
|
22
|
+
gem_file = "#{gem_name}-#{version}.gem"
|
|
23
|
+
|
|
24
|
+
sh "gem uninstall #{gem_name} --version #{version} --executables --ignore-dependencies 2>/dev/null || true"
|
|
25
|
+
sh "gem build #{gemspec}"
|
|
26
|
+
sh "gem install #{gem_file}"
|
|
27
|
+
end
|
data/exe/vivlio-starter-pdf
CHANGED
|
@@ -1,52 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require '
|
|
4
|
+
require 'vivlio_starter/pdf'
|
|
5
5
|
|
|
6
6
|
# vivlio-starter-pdf CLI
|
|
7
7
|
# Provides basic version information for debugging and user confirmation
|
|
8
8
|
|
|
9
|
-
module
|
|
10
|
-
module
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
exit 1
|
|
45
|
-
end
|
|
9
|
+
module VivlioStarter
|
|
10
|
+
module Pdf
|
|
11
|
+
class CLI
|
|
12
|
+
def self.run(args = [])
|
|
13
|
+
case args[0]
|
|
14
|
+
when '--version', '-v'
|
|
15
|
+
puts "vivlio-starter-pdf #{VERSION}"
|
|
16
|
+
when '--help', '-h', nil
|
|
17
|
+
puts <<~HELP
|
|
18
|
+
vivlio-starter-pdf #{VERSION} - Advanced PDF processor for vivlio-starter
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
vivlio-starter-pdf [options]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--version, -v Print version and exit
|
|
25
|
+
--help, -h Print this help and exit
|
|
26
|
+
|
|
27
|
+
Description:
|
|
28
|
+
vivlio-starter-pdf is an AGPL-licensed plugin for vivlio-starter
|
|
29
|
+
that provides advanced PDF processing capabilities including:
|
|
30
|
+
- PDF to Markdown conversion using HexaPDF
|
|
31
|
+
- Japanese OCR support with Tesseract
|
|
32
|
+
- Image extraction and precise positioning
|
|
33
|
+
- PDF outline generation and hidden pagination
|
|
34
|
+
|
|
35
|
+
This is a plugin and should be used through vivlio-starter:
|
|
36
|
+
vs pdf:read document.pdf
|
|
37
|
+
|
|
38
|
+
For more information, visit: https://github.com/Atelier-Mirai/vivlio-starter-pdf
|
|
39
|
+
HELP
|
|
40
|
+
else
|
|
41
|
+
puts "Error: Unknown option '#{args[0]}'"
|
|
42
|
+
puts "Use 'vivlio-starter-pdf --help' for usage information."
|
|
43
|
+
exit 1
|
|
46
44
|
end
|
|
47
45
|
end
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
end
|
|
51
49
|
|
|
52
|
-
|
|
50
|
+
VivlioStarter::Pdf::CLI.run(ARGV)
|
|
@@ -0,0 +1,118 @@
|
|
|
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 VivlioStarter
|
|
9
|
+
module Pdf
|
|
10
|
+
# vivlio-starter 本体から呼び出される HexaPDF ベースのプロバイダ
|
|
11
|
+
#
|
|
12
|
+
# 隠しノンブル書き込み・PDF アウトライン付与など、
|
|
13
|
+
# Enhanced Mode 固有の PDF 操作を提供する。
|
|
14
|
+
class EnhancedProvider
|
|
15
|
+
# 隠しノンブルに使用するフォント名
|
|
16
|
+
FONT_NAME = "Helvetica"
|
|
17
|
+
# 隠しノンブルのフォントサイズ(pt)
|
|
18
|
+
FONT_SIZE_PT = 6
|
|
19
|
+
|
|
20
|
+
# PDF のページ数を取得する
|
|
21
|
+
def page_count(pdf_path)
|
|
22
|
+
Utilities.page_count(pdf_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 空白ページ PDF が存在しなければ生成する
|
|
26
|
+
def ensure_blank_page_pdf(path, width_pt, height_pt)
|
|
27
|
+
Utilities.ensure_blank_page_pdf(path, width_pt, height_pt)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# PDF の各ページに隠しノンブル(ページ番号)を書き込む
|
|
31
|
+
# 奇数ページは左端、偶数ページは右端に 90° 回転して配置する
|
|
32
|
+
# @param pdf_path [String] 対象 PDF のパス
|
|
33
|
+
# @param bleed_pt [Float] 塗り足し幅(pt)
|
|
34
|
+
# @return [Boolean] 成功なら true
|
|
35
|
+
def stamp_nombre!(pdf_path, bleed_pt:)
|
|
36
|
+
return false unless File.exist?(pdf_path)
|
|
37
|
+
|
|
38
|
+
document = HexaPDF::Document.open(pdf_path)
|
|
39
|
+
total = document.pages.count
|
|
40
|
+
return false if total.zero?
|
|
41
|
+
|
|
42
|
+
LogHelper.log_action("[NombreStamper] 隠しノンブルを書き込みます(#{total} ページ)[Enhanced Mode]…")
|
|
43
|
+
|
|
44
|
+
document.pages.each_with_index do |page, idx|
|
|
45
|
+
stamp_page(page, idx + 1, bleed_pt: bleed_pt.to_f)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
document.write(pdf_path, optimize: true)
|
|
49
|
+
LogHelper.log_success("[NombreStamper] 隠しノンブル書き込み完了(#{total} ページ)")
|
|
50
|
+
true
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
LogHelper.log_error("[NombreStamper] 隠しノンブル書き込みに失敗: #{e.message}")
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# PDF にアウトライン(しおり)を付与する
|
|
57
|
+
# OutlineWriter を使い、階層構造を HexaPDF のアウトラインツリーに変換する
|
|
58
|
+
# @param pdf_path [String] 対象 PDF のパス
|
|
59
|
+
# @param items [Array<Hash>] アウトライン項目(:level, :text, :page)
|
|
60
|
+
# @param max_level [Integer] アウトラインの最大階層深度
|
|
61
|
+
# @return [Boolean] 成功なら true
|
|
62
|
+
def add_outline!(pdf_path, items, max_level:)
|
|
63
|
+
return false unless File.exist?(pdf_path)
|
|
64
|
+
|
|
65
|
+
document = HexaPDF::Document.open(pdf_path)
|
|
66
|
+
writer = OutlineWriter.new(document, max_level:, on_skip: method(:log_outline_skip))
|
|
67
|
+
inserted = writer.write(items)
|
|
68
|
+
if inserted.zero?
|
|
69
|
+
LogHelper.log_warn("[OutlineWriter] 有効なアウトライン項目が存在しないためスキップしました")
|
|
70
|
+
return false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
document.write(pdf_path, optimize: true)
|
|
74
|
+
LogHelper.log_success("[OutlineWriter] PDF にアウトラインを #{inserted} 件追加しました")
|
|
75
|
+
true
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
LogHelper.log_error("[OutlineWriter] PDF アウトライン付与に失敗: #{e.message}")
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# 1 ページに隠しノンブルを描画する
|
|
84
|
+
# 奇数ページは左端 90°、偶数ページは右端 -90° に回転配置する
|
|
85
|
+
def stamp_page(page, page_number, bleed_pt:)
|
|
86
|
+
canvas = page.canvas(type: :overlay)
|
|
87
|
+
box = page.box(:media)
|
|
88
|
+
|
|
89
|
+
canvas.font(FONT_NAME, size: FONT_SIZE_PT)
|
|
90
|
+
canvas.fill_color(0)
|
|
91
|
+
|
|
92
|
+
x_offset = bleed_pt / 2.0
|
|
93
|
+
y_center = box.height / 2.0
|
|
94
|
+
|
|
95
|
+
if page_number.odd?
|
|
96
|
+
draw_rotated_text(canvas, page_number.to_s, x: x_offset, y: y_center, angle: 90)
|
|
97
|
+
else
|
|
98
|
+
draw_rotated_text(canvas, page_number.to_s, x: box.width - x_offset, y: y_center, angle: -90)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# キャンバス上の指定座標にテキストを回転描画する
|
|
103
|
+
def draw_rotated_text(canvas, text, x:, y:, angle:)
|
|
104
|
+
canvas
|
|
105
|
+
.save_graphics_state
|
|
106
|
+
.translate(x, y)
|
|
107
|
+
.rotate(angle)
|
|
108
|
+
.text(text, at: [0, 0])
|
|
109
|
+
.restore_graphics_state
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# OutlineWriter のスキップ通知をログに出力するコールバック
|
|
113
|
+
def log_outline_skip(message)
|
|
114
|
+
LogHelper.log_warn("[OutlineWriter] #{message}")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VivlioStarter
|
|
4
|
+
module Pdf
|
|
5
|
+
# Logging helper that integrates with vivlio-starter CLI when available
|
|
6
|
+
module LogHelper
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def log_action(message)
|
|
10
|
+
dispatch(:log_action, message) { puts(message) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def log_info(message)
|
|
14
|
+
dispatch(:log_info, message) { puts(message) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_success(message)
|
|
18
|
+
dispatch(:log_success, message) { puts(message) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def log_warn(message)
|
|
22
|
+
dispatch(:log_warn, message) { warn(message) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def log_error(message)
|
|
26
|
+
dispatch(:log_error, message) { warn(message) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dispatch(method, message)
|
|
30
|
+
if defined?(VivlioStarter::CLI::Common)
|
|
31
|
+
VivlioStarter::CLI::Common.public_send(method, message)
|
|
32
|
+
else
|
|
33
|
+
yield if block_given?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hexapdf"
|
|
4
|
+
|
|
5
|
+
module VivlioStarter
|
|
6
|
+
module Pdf
|
|
7
|
+
# Internal helper responsible for translating outline entries into HexaPDF structures.
|
|
8
|
+
class OutlineWriter
|
|
9
|
+
# @param doc [HexaPDF::Document] target document
|
|
10
|
+
# @param max_level [Integer] maximum depth allowed in the outline tree
|
|
11
|
+
# @param on_skip [Proc,nil] callback triggered when an entry is ignored
|
|
12
|
+
def initialize(doc, max_level:, on_skip: nil)
|
|
13
|
+
@doc = doc
|
|
14
|
+
@max_level = max_level.to_i.clamp(1, 6)
|
|
15
|
+
@page_count = @doc.pages.count
|
|
16
|
+
@on_skip = on_skip
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Writes the given +items+ into the document outline.
|
|
20
|
+
# @param items [Array<Hash>] entries with :level, :text, :page
|
|
21
|
+
# @return [Integer] number of inserted outline nodes
|
|
22
|
+
def write(items)
|
|
23
|
+
return 0 if @page_count.zero?
|
|
24
|
+
|
|
25
|
+
normalized = Array(items).filter_map { normalize_entry(it) }
|
|
26
|
+
return 0 if normalized.empty?
|
|
27
|
+
|
|
28
|
+
outline_root = rebuild_outline_root
|
|
29
|
+
stack = [{ level: 0, node: outline_root }]
|
|
30
|
+
inserted = 0
|
|
31
|
+
|
|
32
|
+
normalized.each do |entry|
|
|
33
|
+
parent = parent_for(entry[:level], stack)
|
|
34
|
+
destination = resolve_destination(entry[:page])
|
|
35
|
+
unless destination
|
|
36
|
+
warn_skip("page #{entry[:page]} is outside 1-#{@page_count}")
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
item = parent.add_item(entry[:text], destination:, open: entry[:level] < @max_level)
|
|
41
|
+
stack << { level: entry[:level], node: item }
|
|
42
|
+
inserted += 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if inserted.zero?
|
|
46
|
+
@doc.catalog.delete(:Outlines)
|
|
47
|
+
return 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
inserted
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Normalizes a raw hash into a consistent entry structure.
|
|
56
|
+
def normalize_entry(raw)
|
|
57
|
+
text = fetch(raw, :text, fetch(raw, :title, nil)).to_s.strip
|
|
58
|
+
warn_skip("missing title for outline entry: #{raw.inspect}") if text.empty?
|
|
59
|
+
return nil if text.empty?
|
|
60
|
+
|
|
61
|
+
page = fetch(raw, :page, nil).to_i
|
|
62
|
+
if page <= 0
|
|
63
|
+
warn_skip("invalid page for outline entry: #{raw.inspect}")
|
|
64
|
+
return nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
level = fetch(raw, :level, 1).to_i
|
|
68
|
+
level = 1 if level < 1
|
|
69
|
+
level = @max_level if level > @max_level
|
|
70
|
+
|
|
71
|
+
{ text:, page:, level: }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fetches a value from the entry by symbol or string key.
|
|
75
|
+
def fetch(raw, key, fallback)
|
|
76
|
+
if raw.respond_to?(:[]) && (raw[key] || raw[key.to_s])
|
|
77
|
+
raw[key] || raw[key.to_s]
|
|
78
|
+
else
|
|
79
|
+
fallback
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the proper parent node for the requested level.
|
|
84
|
+
def parent_for(level, stack)
|
|
85
|
+
while stack.last[:level] >= level
|
|
86
|
+
break if stack.size == 1
|
|
87
|
+
stack.pop
|
|
88
|
+
end
|
|
89
|
+
stack.last[:node]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Resolves the destination page for an entry.
|
|
93
|
+
def resolve_destination(page_number)
|
|
94
|
+
index = page_number.to_i - 1
|
|
95
|
+
return nil unless index.between?(0, @page_count - 1)
|
|
96
|
+
|
|
97
|
+
@doc.pages[index]
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Recreates an empty outline root before inserting new nodes.
|
|
103
|
+
def rebuild_outline_root
|
|
104
|
+
@doc.catalog.delete(:Outlines)
|
|
105
|
+
outline_root = @doc.add({ Type: :Outlines }, type: :Outlines)
|
|
106
|
+
@doc.catalog[:Outlines] = outline_root
|
|
107
|
+
outline_root
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Emits skip notifications through the provided callback.
|
|
111
|
+
def warn_skip(message)
|
|
112
|
+
@on_skip&.call(message)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|