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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vivlio
4
+ module PDF
5
+ VERSION = '0.2.0'
6
+ end
7
+ 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'