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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65b529f996ccbfdab12d4a410404206e4580126c6e75dcbc1b45fb88c6044241
4
- data.tar.gz: 6b8b71dbf5f5f622cca5dc4328016a71b0c29b3fa72dacb83914daae5abd4ac8
3
+ metadata.gz: e62753237dedde7804b194cc7a2c06e0ca662f3b7fe2e002478880908fd02357
4
+ data.tar.gz: fcc0002eb6f708ee15e15e9a641c0dadca9ace84b557eff8f47cae2a303dcc8c
5
5
  SHA512:
6
- metadata.gz: 01cd3cebf924395a018536bfff12940f6990c1051c2a782c48ff56bfb397c3c363d26c33c2b6e6abfdead321e884e0a9ec2da4189f4cbc656271d25c95c3d309
7
- data.tar.gz: 4dc1e2d408d87ebd5738fc697338de5c55d7f8f62d6f38224774e263c392fb4a70e67ebb2f9354c3d76a747229f204c151173c1852f7a481d0d4149713f3e550
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
@@ -1,52 +1,50 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'vivlio/starter/pdf'
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 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
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
- Vivlio::Starter::PDF::CLI.run(ARGV)
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