sghtmltopdf 0.3.0 → 0.4.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 +4 -4
- data/Cargo.lock +2 -2
- data/README.md +7 -2
- data/ext/sghtmltopdf/Cargo.toml +1 -1
- data/lib/sghtmltopdf/configuration.rb +3 -3
- data/lib/sghtmltopdf/options.rb +20 -1
- data/lib/sghtmltopdf/railtie.rb +27 -5
- data/lib/sghtmltopdf/version.rb +1 -1
- data/lib/sghtmltopdf/view_helpers.rb +343 -23
- data/lib/sghtmltopdf.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f317db9c1a48c59df592601e2246186da7539007f19859be3ebbf6c057283bb
|
|
4
|
+
data.tar.gz: 10d371ed48aa9b6772c840a75d4cc9e456468c783cae83ee95a0b73588b345a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba289e596824c8cbc3a259bd1adfd9397e47c333cdbcefeff1d8e33ae512c64ec197a67d98f5f90a35f46cefc833b837b0348e6625dd69e03ff732a717844d3b
|
|
7
|
+
data.tar.gz: 9f6f870dee5e361e175d19d778d3129d416814cb7c7ae0092afe95e59f6469a1f43cbe24a5fa0e9a719c39ee406cb157cbf50285a64a67760f761705558dd9e1
|
data/Cargo.lock
CHANGED
|
@@ -1050,7 +1050,7 @@ dependencies = [
|
|
|
1050
1050
|
|
|
1051
1051
|
[[package]]
|
|
1052
1052
|
name = "sghtmltopdf"
|
|
1053
|
-
version = "0.
|
|
1053
|
+
version = "0.4.0"
|
|
1054
1054
|
dependencies = [
|
|
1055
1055
|
"magnus",
|
|
1056
1056
|
"rb-sys",
|
|
@@ -1059,7 +1059,7 @@ dependencies = [
|
|
|
1059
1059
|
|
|
1060
1060
|
[[package]]
|
|
1061
1061
|
name = "sghtmltopdf-core"
|
|
1062
|
-
version = "0.
|
|
1062
|
+
version = "0.4.0"
|
|
1063
1063
|
dependencies = [
|
|
1064
1064
|
"base64",
|
|
1065
1065
|
"clap",
|
data/README.md
CHANGED
|
@@ -68,14 +68,19 @@ Converter keys are flat CLI flag names, so wicked_pdf's nested `margin: {top: 10
|
|
|
68
68
|
|
|
69
69
|
### Assets
|
|
70
70
|
|
|
71
|
-
PDF rendering does not go through the HTTP server, so `/assets/…` URLs are resolved as local files: the Railtie defaults `base_url` to `Rails.root/public` and restricts local reads to `
|
|
72
|
-
That is enough for a precompiled production app; in development, these helpers
|
|
71
|
+
PDF rendering does not go through the HTTP server, so `/assets/…` URLs are resolved as local files: the Railtie defaults `base_url` to `Rails.root/public` and restricts local reads to `public/` and the asset pipeline load paths via `allow_path`.
|
|
72
|
+
That is enough for a precompiled production app; in development the digested `/assets/…` path names no file on disk, so these helpers look the asset up in the pipeline instead — the CSS expanded into a `<style>`, the image referenced by the path the engine can read:
|
|
73
73
|
|
|
74
74
|
```erb
|
|
75
75
|
<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
|
|
76
76
|
<%= sghtmltopdf_image_tag "logo.png" %>
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
`sghtmltopdf_stylesheet_link_tag` does not copy the CSS verbatim: it points every `url()` at a file the engine can read and splices in every `@import`.
|
|
80
|
+
The asset pipeline rewrites `url()` through `asset_path` while precompiling, which turns a `@font-face` source into a digested `/assets/…` path, or into an absolute URL once `asset_host` is set — neither can be fetched while rendering, and a `@font-face` that fails to load falls back to the engine default rather than to the next `font-family`.
|
|
81
|
+
|
|
82
|
+
A file `allow_path` does not cover is embedded as a `data:` URI instead, so that it cannot silently vanish from the PDF; pass `inline: true` to embed unconditionally.
|
|
83
|
+
|
|
79
84
|
### Streaming the response
|
|
80
85
|
|
|
81
86
|
To send pages as soon as their layout is final, pass a block and use `ActionController::Live` — this also makes `Rack::Timeout` and `Thread#kill` effective at chunk boundaries:
|
data/ext/sghtmltopdf/Cargo.toml
CHANGED
|
@@ -24,12 +24,12 @@ module Sghtmltopdf
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def [](key)
|
|
27
|
-
key = key
|
|
27
|
+
key = Options.canonical_key(key)
|
|
28
28
|
@options.key?(key) ? @options[key] : @defaults[key]
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def []=(key, value)
|
|
32
|
-
@options[key
|
|
32
|
+
@options[Options.canonical_key(key)] = value
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# @param with_defaults [Boolean] 流し込まれた既定値を含めるか。
|
|
@@ -43,7 +43,7 @@ module Sghtmltopdf
|
|
|
43
43
|
# 既定値を流し込む。Railtieが Rails向けの既定値を入れるのに使う。
|
|
44
44
|
# 明示的に設定された値より弱い(順序に関係なく`[]=`が勝つ)。
|
|
45
45
|
def apply_defaults(defaults)
|
|
46
|
-
defaults.each { |key, value| @defaults[key
|
|
46
|
+
defaults.each { |key, value| @defaults[Options.canonical_key(key)] = value }
|
|
47
47
|
self
|
|
48
48
|
end
|
|
49
49
|
|
data/lib/sghtmltopdf/options.rb
CHANGED
|
@@ -17,8 +17,26 @@ module Sghtmltopdf
|
|
|
17
17
|
# クエリにも出さない。
|
|
18
18
|
TRANSPORT_KEYS = %i[server_url server_open_timeout server_read_timeout chunk_size].freeze
|
|
19
19
|
|
|
20
|
+
# 別名のキー(値は正規名)。CLIは`--allow`を`--allow-path`の別名として
|
|
21
|
+
# 受けるが、Ruby側は2つのキーのまま持ち回ってはいけない。既定が一方の
|
|
22
|
+
# キー、呼び出し時の指定がもう一方のキーだと、ハッシュのマージでは
|
|
23
|
+
# 上書きにならず両方がargvへ出てしまう(同じフラグの繰り返しは
|
|
24
|
+
# 「置き換え」ではなく「合併」の意味になる)。
|
|
25
|
+
ALIAS_KEYS = {allow: :allow_path}.freeze
|
|
26
|
+
|
|
20
27
|
module_function
|
|
21
28
|
|
|
29
|
+
# 別名のキーを正規名へ寄せる。
|
|
30
|
+
def canonical_key(key)
|
|
31
|
+
key = key.to_sym
|
|
32
|
+
ALIAS_KEYS.fetch(key, key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ハッシュのキーをまとめて正規化する。
|
|
36
|
+
def canonicalize(options)
|
|
37
|
+
options.to_h { |key, value| [canonical_key(key), value] }
|
|
38
|
+
end
|
|
39
|
+
|
|
22
40
|
# @param options [Hash] Rubyのオプションハッシュ
|
|
23
41
|
# @return [Array<String>] clapへ渡す引数列
|
|
24
42
|
def to_argv(options)
|
|
@@ -113,7 +131,8 @@ module Sghtmltopdf
|
|
|
113
131
|
# 変換オプションだけを、渡された順にペアとして列挙する。
|
|
114
132
|
def each_pair(options, &block)
|
|
115
133
|
options.each do |key, value|
|
|
116
|
-
|
|
134
|
+
key = canonical_key(key)
|
|
135
|
+
next if TRANSPORT_KEYS.include?(key)
|
|
117
136
|
|
|
118
137
|
pairs_for(key, value).each(&block)
|
|
119
138
|
end
|
data/lib/sghtmltopdf/railtie.rb
CHANGED
|
@@ -6,16 +6,38 @@ require_relative "view_helpers"
|
|
|
6
6
|
|
|
7
7
|
module Sghtmltopdf
|
|
8
8
|
class Railtie < ::Rails::Railtie
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
# The directories the engine may read from: `public/`, where the
|
|
10
|
+
# precompiled assets are, plus the asset pipeline load paths, where their
|
|
11
|
+
# sources are in development. The pipeline paths reach outside `Rails.root`
|
|
12
|
+
# for the assets a gem or an engine provides, which is why the whole of
|
|
13
|
+
# `Rails.root` is not a substitute for them.
|
|
14
|
+
#
|
|
15
|
+
# `config.assets` raises rather than answering `nil` when no pipeline gem is
|
|
16
|
+
# installed, hence `try`.
|
|
17
|
+
def self.default_options(app)
|
|
18
|
+
public_dir = File.join(app.root.to_s, "public")
|
|
19
|
+
pipeline = Array(app.config.try(:assets)&.paths).map(&:to_s)
|
|
20
|
+
allow = [public_dir, *pipeline].select { |dir| File.directory?(dir) }.uniq
|
|
21
|
+
|
|
22
|
+
defaults = {}
|
|
23
|
+
# An empty list would mean "no --allow-path", which is not the same thing as
|
|
24
|
+
# "allow nothing": it hands the boundary back to `base_url`. Leaving the
|
|
25
|
+
# key out says that, and says it in one place.
|
|
26
|
+
defaults[:allow_path] = allow unless allow.empty?
|
|
13
27
|
defaults[:base_url] = public_dir if File.directory?(public_dir)
|
|
14
28
|
defaults
|
|
15
29
|
end
|
|
16
30
|
|
|
31
|
+
# 読むのはinitializerの中ではなく`after_initialize`。パイプラインが
|
|
32
|
+
# `config.assets.paths`を埋めるのは自分のinitializer(Propshaftなら
|
|
33
|
+
# `propshaft.append_assets_path`)で、そちらの方が後に走るため。
|
|
34
|
+
#
|
|
35
|
+
# `config/initializers`より後になるが、`apply_defaults`は明示的に設定した
|
|
36
|
+
# 値より常に弱いので、ユーザーの設定を踏むことはない。
|
|
17
37
|
initializer "sghtmltopdf.defaults" do |app|
|
|
18
|
-
|
|
38
|
+
app.config.after_initialize do
|
|
39
|
+
Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app))
|
|
40
|
+
end
|
|
19
41
|
end
|
|
20
42
|
|
|
21
43
|
initializer "sghtmltopdf.renderer" do
|
data/lib/sghtmltopdf/version.rb
CHANGED
|
@@ -1,65 +1,280 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Sghtmltopdf
|
|
4
|
-
# Action View
|
|
4
|
+
# Action View helpers.
|
|
5
5
|
#
|
|
6
|
-
# PDF
|
|
7
|
-
#
|
|
8
|
-
# `Rails.root/public
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Rendering a PDF never goes through an HTTP server, so a URL such as
|
|
7
|
+
# `/assets/…` is resolved as a local file (`--base-url` defaults to
|
|
8
|
+
# `Rails.root/public`, see [Railtie.default_options]). In production, where
|
|
9
|
+
# the assets are precompiled, that makes a plain `stylesheet_link_tag` work as
|
|
10
|
+
# it is; in development, where nothing has been written to `public/` yet, it
|
|
11
|
+
# cannot be resolved: the digest and the `/assets/` mount point are made up by
|
|
12
|
+
# the pipeline at request time, so no file of that name is on disk.
|
|
11
13
|
#
|
|
12
|
-
#
|
|
14
|
+
# Hence helpers that look the asset up in the pipeline and hand the engine
|
|
15
|
+
# something it can actually read:
|
|
13
16
|
#
|
|
14
17
|
# <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
|
|
18
|
+
# <%= sghtmltopdf_image_tag "logo.png" %>
|
|
15
19
|
#
|
|
16
|
-
#
|
|
17
|
-
# `
|
|
20
|
+
# (the counterparts of wicked_pdf's `wicked_pdf_stylesheet_link_tag` and
|
|
21
|
+
# `wicked_pdf_image_tag`).
|
|
18
22
|
module ViewHelpers
|
|
19
|
-
#
|
|
23
|
+
# Local file path of an asset, or `nil` when it cannot be found.
|
|
20
24
|
#
|
|
21
|
-
#
|
|
22
|
-
# 2. アセットパイプラインのロードパス(開発環境。Propshaft/Sprockets)
|
|
25
|
+
# Looks in
|
|
23
26
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
27
|
+
# 1. `public/` (precompiled; production)
|
|
28
|
+
# 2. the asset pipeline load paths (development; Propshaft or Sprockets)
|
|
29
|
+
#
|
|
30
|
+
# in that order. The pipeline lookup goes through `respond_to?` so that
|
|
31
|
+
# neither gem becomes a dependency (best effort).
|
|
26
32
|
def sghtmltopdf_asset_path(source)
|
|
27
33
|
path = source.to_s
|
|
34
|
+
# A source that is already a URL (or a `data:` URI) is not an asset of
|
|
35
|
+
# this application. Without this, `from_public_dir` would strip the host
|
|
36
|
+
# off `https://example.com/logo.png` and hand back `public/logo.png`.
|
|
37
|
+
return nil if path.match?(%r{\A(?:[a-z][a-z0-9+.\-]*:|//)}i)
|
|
28
38
|
return path if path.start_with?("/") && File.file?(path)
|
|
29
39
|
|
|
30
40
|
from_public_dir(path) || from_asset_pipeline(path)
|
|
31
41
|
end
|
|
32
42
|
|
|
33
|
-
# CSS
|
|
34
|
-
#
|
|
43
|
+
# Expands the CSS itself into a `<style>`. Takes several sources; any that
|
|
44
|
+
# cannot be found are skipped silently (rendering still goes ahead).
|
|
45
|
+
#
|
|
46
|
+
# The file is not copied verbatim: its `url()`s are pointed at files the
|
|
47
|
+
# engine can read and its `@import`s are spliced in, see
|
|
48
|
+
# [#inline_stylesheet].
|
|
35
49
|
def sghtmltopdf_stylesheet_link_tag(*sources)
|
|
36
50
|
css = sources.flatten.filter_map do |source|
|
|
37
51
|
path = sghtmltopdf_asset_path(with_extension(source, ".css"))
|
|
38
|
-
|
|
52
|
+
inline_stylesheet(path) if path
|
|
39
53
|
end
|
|
40
54
|
return "".html_safe if css.empty?
|
|
41
55
|
|
|
42
56
|
content_tag(:style, css.join("\n").html_safe, type: "text/css")
|
|
43
57
|
end
|
|
44
58
|
|
|
45
|
-
# `
|
|
59
|
+
# An `<img>` whose `src` the engine can read.
|
|
60
|
+
#
|
|
61
|
+
# The file is referenced by path, which keeps the HTML small: relative to
|
|
62
|
+
# `base_url` when it sits under it (the precompiled case), and the absolute
|
|
63
|
+
# filesystem path otherwise (the development case, where the file is still
|
|
64
|
+
# in the pipeline's load path). The engine reads an absolute `src` as a
|
|
65
|
+
# filesystem path once it fails to resolve under `base_url` (#44), so both
|
|
66
|
+
# forms reach the same file.
|
|
67
|
+
#
|
|
68
|
+
# A path only works if the engine is allowed to read there, so it is used
|
|
69
|
+
# only when `allow_path` (or `base_url`, with none set) covers the file.
|
|
70
|
+
# Everything else falls back to a `data:` URI, which depends on no
|
|
71
|
+
# configuration at all and therefore cannot fail: without that fallback a
|
|
72
|
+
# blocked path would just disappear from the PDF, since a failed asset fetch
|
|
73
|
+
# is ignored by default.
|
|
74
|
+
#
|
|
75
|
+
# `inline: true` forces embedding.
|
|
46
76
|
def sghtmltopdf_image_tag(source, options = {})
|
|
47
|
-
|
|
77
|
+
options = options.symbolize_keys
|
|
78
|
+
inline = options.delete(:inline)
|
|
79
|
+
path = sghtmltopdf_asset_path(source)
|
|
80
|
+
# Not an asset of this application (a remote URL, say): leave it to Rails.
|
|
81
|
+
return image_tag(source, options) if path.nil?
|
|
82
|
+
|
|
83
|
+
if !inline && (src = engine_readable_src(path))
|
|
84
|
+
# `image_tag` would rewrite the source (prefixing `/images/`, the asset
|
|
85
|
+
# host, and so on), so the tag is built directly. That skips the one
|
|
86
|
+
# option `image_tag` does more than copy, so `size:` is expanded here.
|
|
87
|
+
return tag.img(**expand_size(options).merge(src: src))
|
|
88
|
+
end
|
|
89
|
+
# A `data:` URI matches `AssetUrlHelper::URI_REGEXP`, so `image_tag`
|
|
90
|
+
# passes it through untouched and its own options keep working.
|
|
91
|
+
image_tag(data_uri(path), options)
|
|
48
92
|
end
|
|
49
93
|
|
|
50
94
|
private
|
|
51
95
|
|
|
52
|
-
# `
|
|
53
|
-
#
|
|
96
|
+
# `path` read as CSS, with every `url()` pointed at a file the engine can
|
|
97
|
+
# read and every `@import` spliced in.
|
|
98
|
+
#
|
|
99
|
+
# Both have to happen here because the engine keeps one base for the whole
|
|
100
|
+
# document: every CSS source is concatenated before it is parsed, so a
|
|
101
|
+
# `url()` is resolved against the HTML's `base_url` whatever stylesheet it
|
|
102
|
+
# came from. Only this side knows where each file sits on disk, so each
|
|
103
|
+
# one's references are resolved against its own directory before its text
|
|
104
|
+
# is spliced into the next.
|
|
105
|
+
#
|
|
106
|
+
# What has to be undone is mostly the asset pipeline's own work: it
|
|
107
|
+
# rewrites every `url()` through `asset_path` while precompiling, which
|
|
108
|
+
# yields a digested `/assets/…` path, or an absolute URL once `asset_host`
|
|
109
|
+
# is set. Rendering never goes through the HTTP server, so neither can be
|
|
110
|
+
# fetched.
|
|
111
|
+
#
|
|
112
|
+
# `chain` holds the files already being expanded, innermost last.
|
|
113
|
+
def inline_stylesheet(path, chain = [])
|
|
114
|
+
css = File.read(path)
|
|
115
|
+
dir = File.dirname(path)
|
|
116
|
+
chain += [real_path(path)].compact
|
|
117
|
+
comments = comment_ranges(css)
|
|
118
|
+
out = +""
|
|
119
|
+
cursor = 0
|
|
120
|
+
while (import = IMPORT_STATEMENT.match(css, cursor))
|
|
121
|
+
commented = comments.any? { |range| range.cover?(import.begin(0)) }
|
|
122
|
+
expanded = imported_stylesheet(import[:href], dir, chain) unless commented
|
|
123
|
+
out << rewrite_css_urls(css[cursor...import.begin(0)].to_s, dir)
|
|
124
|
+
cursor = import.end(0)
|
|
125
|
+
out << (expanded || import[0])
|
|
126
|
+
end
|
|
127
|
+
out << rewrite_css_urls(css[cursor..].to_s, dir)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The expanded content of an `@import` target, or `nil` to leave the
|
|
131
|
+
# statement as it is, which hands it to the engine untouched. That happens
|
|
132
|
+
# when it names no asset of this application, when the nesting is too deep,
|
|
133
|
+
# and when it points back at a file already being expanded.
|
|
134
|
+
#
|
|
135
|
+
# Media conditions on the statement are dropped. The engine replaces the
|
|
136
|
+
# whole statement with the imported text too, so nothing changes by doing
|
|
137
|
+
# it here.
|
|
138
|
+
def imported_stylesheet(href, dir, chain)
|
|
139
|
+
return nil if chain.length >= MAX_IMPORT_DEPTH
|
|
140
|
+
|
|
141
|
+
target = css_url_target(unquote(href), dir)
|
|
142
|
+
return nil if target.nil?
|
|
143
|
+
|
|
144
|
+
real = real_path(target)
|
|
145
|
+
return nil if real.nil? || chain.include?(real)
|
|
146
|
+
|
|
147
|
+
inline_stylesheet(target, chain)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Byte ranges of the `/* … */` comments in `css`. A commented-out `@import`
|
|
151
|
+
# must not be spliced in.
|
|
152
|
+
def comment_ranges(css)
|
|
153
|
+
css.enum_for(:scan, CSS_COMMENT).map { Regexp.last_match.begin(0)...Regexp.last_match.end(0) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Every `url()` in `css` pointed at something the engine can read: a path
|
|
157
|
+
# when it may read the file there, a `data:` URI when it may not.
|
|
158
|
+
#
|
|
159
|
+
# A reference that names no file of this application is left alone, which
|
|
160
|
+
# covers `data:` URIs, bare fragments, and genuinely remote resources such
|
|
161
|
+
# as a font served by a CDN.
|
|
162
|
+
def rewrite_css_urls(css, dir)
|
|
163
|
+
css.gsub(CSS_URL) do
|
|
164
|
+
match = Regexp.last_match
|
|
165
|
+
target = css_url_target(unquote(match[:href]), dir)
|
|
166
|
+
target ? css_url(engine_readable_src(target) || data_uri(target)) : match[0]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The local file a CSS reference names, or `nil` when it names none.
|
|
171
|
+
def css_url_target(ref, dir)
|
|
172
|
+
path = ref.split(/[?#]/, 2).first.to_s
|
|
173
|
+
return nil if path.empty? || path.match?(/\Adata:/i)
|
|
174
|
+
|
|
175
|
+
if path.match?(%r{\A(?:[a-z][a-z0-9+.\-]*:|//)}i)
|
|
176
|
+
# Only an http(s) or protocol-relative URL can be one of ours. The host
|
|
177
|
+
# is not compared against `asset_host`: it may be a callable or carry a
|
|
178
|
+
# `%d` wildcard, so there is no general way to recognise it. Finding the
|
|
179
|
+
# path on disk is the test instead, and what that finds is the very file
|
|
180
|
+
# the reference names.
|
|
181
|
+
return nil unless path.match?(%r{\A(?:https?:)?//}i)
|
|
182
|
+
|
|
183
|
+
path = path.sub(%r{\A(?:https?:)?//[^/]*}i, "")
|
|
184
|
+
return nil if path.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
path.start_with?("/") ? from_site_root(path) : from_stylesheet_dir(path, dir)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A site-root-relative reference (`/assets/pdf-<digest>.css`) mapped onto a
|
|
191
|
+
# file. `relative_url_root` comes off first: the pipeline writes it in front
|
|
192
|
+
# of every URL, but it is a mount point, not a directory under `public/`.
|
|
193
|
+
def from_site_root(path)
|
|
194
|
+
root = ::Rails.application.config.try(:relative_url_root).to_s
|
|
195
|
+
path = path.delete_prefix(root) unless root.empty?
|
|
196
|
+
candidate = File.join(::Rails.public_path.to_s, path)
|
|
197
|
+
return candidate if File.file?(candidate)
|
|
198
|
+
|
|
199
|
+
# Nothing is precompiled in development, so the load path is asked as
|
|
200
|
+
# well. The mount point is not part of a logical path either.
|
|
201
|
+
logical = path.delete_prefix("/")
|
|
202
|
+
prefix = ::Rails.application.config.try(:assets)&.prefix.to_s.delete_prefix("/")
|
|
203
|
+
from_asset_pipeline(logical) ||
|
|
204
|
+
(prefix.empty? ? nil : from_asset_pipeline(logical.delete_prefix("#{prefix}/")))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A reference relative to the stylesheet that wrote it, which is what CSS
|
|
208
|
+
# says it means and what the pipeline assumed while compiling the file.
|
|
209
|
+
#
|
|
210
|
+
# The load path is tried too. An uncompiled stylesheet sitting at the root
|
|
211
|
+
# of its own load path names assets by logical path, which is spelled the
|
|
212
|
+
# same way but is looked up across every root.
|
|
213
|
+
def from_stylesheet_dir(path, dir)
|
|
214
|
+
candidate = File.expand_path(path, dir)
|
|
215
|
+
return candidate if File.file?(candidate)
|
|
216
|
+
|
|
217
|
+
from_asset_pipeline(path.delete_prefix("./"))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A `url()` whose argument is always quoted, so that a path holding a space
|
|
221
|
+
# or a parenthesis survives.
|
|
222
|
+
def css_url(value)
|
|
223
|
+
%(url("#{value.gsub(/["\\]/) { |char| "\\#{char}" }}"))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def unquote(value)
|
|
227
|
+
value = value.to_s.strip
|
|
228
|
+
quoted = value.match(/\A(["'])(.*)\1\z/m)
|
|
229
|
+
quoted ? quoted[2] : value
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Matches the engine's own cap (`core/src/style/import.rs`). Past it the
|
|
233
|
+
# statement is left in place and the engine decides what to do with it.
|
|
234
|
+
MAX_IMPORT_DEPTH = 16
|
|
235
|
+
private_constant :MAX_IMPORT_DEPTH
|
|
236
|
+
|
|
237
|
+
# `@import` up to its terminating `;`, in both the `url()` and the bare
|
|
238
|
+
# string form. Media conditions are matched so they are consumed, not kept.
|
|
239
|
+
IMPORT_STATEMENT = /
|
|
240
|
+
@import \s+
|
|
241
|
+
(?: url\( \s* (?<href> "[^"]*" | '[^']*' | [^)"'\s]* ) \s* \)
|
|
242
|
+
| (?<href> "[^"]*" | '[^']*' ) )
|
|
243
|
+
[^;]* ;
|
|
244
|
+
/xi
|
|
245
|
+
private_constant :IMPORT_STATEMENT
|
|
246
|
+
|
|
247
|
+
# A `url()` token. The lookbehind keeps identifiers ending in "url" out.
|
|
248
|
+
CSS_URL = /(?<![\w-])url\( \s* (?<href> "[^"]*" | '[^']*' | [^)"'\s]* ) \s* \)/xi
|
|
249
|
+
private_constant :CSS_URL
|
|
250
|
+
|
|
251
|
+
CSS_COMMENT = %r{/\*.*?\*/}m
|
|
252
|
+
private_constant :CSS_COMMENT
|
|
253
|
+
|
|
254
|
+
# Takes the path part of the URL `asset_path` returns (which may carry an
|
|
255
|
+
# asset host) and maps it onto a real file under `public/`.
|
|
54
256
|
def from_public_dir(source)
|
|
55
|
-
|
|
56
|
-
relative = url.to_s.sub(%r{\Ahttps?://[^/]+}, "").split(/[?#]/).first.to_s
|
|
257
|
+
relative = asset_url_for(source).to_s.sub(%r{\Ahttps?://[^/]+}, "").split(/[?#]/).first.to_s
|
|
57
258
|
return nil if relative.empty?
|
|
58
259
|
|
|
59
260
|
candidate = File.join(::Rails.public_path.to_s, relative)
|
|
60
261
|
File.file?(candidate) ? candidate : nil
|
|
61
262
|
end
|
|
62
263
|
|
|
264
|
+
# `asset_path` for `source`, falling back to `source` itself.
|
|
265
|
+
#
|
|
266
|
+
# Both pipelines raise rather than return a path for an asset outside their
|
|
267
|
+
# load path (`Propshaft::MissingAssetError`,
|
|
268
|
+
# `Sprockets::Rails::Helper::AssetNotFound`). A file that lives only in
|
|
269
|
+
# `public/` is exactly that, so the raw source is tried against `public/`.
|
|
270
|
+
def asset_url_for(source)
|
|
271
|
+
return source unless respond_to?(:asset_path)
|
|
272
|
+
|
|
273
|
+
asset_path(source)
|
|
274
|
+
rescue StandardError
|
|
275
|
+
source
|
|
276
|
+
end
|
|
277
|
+
|
|
63
278
|
def from_asset_pipeline(source)
|
|
64
279
|
assets = ::Rails.application.try(:assets)
|
|
65
280
|
return nil if assets.nil?
|
|
@@ -77,6 +292,111 @@ module Sghtmltopdf
|
|
|
77
292
|
nil
|
|
78
293
|
end
|
|
79
294
|
|
|
295
|
+
# `data:<type>;base64,<data>` for a local file. `pack("m0")` is strict
|
|
296
|
+
# base64 (no line breaks) and needs no require, unlike `Base64`, which is no
|
|
297
|
+
# longer a default gem on Ruby 3.4.
|
|
298
|
+
def data_uri(path)
|
|
299
|
+
base64 = [File.binread(path)].pack("m0")
|
|
300
|
+
"data:#{mime_type_of(path)};base64,#{base64}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The `src` to reference `file` by, or `nil` when the engine would not be
|
|
304
|
+
# allowed to read it there.
|
|
305
|
+
def engine_readable_src(file)
|
|
306
|
+
return nil unless engine_can_read?(file)
|
|
307
|
+
|
|
308
|
+
relative_to_base_url(file) || real_path(file)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Whether the engine's rules for local files let it read `file`.
|
|
312
|
+
#
|
|
313
|
+
# `allow_path` decides on its own once it is set: the engine stops treating
|
|
314
|
+
# `base_url` as the boundary and consults the allowed directories only.
|
|
315
|
+
# A run that has no local access at all, or one delegated to a server that
|
|
316
|
+
# may not even share this filesystem, can read nothing here.
|
|
317
|
+
def engine_can_read?(file)
|
|
318
|
+
config = Sghtmltopdf.config
|
|
319
|
+
return false if config[:disable_local_file_access] || config[:server_url]
|
|
320
|
+
|
|
321
|
+
file = real_path(file)
|
|
322
|
+
return false if file.nil?
|
|
323
|
+
|
|
324
|
+
dirs = Array(config[:allow_path]).filter_map { |dir| real_path(dir) }
|
|
325
|
+
dirs = [real_path(config[:base_url])].compact if dirs.empty?
|
|
326
|
+
dirs.any? { |dir| file.start_with?(dir + File::SEPARATOR) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The path of `file` relative to the configured `base_url`, or `nil` when
|
|
330
|
+
# `base_url` is not a directory holding it (it may be an http(s) URL, or the
|
|
331
|
+
# file may live outside it, as an asset pipeline one in development does).
|
|
332
|
+
def relative_to_base_url(file)
|
|
333
|
+
base = real_path(Sghtmltopdf.config[:base_url])
|
|
334
|
+
full = real_path(file)
|
|
335
|
+
return nil if base.nil? || full.nil?
|
|
336
|
+
|
|
337
|
+
prefix = base + File::SEPARATOR
|
|
338
|
+
full.start_with?(prefix) ? full.delete_prefix(prefix) : nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# `path` with symlinks resolved, or `nil` when it is not a local path at
|
|
342
|
+
# all. The engine canonicalizes both sides before comparing them, so a
|
|
343
|
+
# symlink pointing out of an allowed directory does not count as inside it.
|
|
344
|
+
def real_path(path)
|
|
345
|
+
path = path.to_s
|
|
346
|
+
return nil if path.empty? || path.match?(%r{\Ahttps?://}i)
|
|
347
|
+
|
|
348
|
+
File.realpath(path)
|
|
349
|
+
rescue SystemCallError
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# `image_tag`'s `size:` shorthand: "40x30", or "40" for a square. The tag is
|
|
354
|
+
# built without `image_tag` here, so the expansion has to happen here too.
|
|
355
|
+
def expand_size(options)
|
|
356
|
+
return options unless options.key?(:size)
|
|
357
|
+
|
|
358
|
+
if options[:height] || options[:width]
|
|
359
|
+
raise ArgumentError, "Cannot pass a :size option with a :height or :width option"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
size = options[:size].to_s
|
|
363
|
+
options = options.except(:size)
|
|
364
|
+
case size
|
|
365
|
+
when /\A(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)\z/ then options.merge(width: $1, height: $2)
|
|
366
|
+
when /\A\d+(?:\.\d+)?\z/ then options.merge(width: size, height: size)
|
|
367
|
+
else options
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Media type from the file extension. The engine detects the format from the
|
|
372
|
+
# bytes, so this only has to be honest, not exhaustive.
|
|
373
|
+
def mime_type_of(path)
|
|
374
|
+
MIME_TYPES.fetch(File.extname(path).downcase.delete_prefix("."), "application/octet-stream")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
MIME_TYPES = {
|
|
378
|
+
"png" => "image/png",
|
|
379
|
+
"jpg" => "image/jpeg",
|
|
380
|
+
"jpeg" => "image/jpeg",
|
|
381
|
+
"gif" => "image/gif",
|
|
382
|
+
"webp" => "image/webp",
|
|
383
|
+
"avif" => "image/avif",
|
|
384
|
+
"bmp" => "image/bmp",
|
|
385
|
+
"ico" => "image/vnd.microsoft.icon",
|
|
386
|
+
"tif" => "image/tiff",
|
|
387
|
+
"tiff" => "image/tiff",
|
|
388
|
+
"svg" => "image/svg+xml",
|
|
389
|
+
# Reached through a stylesheet's `url()`, not through `image_tag`.
|
|
390
|
+
"otf" => "font/otf",
|
|
391
|
+
"ttf" => "font/ttf",
|
|
392
|
+
"ttc" => "font/collection",
|
|
393
|
+
"woff" => "font/woff",
|
|
394
|
+
"woff2" => "font/woff2",
|
|
395
|
+
"eot" => "application/vnd.ms-fontobject",
|
|
396
|
+
"css" => "text/css"
|
|
397
|
+
}.freeze
|
|
398
|
+
private_constant :MIME_TYPES
|
|
399
|
+
|
|
80
400
|
def with_extension(source, extension)
|
|
81
401
|
name = source.to_s
|
|
82
402
|
name.end_with?(extension) ? name : "#{name}#{extension}"
|
data/lib/sghtmltopdf.rb
CHANGED
|
@@ -47,6 +47,7 @@ module Sghtmltopdf
|
|
|
47
47
|
# 1回に渡すバイト数の目安は`chunk_size:`で変えられる(既定64KiB。
|
|
48
48
|
# ローカル変換のみ。小さくするとGVLの取り直しが増える)。
|
|
49
49
|
def render(html, **options, &block)
|
|
50
|
+
options = Options.canonicalize(options)
|
|
50
51
|
client = server_client(options)
|
|
51
52
|
return client.render(html.to_s, server_options(options), &block) if client
|
|
52
53
|
return Native.render(html.to_s, argv_for(options)) if block.nil?
|
|
@@ -60,6 +61,7 @@ module Sghtmltopdf
|
|
|
60
61
|
# 一時ファイルへ書いて成功時だけrenameするため、途中で失敗しても
|
|
61
62
|
# 壊れたPDFが出力先に残らない(サーバへ委譲する場合も同じ)。
|
|
62
63
|
def render_to_file(html, path, **options)
|
|
64
|
+
options = Options.canonicalize(options)
|
|
63
65
|
client = server_client(options)
|
|
64
66
|
return client.render_to_file(html.to_s, server_options(options), path.to_s) if client
|
|
65
67
|
|