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.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # vivlio-starter-pdf
2
+
3
+ [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
4
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-red.svg)](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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -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