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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vivlio
|
|
4
|
+
module PDF
|
|
5
|
+
# One document open in the viewer, inside one browser tab.
|
|
6
|
+
#
|
|
7
|
+
# Wraps the CDP page so callers talk about rendering and printing rather
|
|
8
|
+
# than about JavaScript evaluation, and so Ferrum's exceptions stop here.
|
|
9
|
+
# A Session is single-use: open it, read from it, print it, close it.
|
|
10
|
+
class Session
|
|
11
|
+
READY_STATE = <<~JS
|
|
12
|
+
window.coreViewer ? window.coreViewer.readyState : 'loading'
|
|
13
|
+
JS
|
|
14
|
+
|
|
15
|
+
# Reading the TOC has a side effect we depend on: the entries must be in
|
|
16
|
+
# the DOM while printing for Chromium to emit named destinations for
|
|
17
|
+
# them. Same approach as vivliostyle-cli.
|
|
18
|
+
READ_TOC = <<~JS
|
|
19
|
+
const done = arguments[0];
|
|
20
|
+
function listener(payload) {
|
|
21
|
+
if (payload.a !== 'toc') return;
|
|
22
|
+
window.coreViewer.removeListener('done', listener);
|
|
23
|
+
window.coreViewer.showTOC(false);
|
|
24
|
+
done(window.coreViewer.getTOC());
|
|
25
|
+
}
|
|
26
|
+
window.coreViewer.addListener('done', listener);
|
|
27
|
+
window.coreViewer.showTOC(true);
|
|
28
|
+
JS
|
|
29
|
+
|
|
30
|
+
POLL_INTERVAL = 0.2
|
|
31
|
+
|
|
32
|
+
# Closes the tab if the document never renders, so a Printer reusing one
|
|
33
|
+
# browser does not accumulate tabs across failed conversions.
|
|
34
|
+
def self.open(page, url, timeout:)
|
|
35
|
+
session = new(page, timeout: timeout)
|
|
36
|
+
session.visit(url)
|
|
37
|
+
session
|
|
38
|
+
rescue StandardError
|
|
39
|
+
begin
|
|
40
|
+
session&.close
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil # whatever went wrong first is the failure worth reporting
|
|
43
|
+
end
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Non-fatal problems met while rendering, in the order they happened.
|
|
48
|
+
# Printer hands these to the caller as Result#warnings; nothing here
|
|
49
|
+
# writes to stderr on the caller's behalf.
|
|
50
|
+
attr_reader :warnings
|
|
51
|
+
|
|
52
|
+
def initialize(page, timeout:)
|
|
53
|
+
@page = page
|
|
54
|
+
@timeout = timeout
|
|
55
|
+
@warnings = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit(url)
|
|
59
|
+
PDF.translate_browser_errors { @page.go_to(url) }
|
|
60
|
+
wait_until_ready
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The publication's table of contents as a TocItem forest.
|
|
65
|
+
#
|
|
66
|
+
# A document that cannot report one still prints; the failure is recorded
|
|
67
|
+
# in +warnings+ rather than aborting the conversion over an outline.
|
|
68
|
+
def toc
|
|
69
|
+
@toc ||= TocItem.build(read_toc)
|
|
70
|
+
rescue Error => e
|
|
71
|
+
@warnings << "could not read the table of contents (#{e.message}); outline skipped"
|
|
72
|
+
@toc = []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Renders the paginated document to PDF bytes.
|
|
76
|
+
def to_pdf(generate_outline: false)
|
|
77
|
+
parameters = {
|
|
78
|
+
preferCSSPageSize: true,
|
|
79
|
+
printBackground: true,
|
|
80
|
+
transferMode: 'ReturnAsBase64'
|
|
81
|
+
}
|
|
82
|
+
parameters[:generateDocumentOutline] = true if generate_outline
|
|
83
|
+
printed = PDF.translate_browser_errors { @page.command('Page.printToPDF', **parameters) }
|
|
84
|
+
printed['data'].unpack1('m')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def close
|
|
88
|
+
PDF.translate_browser_errors { @page.close }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def read_toc
|
|
94
|
+
PDF.translate_browser_errors { @page.evaluate_async(READ_TOC, @timeout) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def wait_until_ready
|
|
98
|
+
deadline = now + @timeout
|
|
99
|
+
loop do
|
|
100
|
+
case state = PDF.translate_browser_errors { @page.evaluate(READY_STATE) }
|
|
101
|
+
when 'complete' then return
|
|
102
|
+
when 'error' then raise RenderError, 'Vivliostyle reported a rendering error'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if now > deadline
|
|
106
|
+
raise TimeoutError, "rendering did not finish within #{@timeout}s (readyState=#{state})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
sleep POLL_INTERVAL
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def now
|
|
114
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vivlio
|
|
4
|
+
module PDF
|
|
5
|
+
# A document to render: a single HTML file, the OPF of an unzipped EPUB, or
|
|
6
|
+
# a webpub manifest. Beyond being a local file, it knows only whether it
|
|
7
|
+
# brings a spine with it.
|
|
8
|
+
class Source < LocalFile
|
|
9
|
+
OPF_EXTENSIONS = ['.opf'].freeze
|
|
10
|
+
MANIFEST_NAMES = ['publication.json', 'manifest.json'].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(path)
|
|
13
|
+
super(path, kind: 'source')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A publication (EPUB/webpub) has a spine to follow; a lone HTML file
|
|
17
|
+
# does not. Only used as the default for Printer#print's book_mode:.
|
|
18
|
+
def publication?
|
|
19
|
+
OPF_EXTENSIONS.include?(File.extname(path).downcase) ||
|
|
20
|
+
MANIFEST_NAMES.include?(File.basename(path))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Vivlio
|
|
6
|
+
module PDF
|
|
7
|
+
# Builds a file beside where it belongs and moves it into place only once
|
|
8
|
+
# it is finished, so a run that fails part way through never leaves a
|
|
9
|
+
# half-written file where the caller expects a complete one.
|
|
10
|
+
module StagedFile
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Yields the path to build, and returns whatever the block returned.
|
|
14
|
+
def write(destination)
|
|
15
|
+
staged = "#{destination}.part"
|
|
16
|
+
claim(staged)
|
|
17
|
+
result = yield staged
|
|
18
|
+
File.rename(staged, destination)
|
|
19
|
+
result
|
|
20
|
+
ensure
|
|
21
|
+
FileUtils.rm_f(staged)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Creates the staged file before anyone writes to it. The staged name is
|
|
25
|
+
# predictable, so in a world-writable output directory someone could
|
|
26
|
+
# plant a symlink there and have the PDF bytes written through it into a
|
|
27
|
+
# file of their choosing. Deleting whatever is at the name (unlinking a
|
|
28
|
+
# symlink cannot touch its target) and then creating with EXCL closes
|
|
29
|
+
# that: if something reappears in between, the build fails instead of
|
|
30
|
+
# writing through it. The deletion also clears a stale .part left behind
|
|
31
|
+
# by a killed run.
|
|
32
|
+
def claim(staged)
|
|
33
|
+
FileUtils.rm_f(staged)
|
|
34
|
+
File.open(staged, File::WRONLY | File::CREAT | File::EXCL) {} # rubocop:disable Lint/EmptyBlock
|
|
35
|
+
rescue Errno::EEXIST
|
|
36
|
+
raise Error, "staged file reappeared while claiming it: #{staged}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vivlio
|
|
4
|
+
module PDF
|
|
5
|
+
# One entry of a publication's table of contents, as reported by
|
|
6
|
+
# window.coreViewer.getTOC(). Immutable value object holding a subtree.
|
|
7
|
+
#
|
|
8
|
+
# +id+ is the anchor id of the heading the entry points at; Chromium turns
|
|
9
|
+
# it into a PDF named destination while printing, and Outline::Toc later
|
|
10
|
+
# references that name. No page numbers are involved.
|
|
11
|
+
class TocItem
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :title, :children
|
|
15
|
+
|
|
16
|
+
# Builds a forest from the viewer's raw TOC array.
|
|
17
|
+
def self.build(raw)
|
|
18
|
+
Array(raw).map do |entry|
|
|
19
|
+
new(id: entry['id'], title: entry['title'], children: build(entry['children']))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(id:, title: nil, children: [])
|
|
24
|
+
@id = id
|
|
25
|
+
@title = title
|
|
26
|
+
@children = children.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Depth-first traversal over self and all descendants.
|
|
31
|
+
def each(&block)
|
|
32
|
+
return enum_for(:each) unless block
|
|
33
|
+
|
|
34
|
+
yield self
|
|
35
|
+
children.each { |child| child.each(&block) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def leaf?
|
|
39
|
+
children.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Falls back to the anchor id so an entry is never blank in a PDF reader.
|
|
43
|
+
def label
|
|
44
|
+
title || id.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class} #{label.inspect} children=#{children.size}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Vivlio
|
|
6
|
+
module PDF
|
|
7
|
+
# The Vivliostyle Viewer installation used for rendering: a directory of
|
|
8
|
+
# static files containing index.html.
|
|
9
|
+
#
|
|
10
|
+
# Defaults to the copy vendored in this gem; pass another path to run a
|
|
11
|
+
# different release without rebuilding the gem.
|
|
12
|
+
class Viewer
|
|
13
|
+
VENDORED_PATH = File.expand_path('../../../vendor/viewer', __dir__)
|
|
14
|
+
|
|
15
|
+
attr_reader :path, :version
|
|
16
|
+
|
|
17
|
+
def self.default
|
|
18
|
+
@default ||= new(VENDORED_PATH)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Accepts a Viewer, a path, or nil (meaning the vendored viewer).
|
|
22
|
+
def self.coerce(value)
|
|
23
|
+
case value
|
|
24
|
+
when Viewer then value
|
|
25
|
+
when nil then default
|
|
26
|
+
else new(value)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(path)
|
|
31
|
+
@path = File.expand_path(path.to_s)
|
|
32
|
+
@index = LocalFile.new(File.join(@path, 'index.html'), kind: 'Vivliostyle Viewer')
|
|
33
|
+
@version = read_version
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def index_path
|
|
38
|
+
@index.path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Viewer URL loading +source+.
|
|
42
|
+
#
|
|
43
|
+
# Fragment parameters:
|
|
44
|
+
# src document to load
|
|
45
|
+
# bookMode follow the spine/TOC to load the whole publication
|
|
46
|
+
# renderAllPages paginate everything up front (required before print)
|
|
47
|
+
# style additional stylesheet(s)
|
|
48
|
+
#
|
|
49
|
+
# A stylesheet the viewer cannot find is silently ignored, so the paths
|
|
50
|
+
# are resolved here and a missing one is reported instead.
|
|
51
|
+
def url_for(source, book_mode: true, style: nil)
|
|
52
|
+
parameters = ["src=#{Source.coerce(source).url}"]
|
|
53
|
+
parameters << 'bookMode=true' if book_mode
|
|
54
|
+
parameters << 'renderAllPages=true'
|
|
55
|
+
Array(style).each do |sheet|
|
|
56
|
+
parameters << "style=#{LocalFile.new(sheet, kind: 'stylesheet').url}"
|
|
57
|
+
end
|
|
58
|
+
"#{@index.url}##{parameters.join('&')}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The bundled package.json records which viewer release this is; it is
|
|
68
|
+
# the only statement of that version, so nothing can drift from it.
|
|
69
|
+
def read_version
|
|
70
|
+
manifest = File.join(@path, 'package.json')
|
|
71
|
+
return nil unless File.exist?(manifest)
|
|
72
|
+
|
|
73
|
+
JSON.parse(File.read(manifest))['version']
|
|
74
|
+
rescue JSON::ParserError, SystemCallError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/vivlio/pdf.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ferrum'
|
|
4
|
+
|
|
5
|
+
require_relative 'pdf/version'
|
|
6
|
+
|
|
7
|
+
module Vivlio
|
|
8
|
+
# HTML, unzipped EPUB, or webpub → PDF, rendered by the Vivliostyle Viewer
|
|
9
|
+
# in a local Chrome/Chromium driven over CDP. No Node.js required.
|
|
10
|
+
module PDF
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# The viewer reported that it could not lay the document out.
|
|
14
|
+
class RenderError < Error; end
|
|
15
|
+
|
|
16
|
+
# Rendering did not finish within the configured timeout.
|
|
17
|
+
class TimeoutError < Error; end
|
|
18
|
+
|
|
19
|
+
# The browser could not be started, or stopped answering.
|
|
20
|
+
class BrowserError < Error; end
|
|
21
|
+
|
|
22
|
+
# How we drive Chrome is an implementation detail, so Ferrum's exceptions
|
|
23
|
+
# are translated at every point they can escape: callers only ever have to
|
|
24
|
+
# rescue Vivlio::PDF::Error.
|
|
25
|
+
#
|
|
26
|
+
# SystemCallError too: spawning the browser raises Errno::ENOENT when
|
|
27
|
+
# browser_path points at nothing, and inside these blocks an OS-level
|
|
28
|
+
# failure is a browser failure.
|
|
29
|
+
def self.translate_browser_errors
|
|
30
|
+
yield
|
|
31
|
+
rescue Ferrum::TimeoutError => e
|
|
32
|
+
raise TimeoutError, e.message
|
|
33
|
+
rescue Ferrum::Error, SystemCallError => e
|
|
34
|
+
raise BrowserError, e.message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Converts one document, starting and stopping a browser around it.
|
|
38
|
+
# Prefer a Printer when converting several documents in a row.
|
|
39
|
+
#
|
|
40
|
+
# Options are split between the browser session and the conversion by
|
|
41
|
+
# Printer::SETUP_OPTIONS and Printer::PRINT_OPTIONS; anything else is a
|
|
42
|
+
# typo, and saying so beats silently ignoring it.
|
|
43
|
+
#
|
|
44
|
+
# Vivlio::PDF.print(source: 'book/OEBPS/package.opf', output: 'book.pdf')
|
|
45
|
+
def self.print(source:, output:, **options)
|
|
46
|
+
unknown = options.keys - Printer::SETUP_OPTIONS - Printer::PRINT_OPTIONS
|
|
47
|
+
unless unknown.empty?
|
|
48
|
+
raise ArgumentError, "unknown keyword#{'s' if unknown.size > 1}: " \
|
|
49
|
+
"#{unknown.map(&:inspect).join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Printer.open(**options.slice(*Printer::SETUP_OPTIONS)) do |printer|
|
|
53
|
+
printer.print(source: source, output: output, **options.slice(*Printer::PRINT_OPTIONS))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
require_relative 'pdf/local_file'
|
|
60
|
+
require_relative 'pdf/source'
|
|
61
|
+
require_relative 'pdf/viewer'
|
|
62
|
+
require_relative 'pdf/toc_item'
|
|
63
|
+
require_relative 'pdf/metadata'
|
|
64
|
+
require_relative 'pdf/outline'
|
|
65
|
+
require_relative 'pdf/session'
|
|
66
|
+
require_relative 'pdf/staged_file'
|
|
67
|
+
require_relative 'pdf/document'
|
|
68
|
+
require_relative 'pdf/result'
|
|
69
|
+
require_relative 'pdf/printer'
|