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.
- checksums.yaml +7 -0
- data/LICENSE.txt +661 -0
- data/README.ja.md +127 -0
- data/README.md +126 -0
- data/lib/vivlio/pdf/document.rb +93 -0
- data/lib/vivlio/pdf/local_file.rb +40 -0
- data/lib/vivlio/pdf/metadata.rb +63 -0
- data/lib/vivlio/pdf/outline.rb +57 -0
- data/lib/vivlio/pdf/printer.rb +131 -0
- data/lib/vivlio/pdf/result.rb +38 -0
- data/lib/vivlio/pdf/session.rb +118 -0
- data/lib/vivlio/pdf/source.rb +24 -0
- data/lib/vivlio/pdf/staged_file.rb +40 -0
- data/lib/vivlio/pdf/toc_item.rb +52 -0
- data/lib/vivlio/pdf/version.rb +7 -0
- data/lib/vivlio/pdf/viewer.rb +79 -0
- data/lib/vivlio/pdf.rb +69 -0
- data/vendor/viewer/LICENSE +661 -0
- data/vendor/viewer/css/ui.arrows.css +1 -0
- data/vendor/viewer/css/ui.loading-overlay.css +1 -0
- data/vendor/viewer/css/ui.menu-bar.css +1 -0
- data/vendor/viewer/css/ui.message-dialog.css +1 -0
- data/vendor/viewer/css/ui.text-selection-menu.css +1 -0
- data/vendor/viewer/css/vivliostyle-viewer.css +1 -0
- data/vendor/viewer/fonts/fa-solid-900.woff2 +0 -0
- data/vendor/viewer/index.html +362 -0
- data/vendor/viewer/js/vivliostyle-viewer.js +651 -0
- data/vendor/viewer/package.json +93 -0
- data/vendor/viewer/resources/mathjax-config.js +43 -0
- data/vendor/viewer/resources/vivliostyle-icon.png +0 -0
- data/vendor/viewer/resources/vivliostyle-logo.svg +26 -0
- metadata +100 -0
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
|