vivlio-pdf 0.2.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.
data/README.ja.md ADDED
@@ -0,0 +1,127 @@
1
+ # vivlio-pdf
2
+
3
+ CSS 組版による PDF 生成を Ruby から行う gem です。
4
+
5
+ [English README is here](./README.md)
6
+
7
+ ローカルの Chrome/Chromium を CDP / [ferrum](https://github.com/rubycdp/ferrum) 経由で起動し、同梱の [Vivliostyle Viewer](https://github.com/vivliostyle/vivliostyle.js) を使って CSS Paged Media(柱・ノンブル・目次リーダー・PDF しおり) に対応した印刷用PDFを出力します。
8
+
9
+ Node.js には依存せず、Ruby 単体で動作します。
10
+
11
+ ## 必要環境
12
+
13
+ - Ruby >= 3.1
14
+ - Chrome / Chromium (ローカルにインストール済みであること)
15
+ - macOS / Linux (Windows はパスの `file://` URL 化が未対応)
16
+
17
+ ## インストール
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem 'vivlio-pdf', github: 'takahashim/vivlio-pdf'
22
+ ```
23
+
24
+ ## 使い方
25
+
26
+ ```ruby
27
+ require 'vivlio/pdf'
28
+
29
+ # 単発変換
30
+ begin
31
+ result = Vivlio::PDF.print(
32
+ source: 'book/OEBPS/package.opf', # HTML / 展開済みEPUBのOPF / webpub manifest
33
+ output: 'book.pdf',
34
+ outline: :toc, # :toc(既定) / :headings / :none
35
+ metadata: { title: 'Vivliostyleで技術書をかこう!', author: 'takahashim' }
36
+ )
37
+ rescue Vivlio::PDF::Error => e
38
+ # 変換の失敗はすべてこの派生(TimeoutError / RenderError など)で届きます。
39
+ # ブラウザ駆動の内部例外(Ferrum)がそのまま漏れてくることはありません。
40
+ abort e.message
41
+ end
42
+
43
+ result.pages #=> 120
44
+ result.bookmarks #=> 79(PDF に実際に入ったしおりの数)
45
+ result.warnings #=> [](目次が読めなかった等、変換は続行した問題)
46
+ result.to_s #=> "book.pdf"(文字列としても振る舞う)
47
+
48
+ # 複数変換(ブラウザを使い回す)
49
+ Vivlio::PDF::Printer.open do |printer|
50
+ printer.print(source: 'a.html', output: 'a.pdf', style: 'print.css')
51
+ printer.print(source: 'b.html', output: 'b.pdf')
52
+ end
53
+ ```
54
+
55
+ #### 主なオプション
56
+
57
+ - `browser_path:` Chrome 実行ファイルのパス(省略時は自動検出)
58
+ - `viewer:` 同梱以外の Vivliostyle Viewer(パスまたは `Viewer` オブジェクト)
59
+ - `timeout:` レンダリング待ちの上限秒(既定 300)
60
+ - `process_timeout:` Chrome 起動待ちの上限秒(既定 30)
61
+ - `style:` 追加スタイルシートのパス(複数可)
62
+ - `book_mode:` 目次/spine をたどって全体を読む(既定: OPF/manifest なら true)
63
+
64
+ ## 構成
65
+
66
+ - `Printer`: ブラウザを保有し、変換全体を差配する
67
+ - `Viewer`: Viewer の所在と、文書を開く URL の組み立て
68
+ - `Source`: 変換対象の文書 (HTML / OPF / manifest)
69
+ - `Session`: 開かれた1文書用のセッション。描画完了待ち・目次取得・PDF化などに使用
70
+ - `Outline::{Toc,Headings,None}`: しおりの生成
71
+ - `TocItem`: 目次の木構造 (値オブジェクト)
72
+ - `Metadata`: 文書情報辞書に書く値 (値オブジェクト)
73
+ - `Document`: 出力 PDF。hexapdf でしおり・メタデータを書き込む
74
+ - `Result`: 変換結果(パス・ページ数・しおり数)
75
+
76
+ ## PDF しおりの仕組み
77
+
78
+ `outline: :toc` では vivliostyle-cli と同じ方式を使います。
79
+
80
+ 印刷前に目次リンクを DOM に表示して Chromium に名前付きデスティネーションを埋め込ませ、`coreViewer.getTOC()` の木構造から [hexapdf](https://hexapdf.gettalong.org/) で /Outlines を構築します。
81
+ ページ番号の計算は行いません。
82
+
83
+ ## ライセンス
84
+
85
+ AGPL-3.0-or-later。詳細は [LICENSE](./LICENSE.txt) を参照してください。
86
+
87
+ - `vendor/viewer/` には [@vivliostyle/viewer](https://www.npmjs.com/package/@vivliostyle/viewer)(AGPL-3.0)を同梱しています。
88
+ 対応するソースコードは[vivliostyle/vivliostyle.js](https://github.com/vivliostyle/vivliostyle.js)の該当バージョンタグから入手できます。
89
+ - 依存gemのライセンスは ferrum は MIT、hexapdf は AGPL-3.0 です。
90
+
91
+ ※ 本 gem で生成した PDF は AGPL の対象外です(ソフトウェア自体の配布・ネットワーク提供時のみ義務が発生します)
92
+
93
+ ## 同梱している Viewer のバージョン
94
+
95
+ 同梱中のバージョンは `vendor/viewer/package.json` に記録されており、実行時は
96
+ `Vivlio::PDF::Viewer.default.version` で参照できます。
97
+
98
+ Viewer は依存ではなく同梱物なので、gem のバージョンを固定すれば紙面が固定されます。
99
+ 本のリポジトリで `Gemfile.lock` を維持していれば、あとから組み直しても同じ PDF が得られます。
100
+ 入稿前に `bundle update` しないでください。
101
+
102
+ Viewer が変わるとページ送りが変わることがあります。テストが通っても変わります。
103
+ そのため本 gem では、Viewer の更新を含むリリースは patch では出しません(最低でも minor)。
104
+
105
+ 別のバージョンを使いたい場合は、gem を待たずに差し替えられます。
106
+
107
+ ```ruby
108
+ Vivlio::PDF.print(source: 'book.opf', output: 'book.pdf',
109
+ viewer: '/path/to/vivliostyle-viewer')
110
+ ```
111
+
112
+ ## Viewer の更新方法
113
+
114
+ ```console
115
+ $ rake "viewer:update[2.45.0]"
116
+ ```
117
+
118
+ バージョンは同梱の `package.json` から読むので、他に更新すべき箇所はありません。
119
+ ソースマップは同梱しません(実行時に不要で、他のファイルの合計より大きいため)。
120
+ Viewer の TypeScript を読みたいときは、npm の tarball から
121
+ `vendor/viewer/js/` に手で置いてください。`.gitignore` で除外してあります。
122
+
123
+ 新しいリリースは GitHub Actions が週次で検出し、更新用のプルリクエストを起票します
124
+ (`.github/workflows/update-viewer.yml`)。自動マージはしません。
125
+ `test/test_viewer_behavior.rb` は Viewer の挙動を固定したテストで、
126
+ ここが落ちている場合は不具合ではなく Viewer 側の挙動が変わったことを意味します。
127
+ 回避策を外せるようになった可能性があるので、内容を確認してください。
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # vivlio-pdf
2
+
3
+ CSS typesetting from Ruby: a gem that renders print-quality PDFs.
4
+
5
+ [日本語版 README はこちら](./README.ja.md)
6
+
7
+ It drives a local Chrome/Chromium over CDP via [ferrum](https://github.com/rubycdp/ferrum), loading documents into the bundled [Vivliostyle Viewer](https://github.com/vivliostyle/vivliostyle.js) to produce PDFs with full CSS Paged Media support — running heads, page numbers, TOC leaders, and PDF bookmarks.
8
+
9
+ No Node.js required; it runs on Ruby alone.
10
+
11
+ ## Requirements
12
+
13
+ - Ruby >= 3.1
14
+ - Chrome / Chromium (installed locally)
15
+ - macOS / Linux (Windows is not supported: paths are not converted to `file://` URLs correctly)
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem 'vivlio-pdf', github: 'takahashim/vivlio-pdf'
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```ruby
27
+ require 'vivlio/pdf'
28
+
29
+ # One-shot conversion
30
+ begin
31
+ result = Vivlio::PDF.print(
32
+ source: 'book/OEBPS/package.opf', # HTML / OPF of an unzipped EPUB / webpub manifest
33
+ output: 'book.pdf',
34
+ outline: :toc, # :toc (default) / :headings / :none
35
+ metadata: { title: 'Writing Books with Vivliostyle', author: 'takahashim' }
36
+ )
37
+ rescue Vivlio::PDF::Error => e
38
+ # Every conversion failure arrives as a subclass of this
39
+ # (TimeoutError, RenderError, and so on). The browser-driving
40
+ # internals (Ferrum) never leak their own exceptions.
41
+ abort e.message
42
+ end
43
+
44
+ result.pages #=> 120
45
+ result.bookmarks #=> 79 (bookmarks actually present in the PDF)
46
+ result.warnings #=> [] (problems the conversion survived, e.g. an unreadable TOC)
47
+ result.to_s #=> "book.pdf" (also acts as a string)
48
+
49
+ # Several conversions, reusing one browser
50
+ Vivlio::PDF::Printer.open do |printer|
51
+ printer.print(source: 'a.html', output: 'a.pdf', style: 'print.css')
52
+ printer.print(source: 'b.html', output: 'b.pdf')
53
+ end
54
+ ```
55
+
56
+ #### Main options
57
+
58
+ - `browser_path:` path to the Chrome executable (auto-detected when omitted)
59
+ - `viewer:` a Vivliostyle Viewer other than the bundled one (a path or a `Viewer` object)
60
+ - `timeout:` upper limit in seconds to wait for rendering (default 300)
61
+ - `process_timeout:` upper limit in seconds to wait for Chrome to start (default 30)
62
+ - `style:` additional stylesheet path(s)
63
+ - `book_mode:` follow the TOC/spine and read the whole publication (default: true for OPF/manifest)
64
+
65
+ ## Architecture
66
+
67
+ - `Printer`: owns the browser and orchestrates conversions
68
+ - `Viewer`: locates the viewer and builds the URL that opens a document
69
+ - `Source`: the document to convert (HTML / OPF / manifest)
70
+ - `Session`: one open document; waits for rendering, reads the TOC, prints to PDF
71
+ - `Outline::{Toc,Headings,None}`: bookmark generation strategies
72
+ - `TocItem`: the TOC tree (value object)
73
+ - `Metadata`: values written into the document information dictionary (value object)
74
+ - `Document`: the output PDF; writes bookmarks and metadata via hexapdf
75
+ - `Result`: the outcome of a conversion (path, page count, bookmark count)
76
+
77
+ ## How PDF bookmarks work
78
+
79
+ With `outline: :toc`, the same approach as vivliostyle-cli is used.
80
+
81
+ Before printing, the TOC links are made visible in the DOM so that Chromium embeds named destinations for them; the tree from `coreViewer.getTOC()` is then written as `/Outlines` with [hexapdf](https://hexapdf.gettalong.org/).
82
+ No page numbers are computed.
83
+
84
+ ## License
85
+
86
+ AGPL-3.0-or-later. See [LICENSE](./LICENSE.txt) for details.
87
+
88
+ - `vendor/viewer/` bundles [@vivliostyle/viewer](https://www.npmjs.com/package/@vivliostyle/viewer) (AGPL-3.0).
89
+ The corresponding source code is available from the matching version tag of [vivliostyle/vivliostyle.js](https://github.com/vivliostyle/vivliostyle.js).
90
+ - Dependency licenses: ferrum is MIT, hexapdf is AGPL-3.0.
91
+
92
+ Note: PDFs produced with this gem are not subject to the AGPL. The obligations apply only to distributing the software itself or offering it over a network.
93
+
94
+ ## The bundled viewer version
95
+
96
+ The bundled version is recorded in `vendor/viewer/package.json` and can be read at run time via `Vivlio::PDF::Viewer.default.version`.
97
+
98
+ The viewer is vendored, not a dependency, so pinning the gem version pins your page layout.
99
+ As long as your book's repository keeps its `Gemfile.lock`, rebuilding later produces the same PDF.
100
+ Do not `bundle update` right before going to press.
101
+
102
+ A viewer change can move lines between pages — even when every test passes.
103
+ For that reason, a release that updates the viewer is never a patch release (minor at minimum).
104
+
105
+ To use a different viewer version without waiting for a gem release:
106
+
107
+ ```ruby
108
+ Vivlio::PDF.print(source: 'book.opf', output: 'book.pdf',
109
+ viewer: '/path/to/vivliostyle-viewer')
110
+ ```
111
+
112
+ ## Updating the viewer
113
+
114
+ ```console
115
+ $ rake "viewer:update[2.45.0]"
116
+ ```
117
+
118
+ The version is read from the bundled `package.json`, so nothing else needs to be kept in step.
119
+ Source maps are not vendored (they are unused at run time and larger than everything else combined).
120
+ To read the viewer's TypeScript, place the map into `vendor/viewer/js/` by hand from the npm tarball; `.gitignore` keeps it out of the way.
121
+
122
+ A GitHub Actions workflow checks for new releases weekly and opens an update pull request
123
+ (`.github/workflows/update-viewer.yml`). It never merges automatically.
124
+ `test/test_viewer_behavior.rb` pins the viewer's behaviour: a failure there is not a bug —
125
+ it means the viewer's behaviour changed, possibly in a way that lets a workaround be
126
+ removed, so read what it found.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hexapdf'
4
+
5
+ module Vivlio
6
+ module PDF
7
+ # A PDF file on disk, opened for post-processing.
8
+ #
9
+ # Everything here happens after Chromium has printed: only the document
10
+ # information dictionary and the bookmark outline are touched. Page
11
+ # positions are never computed -- outline entries point at the named
12
+ # destinations Chromium already embedded for in-document link targets.
13
+ class Document
14
+ # Saves only when the block returns normally: a conversion that raises
15
+ # half way through leaves the file as Chromium printed it rather than
16
+ # committing whichever edits happened to be applied first.
17
+ def self.open(path, &block)
18
+ document = new(path)
19
+ return document unless block
20
+
21
+ result = block.call(document)
22
+ document.save
23
+ result
24
+ end
25
+
26
+ def initialize(path)
27
+ @path = path
28
+ @pdf = HexaPDF::Document.open(path)
29
+ @dirty = false
30
+ end
31
+
32
+ attr_reader :path
33
+
34
+ def page_count
35
+ @pdf.pages.count
36
+ end
37
+
38
+ # Bookmarks actually present, counted from the PDF itself so the number
39
+ # is right whether we wrote them or Chromium did. Asking HexaPDF for the
40
+ # outline would create one, so a document without bookmarks is answered
41
+ # without touching it.
42
+ def bookmark_count
43
+ return 0 unless @pdf.catalog.key?(:Outlines)
44
+
45
+ @pdf.outline.each_item.count
46
+ end
47
+
48
+ def metadata=(metadata)
49
+ metadata.write_to(@pdf.trailer.info)
50
+ @dirty = true
51
+ end
52
+
53
+ # +entries+ is a TocItem forest; nested items become nested bookmarks.
54
+ def outline=(entries)
55
+ return if entries.empty?
56
+
57
+ add_bookmarks(entries, @pdf.outline)
58
+ @dirty = true
59
+ end
60
+
61
+ def save
62
+ return false unless @dirty
63
+
64
+ @pdf.write(@path, optimize: true)
65
+ @dirty = false
66
+ true
67
+ end
68
+
69
+ private
70
+
71
+ def add_bookmarks(entries, parent)
72
+ entries.each do |entry|
73
+ bookmark = parent.add_item(entry.label, destination: destination_for(entry))
74
+ add_bookmarks(entry.children, bookmark)
75
+ end
76
+ end
77
+
78
+ # Prefer the named destination Chromium created for the anchor; fall back
79
+ # to the first page so an entry never dangles if the anchor was missing
80
+ # from the DOM at print time.
81
+ def destination_for(entry)
82
+ return first_page_destination unless entry.id
83
+ return entry.id if @pdf.destinations.resolve(entry.id)
84
+
85
+ first_page_destination
86
+ end
87
+
88
+ def first_page_destination
89
+ [@pdf.pages[0], :Fit]
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Vivlio
6
+ module PDF
7
+ # A file on disk the browser will be pointed at: a path that exists, and
8
+ # the file:// URL naming it.
9
+ #
10
+ # Everything the viewer loads -- the document, its stylesheets, the
11
+ # viewer's own index.html -- is one of these. +kind+ only names the thing
12
+ # in the error raised when it is missing.
13
+ class LocalFile
14
+ attr_reader :path, :url
15
+
16
+ def self.coerce(value)
17
+ value.is_a?(self) ? value : new(value)
18
+ end
19
+
20
+ def initialize(path, kind: 'file')
21
+ @path = File.expand_path(path.to_s)
22
+ raise Error, "#{kind} not found: #{@path}" unless File.exist?(@path)
23
+
24
+ @url = file_url(@path)
25
+ freeze
26
+ end
27
+
28
+ def to_s
29
+ path
30
+ end
31
+
32
+ private
33
+
34
+ # Each segment is escaped separately so that the separators survive.
35
+ def file_url(absolute)
36
+ "file://#{absolute.split('/').map { |segment| URI.encode_uri_component(segment) }.join('/')}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vivlio
4
+ module PDF
5
+ # Values written into the PDF document information dictionary.
6
+ class Metadata
7
+ FIELDS = { title: :Title, author: :Author, subject: :Subject,
8
+ keywords: :Keywords, creator: :Creator,
9
+ creation_date: :CreationDate }.freeze
10
+
11
+ attr_reader :title, :author, :subject, :keywords, :creator, :creation_date
12
+
13
+ # Accepts a Metadata, a Hash of the fields above, or nil.
14
+ def self.coerce(value)
15
+ case value
16
+ when Metadata then value
17
+ when nil then new
18
+ when Hash then new(**value)
19
+ else raise ArgumentError, "cannot coerce #{value.class} into Metadata"
20
+ end
21
+ end
22
+
23
+ def initialize(title: nil, author: nil, subject: nil, keywords: nil,
24
+ creator: nil, creation_date: nil)
25
+ @title = title
26
+ @author = author
27
+ @subject = subject
28
+ @keywords = keywords
29
+ @creator = creator
30
+ @creation_date = creation_date
31
+ freeze
32
+ end
33
+
34
+ def to_h
35
+ FIELDS.keys.to_h { |name| [name, public_send(name)] }.compact
36
+ end
37
+
38
+ # True when there is nothing to write, i.e. no reason to rewrite the PDF
39
+ # for metadata alone.
40
+ def empty?
41
+ to_h.empty?
42
+ end
43
+
44
+ # Names what produced the PDF, unless the caller named it themselves.
45
+ # Deciding that string is Printer's job: only it knows which viewer
46
+ # actually did the rendering.
47
+ def with_creator(creator)
48
+ return self if @creator
49
+
50
+ Metadata.new(**to_h, creator: creator)
51
+ end
52
+
53
+ # +info+ is a HexaPDF document information dictionary.
54
+ def write_to(info)
55
+ FIELDS.each do |name, key|
56
+ value = public_send(name)
57
+ info[key] = value if value
58
+ end
59
+ info
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vivlio
4
+ module PDF
5
+ # How PDF bookmarks are produced. Each strategy answers two questions:
6
+ # whether Chromium should build the outline itself while printing, and
7
+ # which TOC entries (if any) we write afterwards with HexaPDF.
8
+ module Outline
9
+ # Accepts a Strategy, a mode name, or a boolean for callers who think of
10
+ # bookmarks as something they simply turn on or off.
11
+ def self.resolve(mode)
12
+ case mode
13
+ when Strategy then mode
14
+ when :toc, 'toc', nil, true then Toc.new
15
+ when :headings, 'headings' then Headings.new
16
+ when :none, 'none', false then None.new
17
+ else raise ArgumentError, "unknown outline mode: #{mode.inspect}"
18
+ end
19
+ end
20
+
21
+ class Strategy
22
+ # Set Page.printToPDF's generateDocumentOutline.
23
+ def chromium_generated?
24
+ false
25
+ end
26
+
27
+ # TocItems to write into the PDF after printing.
28
+ def entries(_session)
29
+ []
30
+ end
31
+
32
+ def to_s
33
+ self.class.name.split('::').last.downcase
34
+ end
35
+ end
36
+
37
+ # Bookmarks mirroring the publication's own table of contents, resolved
38
+ # through the named destinations Chromium embeds for the TOC links.
39
+ class Toc < Strategy
40
+ def entries(session)
41
+ session.toc
42
+ end
43
+ end
44
+
45
+ # Bookmarks Chromium derives from the h1-h6 outline. Cheaper, but the
46
+ # depth cannot be controlled.
47
+ class Headings < Strategy
48
+ def chromium_generated?
49
+ true
50
+ end
51
+ end
52
+
53
+ class None < Strategy
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ferrum'
4
+
5
+ module Vivlio
6
+ module PDF
7
+ # Renders documents to PDF through a browser running the Vivliostyle Viewer.
8
+ #
9
+ # A Printer owns one browser instance. Reuse it across conversions and
10
+ # close it when done, or use the block form which closes it for you:
11
+ #
12
+ # Vivlio::PDF::Printer.open do |printer|
13
+ # printer.print(source: 'a.html', output: 'a.pdf')
14
+ # printer.print(source: 'b.html', output: 'b.pdf')
15
+ # end
16
+ class Printer
17
+ DEFAULT_TIMEOUT = 300
18
+
19
+ # How long to wait for Chrome to boot and report its websocket URL. This
20
+ # is Ferrum's :process_timeout, separate from :timeout above -- the latter
21
+ # bounds rendering, this one bounds startup. Ferrum defaults it to 10s,
22
+ # which a loaded CI runner occasionally overruns; 30s absorbs that.
23
+ DEFAULT_PROCESS_TIMEOUT = 30
24
+
25
+ # Chromium refuses XHR against file:// without this, and the viewer
26
+ # fetches the source document that way. Build-tool usage only.
27
+ BROWSER_OPTIONS = { 'allow-file-access-from-files' => nil }.freeze
28
+
29
+ # The optional keywords of .new and of #print: the option surface
30
+ # Vivlio::PDF.print routes and validates against. Spelled out rather
31
+ # than derived from the signatures, because these are public API and a
32
+ # parameter rename should not quietly become an API change. A unit test
33
+ # fails if either signature drifts away from its list.
34
+ SETUP_OPTIONS = %i[viewer browser_path timeout process_timeout].freeze
35
+ PRINT_OPTIONS = %i[outline metadata book_mode style].freeze
36
+
37
+ # What one pass through the viewer produced, besides the PDF bytes.
38
+ Rendering = Struct.new(:entries, :warnings)
39
+
40
+ attr_reader :viewer, :timeout
41
+
42
+ def self.open(**options)
43
+ printer = new(**options)
44
+ return printer unless block_given?
45
+
46
+ begin
47
+ yield printer
48
+ ensure
49
+ printer.close
50
+ end
51
+ end
52
+
53
+ def initialize(viewer: nil, browser_path: nil, timeout: DEFAULT_TIMEOUT,
54
+ process_timeout: DEFAULT_PROCESS_TIMEOUT)
55
+ @viewer = Viewer.coerce(viewer)
56
+ @timeout = timeout
57
+ @process_timeout = process_timeout
58
+ @browser_path = browser_path
59
+ end
60
+
61
+ # Renders +source+ and writes a PDF to +output+, returning a Result.
62
+ def print(source:, output:, outline: :toc, metadata: nil, book_mode: nil, style: nil)
63
+ source = Source.coerce(source)
64
+ strategy = Outline.resolve(outline)
65
+ metadata = Metadata.coerce(metadata)
66
+ book_mode = source.publication? if book_mode.nil?
67
+ url = @viewer.url_for(source, book_mode: book_mode, style: style)
68
+
69
+ StagedFile.write(output) do |staged|
70
+ rendering = render(url, strategy, staged)
71
+ pages, bookmarks = finalize(staged, metadata: metadata, entries: rendering.entries)
72
+ Result.new(path: output, pages: pages, bookmarks: bookmarks,
73
+ outline: rendering.entries, warnings: rendering.warnings)
74
+ end
75
+ end
76
+
77
+ def browser
78
+ @browser ||= PDF.translate_browser_errors do
79
+ Ferrum::Browser.new(
80
+ headless: true,
81
+ timeout: @timeout,
82
+ process_timeout: @process_timeout,
83
+ browser_path: @browser_path,
84
+ browser_options: BROWSER_OPTIONS
85
+ )
86
+ end
87
+ end
88
+
89
+ def close
90
+ PDF.translate_browser_errors { @browser&.quit }
91
+ @browser = nil
92
+ end
93
+
94
+ private
95
+
96
+ # Lays the document out and writes the printed bytes to +staged+.
97
+ def render(url, strategy, staged)
98
+ with_session(url) do |session|
99
+ entries = strategy.entries(session)
100
+ File.binwrite(staged, session.to_pdf(generate_outline: strategy.chromium_generated?))
101
+ Rendering.new(entries, session.warnings)
102
+ end
103
+ end
104
+
105
+ def with_session(url)
106
+ page = PDF.translate_browser_errors { browser.create_page }
107
+ session = Session.open(page, url, timeout: @timeout)
108
+ begin
109
+ yield session
110
+ ensure
111
+ session.close
112
+ end
113
+ end
114
+
115
+ # Applies metadata and bookmarks, and reports what the finished PDF holds.
116
+ def finalize(output, metadata:, entries:)
117
+ Document.open(output) do |document|
118
+ document.metadata = metadata.with_creator(creator) unless metadata.empty?
119
+ document.outline = entries
120
+ [document.page_count, document.bookmark_count]
121
+ end
122
+ end
123
+
124
+ # How the PDF says it was made. The viewer version is read off the viewer
125
+ # actually in use, so it stays true when a caller supplies their own.
126
+ def creator
127
+ "vivlio-pdf #{VERSION} (Vivliostyle Viewer #{@viewer.version || 'unknown'})"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vivlio
4
+ module PDF
5
+ # The outcome of one conversion.
6
+ #
7
+ # Behaves like the output path in string contexts, so callers that only
8
+ # care where the file landed can keep treating the return value as one.
9
+ class Result
10
+ # +outline+ is the table of contents we read from the viewer, empty when
11
+ # the bookmarks came from Chromium instead. +bookmarks+ counts what the
12
+ # PDF really contains, so it holds for every outline mode.
13
+ attr_reader :path, :pages, :bookmarks, :outline, :warnings
14
+
15
+ def initialize(path:, pages:, bookmarks:, outline: [], warnings: [])
16
+ @path = path.to_s
17
+ @pages = pages
18
+ @bookmarks = bookmarks
19
+ @outline = outline.freeze
20
+ @warnings = warnings.freeze
21
+ freeze
22
+ end
23
+
24
+ def size
25
+ File.size(path)
26
+ end
27
+
28
+ def to_s
29
+ path
30
+ end
31
+ alias to_str to_s
32
+
33
+ def inspect
34
+ "#<#{self.class} #{path.inspect} pages=#{pages} bookmarks=#{bookmarks}>"
35
+ end
36
+ end
37
+ end
38
+ end