sghtmltopdf 0.1.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/Cargo.lock +1363 -0
- data/Cargo.toml +3 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/ext/sghtmltopdf/Cargo.toml +20 -0
- data/ext/sghtmltopdf/extconf.rb +21 -0
- data/ext/sghtmltopdf/src/callback_sink.rs +323 -0
- data/ext/sghtmltopdf/src/errors.rs +77 -0
- data/ext/sghtmltopdf/src/gvl.rs +114 -0
- data/ext/sghtmltopdf/src/lib.rs +167 -0
- data/lib/sghtmltopdf/configuration.rb +68 -0
- data/lib/sghtmltopdf/options.rb +126 -0
- data/lib/sghtmltopdf/railtie.rb +31 -0
- data/lib/sghtmltopdf/renderer.rb +100 -0
- data/lib/sghtmltopdf/server_client.rb +139 -0
- data/lib/sghtmltopdf/version.rb +5 -0
- data/lib/sghtmltopdf/view_helpers.rb +85 -0
- data/lib/sghtmltopdf.rb +121 -0
- metadata +66 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//! GVL(Global VM Lock)の解放。
|
|
2
|
+
//!
|
|
3
|
+
//! レンダリングの間はGVLを解放し、Pumaの他スレッドを止めないようにする。
|
|
4
|
+
|
|
5
|
+
use std::ffi::c_void;
|
|
6
|
+
use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
|
|
7
|
+
|
|
8
|
+
/// GVLを解放して`func`を実行する。
|
|
9
|
+
///
|
|
10
|
+
/// # なぜ`Send`境界が要るか
|
|
11
|
+
///
|
|
12
|
+
/// magnusは「GVLを解放するAPIは存在しない」前提でGVL状態をスレッド
|
|
13
|
+
/// ローカルにキャッシュしており(`magnus::api`の *assumed not to change
|
|
14
|
+
/// because there's currently no api to unlock*)、ここで解放しても
|
|
15
|
+
/// `Ruby::get()`は`Ok`を返してしまう。返ったハンドルでRubyに触ればUBになる。
|
|
16
|
+
///
|
|
17
|
+
/// `Send`境界を課すと、magnusの値(`NonNull<RBasic>`)も`Ruby`ハンドル
|
|
18
|
+
/// (`*mut ()`)も`!Send`なのでキャプチャがコンパイルエラーになる。
|
|
19
|
+
/// クロージャの中で改めて`Ruby::get()`を呼ぶことまでは型では防げないが、
|
|
20
|
+
/// 解放区間で呼ぶのは`sghtmltopdf_core`の関数だけであり、コアはRubyを
|
|
21
|
+
/// 一切知らないため到達しない。
|
|
22
|
+
///
|
|
23
|
+
/// # 割り込み
|
|
24
|
+
///
|
|
25
|
+
/// UBF(unblock function)は`None`=割り込み不可。`Kernel#trap`やCtrl-Cでの
|
|
26
|
+
/// 中断は初期スコープ外とする。
|
|
27
|
+
#[allow(dead_code)] // 将来のGVL解放実装で使う
|
|
28
|
+
pub fn without_gvl<F, R>(func: F) -> R
|
|
29
|
+
where
|
|
30
|
+
F: FnOnce() -> R + Send,
|
|
31
|
+
R: Send,
|
|
32
|
+
{
|
|
33
|
+
struct State<F, R> {
|
|
34
|
+
func: Option<F>,
|
|
35
|
+
result: Option<std::thread::Result<R>>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
unsafe extern "C" fn call<F, R>(arg: *mut c_void) -> *mut c_void
|
|
39
|
+
where
|
|
40
|
+
F: FnOnce() -> R + Send,
|
|
41
|
+
R: Send,
|
|
42
|
+
{
|
|
43
|
+
let state = unsafe { &mut *(arg as *mut State<F, R>) };
|
|
44
|
+
let func = state.func.take().expect("コールバックが2度呼ばれました");
|
|
45
|
+
// パニックがFFI境界を越えるとプロセスがabortするため、ここで捕まえて
|
|
46
|
+
// GVLを取り戻してからRust側でresumeする。
|
|
47
|
+
state.result = Some(catch_unwind(AssertUnwindSafe(func)));
|
|
48
|
+
std::ptr::null_mut()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let mut state = State::<F, R> {
|
|
52
|
+
func: Some(func),
|
|
53
|
+
result: None,
|
|
54
|
+
};
|
|
55
|
+
unsafe {
|
|
56
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
57
|
+
Some(call::<F, R>),
|
|
58
|
+
&mut state as *mut _ as *mut c_void,
|
|
59
|
+
None,
|
|
60
|
+
std::ptr::null_mut(),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
match state.result.expect("コールバックが実行されませんでした") {
|
|
64
|
+
Ok(value) => value,
|
|
65
|
+
Err(panic) => resume_unwind(panic),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// GVLを取り戻して`func`を実行する。[`without_gvl`]の内側からだけ呼ぶ。
|
|
70
|
+
///
|
|
71
|
+
/// `without_gvl`と違い`Send`境界は課さない。ここはGVLを保持している=Rubyに
|
|
72
|
+
/// 触ってよい区間だから。
|
|
73
|
+
///
|
|
74
|
+
/// # 呼び出し側が守ること(libruby側の制約)
|
|
75
|
+
///
|
|
76
|
+
/// * `func`からRubyのオブジェクトを返さない。返すとGVLを再び手放した
|
|
77
|
+
/// あとGCのスコープから外れ、マークされない。値を持ち帰るときは
|
|
78
|
+
/// `rb_gc_register_address`で登録したスロットへ入れること
|
|
79
|
+
/// (`callback_sink::ValueSlot`)
|
|
80
|
+
/// * `func`から例外を投げさせない。longjmpがこの関数を飛び越えると
|
|
81
|
+
/// 未定義動作になる。Rubyの呼び出しは必ず`rb_protect`相当で包む
|
|
82
|
+
/// (magnusの`Proc::call`は内部で`protect`しているのでそのまま使える)
|
|
83
|
+
pub fn with_gvl<F, R>(func: F) -> R
|
|
84
|
+
where
|
|
85
|
+
F: FnOnce() -> R,
|
|
86
|
+
{
|
|
87
|
+
struct State<F, R> {
|
|
88
|
+
func: Option<F>,
|
|
89
|
+
result: Option<std::thread::Result<R>>,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
unsafe extern "C" fn call<F, R>(arg: *mut c_void) -> *mut c_void
|
|
93
|
+
where
|
|
94
|
+
F: FnOnce() -> R,
|
|
95
|
+
{
|
|
96
|
+
let state = unsafe { &mut *(arg as *mut State<F, R>) };
|
|
97
|
+
let func = state.func.take().expect("コールバックが2度呼ばれました");
|
|
98
|
+
// パニックがlibrubyのフレームを越えるとプロセスがabortする。
|
|
99
|
+
state.result = Some(catch_unwind(AssertUnwindSafe(func)));
|
|
100
|
+
std::ptr::null_mut()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let mut state = State::<F, R> {
|
|
104
|
+
func: Some(func),
|
|
105
|
+
result: None,
|
|
106
|
+
};
|
|
107
|
+
unsafe {
|
|
108
|
+
rb_sys::rb_thread_call_with_gvl(Some(call::<F, R>), &mut state as *mut _ as *mut c_void);
|
|
109
|
+
}
|
|
110
|
+
match state.result.expect("コールバックが実行されませんでした") {
|
|
111
|
+
Ok(value) => value,
|
|
112
|
+
Err(panic) => resume_unwind(panic),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//! Ruby拡張のエントリポイント。
|
|
2
|
+
//!
|
|
3
|
+
//! この層は薄く保つ。オプションの引数列(argv)への組み立てはRuby側が
|
|
4
|
+
//! 行い、ここは受け取ったargvをCLI・HTTPサーバと同じパーサへ通して
|
|
5
|
+
//! レンダリングするだけ。
|
|
6
|
+
|
|
7
|
+
mod callback_sink;
|
|
8
|
+
mod errors;
|
|
9
|
+
mod gvl;
|
|
10
|
+
|
|
11
|
+
use std::io::Cursor;
|
|
12
|
+
use std::path::PathBuf;
|
|
13
|
+
|
|
14
|
+
use magnus::rb_sys::AsRawValue;
|
|
15
|
+
use magnus::{block::Proc, function, prelude::*, Error, RString, Ruby};
|
|
16
|
+
use sghtmltopdf_core::cli::{self, convert};
|
|
17
|
+
use sghtmltopdf_core::sink::{FileSink, MemorySink};
|
|
18
|
+
|
|
19
|
+
use callback_sink::{pump_to_block, BlockSlot, PendingUnwind, ValueSlot};
|
|
20
|
+
|
|
21
|
+
/// HTMLを変換してPDFのバイト列を返す。
|
|
22
|
+
fn render(html: RString, argv: Vec<String>) -> Result<RString, Error> {
|
|
23
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
24
|
+
// GVLを解放する前にRust側へコピーする。解放中はRubyのオブジェクトに
|
|
25
|
+
// 触れないため、`RString`のままでは持ち込めない。
|
|
26
|
+
let html = unsafe { html.as_slice() }.to_vec();
|
|
27
|
+
errors::catch_panic(&ruby, move || render_inner(html, argv))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn render_inner(html: Vec<u8>, argv: Vec<String>) -> Result<RString, Error> {
|
|
31
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
32
|
+
let (args, fonts) = cli::parse_convert_argv(&argv).map_err(|e| errors::to_ruby(&ruby, e))?;
|
|
33
|
+
|
|
34
|
+
// GVLを解放したうえで、さらにレンダリング専用のスタックを確保した
|
|
35
|
+
// スレッドへ移す。Rubyのスレッドのマシンスタックは既定1MiBしかなく、
|
|
36
|
+
// レイアウト・描画の再帰に耐えられないため(`callback_sink`のモジュール
|
|
37
|
+
// doc参照)。この経路はRubyへコールバックしないので、そのまま移せる。
|
|
38
|
+
let pdf = gvl::without_gvl(move || {
|
|
39
|
+
cli::with_render_stack(move || {
|
|
40
|
+
convert::render_to_memory(&args, &fonts, Cursor::new(html), MemorySink::new())
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
.map_err(|e| errors::to_ruby(&ruby, e))?;
|
|
44
|
+
|
|
45
|
+
Ok(ruby.str_from_slice(&pdf))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// HTMLを変換して`path`へ書き出す。
|
|
49
|
+
///
|
|
50
|
+
/// 出力先は[`FileSink`]が決めるので、argvの`--output`は使われない
|
|
51
|
+
/// (一時ファイルへ書いて成功時だけrenameするため、途中で失敗しても
|
|
52
|
+
/// 壊れたPDFが残らない)。
|
|
53
|
+
fn render_to_file(html: RString, argv: Vec<String>, path: String) -> Result<(), Error> {
|
|
54
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
55
|
+
let html = unsafe { html.as_slice() }.to_vec();
|
|
56
|
+
errors::catch_panic(&ruby, move || render_to_file_inner(html, argv, path))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn render_to_file_inner(html: Vec<u8>, argv: Vec<String>, path: String) -> Result<(), Error> {
|
|
60
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
61
|
+
let (args, fonts) = cli::parse_convert_argv(&argv).map_err(|e| errors::to_ruby(&ruby, e))?;
|
|
62
|
+
|
|
63
|
+
let path = PathBuf::from(path);
|
|
64
|
+
let sink = FileSink::create(&path).map_err(|e| {
|
|
65
|
+
errors::to_ruby(
|
|
66
|
+
&ruby,
|
|
67
|
+
cli::CliError::Input(format!("{}の作成に失敗しました: {e}", path.display())),
|
|
68
|
+
)
|
|
69
|
+
})?;
|
|
70
|
+
|
|
71
|
+
gvl::without_gvl(move || {
|
|
72
|
+
cli::with_render_stack(move || convert::render(&args, &fonts, Cursor::new(html), sink))
|
|
73
|
+
})
|
|
74
|
+
.map_err(|e| errors::to_ruby(&ruby, e))?;
|
|
75
|
+
Ok(())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// HTMLを変換し、確定したPDFのバイト列を`chunk_size`ごとに`block`へ渡す。
|
|
79
|
+
///
|
|
80
|
+
/// レンダリングの間はGVLを解放し、ブロックを呼ぶ瞬間だけ取り戻す。ブロックが
|
|
81
|
+
/// 例外を投げた場合は、その例外をそのまま呼び出し元へ伝える(エンジン側は
|
|
82
|
+
/// 通常のエラーパスで巻き戻る)。
|
|
83
|
+
fn render_each(
|
|
84
|
+
html: RString,
|
|
85
|
+
argv: Vec<String>,
|
|
86
|
+
block: Proc,
|
|
87
|
+
chunk_size: usize,
|
|
88
|
+
) -> Result<(), Error> {
|
|
89
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
90
|
+
let html = unsafe { html.as_slice() }.to_vec();
|
|
91
|
+
errors::catch_panic(&ruby, move || {
|
|
92
|
+
render_each_inner(html, argv, block, chunk_size)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn render_each_inner(
|
|
97
|
+
html: Vec<u8>,
|
|
98
|
+
argv: Vec<String>,
|
|
99
|
+
block: Proc,
|
|
100
|
+
chunk_size: usize,
|
|
101
|
+
) -> Result<(), Error> {
|
|
102
|
+
let ruby = Ruby::get().expect("GVLを保持したまま呼ばれるはず");
|
|
103
|
+
let (args, fonts) = cli::parse_convert_argv(&argv).map_err(|e| errors::to_ruby(&ruby, e))?;
|
|
104
|
+
|
|
105
|
+
// ブロックはGVL解放区間をまたいで生きる必要があるため、GCへ登録する
|
|
106
|
+
// (解放後にスタックへ積んだ値は保守的GCの走査対象外)。
|
|
107
|
+
let block = ValueSlot::new(block.as_raw());
|
|
108
|
+
let mut pending = PendingUnwind::default();
|
|
109
|
+
|
|
110
|
+
let result = {
|
|
111
|
+
let slot = BlockSlot::new(&block);
|
|
112
|
+
let pending = &mut pending;
|
|
113
|
+
gvl::without_gvl(move || {
|
|
114
|
+
// レンダリングは専用スタックのスレッドで走り、確定したチャンクだけが
|
|
115
|
+
// ここへ戻ってくる。ブロックの呼び出し(=GVLの再取得)は、GVLを
|
|
116
|
+
// 手放したこのスレッドで行う必要があるため`pump_to_block`に任せる。
|
|
117
|
+
pump_to_block(slot, pending, chunk_size, move |sink| {
|
|
118
|
+
convert::render(&args, &fonts, Cursor::new(html), sink)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
drop(block);
|
|
123
|
+
|
|
124
|
+
// ブロック由来の中断は、エンジンが返すエラーより優先して伝える
|
|
125
|
+
// (`Sink::Error`が`io::Error`固定のため、理由はこちらに載っている)。
|
|
126
|
+
// `break`等の脱出は`into_error`の中で`rb_jump_tag`し戻らないので、
|
|
127
|
+
// Rust側の値はここで落としきってから呼ぶ。
|
|
128
|
+
if pending.is_pending() {
|
|
129
|
+
drop(result);
|
|
130
|
+
return Err(pending
|
|
131
|
+
.into_error()
|
|
132
|
+
.expect("is_pendingがtrueなら中断が入っている"));
|
|
133
|
+
}
|
|
134
|
+
result.map_err(|e| errors::to_ruby(&ruby, e))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// coreへリンクできていることの確認用(疎通確認)。
|
|
138
|
+
fn core_version() -> String {
|
|
139
|
+
env!("CARGO_PKG_VERSION").to_string()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// coreのシンボルを実際に1つ呼んでリンクを確かめる。
|
|
143
|
+
fn default_page_size() -> String {
|
|
144
|
+
let settings = sghtmltopdf_core::layout::PageSettings::default();
|
|
145
|
+
format!("{}x{}", settings.size.width, settings.size.height)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// GVLを解放して実行できることの確認用。解放中も他のRubyスレッドが
|
|
149
|
+
/// 進めることをRuby側のテストで検証する。
|
|
150
|
+
fn sleep_without_gvl(ms: u64) {
|
|
151
|
+
gvl::without_gvl(|| std::thread::sleep(std::time::Duration::from_millis(ms)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[magnus::init]
|
|
155
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
156
|
+
let module = ruby.define_module("Sghtmltopdf")?;
|
|
157
|
+
errors::define(ruby, module)?;
|
|
158
|
+
|
|
159
|
+
let native = module.define_module("Native")?;
|
|
160
|
+
native.define_singleton_method("render", function!(render, 2))?;
|
|
161
|
+
native.define_singleton_method("render_to_file", function!(render_to_file, 3))?;
|
|
162
|
+
native.define_singleton_method("render_each", function!(render_each, 4))?;
|
|
163
|
+
native.define_singleton_method("core_version", function!(core_version, 0))?;
|
|
164
|
+
native.define_singleton_method("default_page_size", function!(default_page_size, 0))?;
|
|
165
|
+
native.define_singleton_method("sleep_without_gvl", function!(sleep_without_gvl, 1))?;
|
|
166
|
+
Ok(())
|
|
167
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sghtmltopdf
|
|
4
|
+
# グローバルな既定オプション。
|
|
5
|
+
#
|
|
6
|
+
# Sghtmltopdf.configure do |c|
|
|
7
|
+
# c.page_size = "A4"
|
|
8
|
+
# c.gothic_font = "/path/to/NotoSansJP-Regular.ttf"
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# ここで設定した値は`render`/`render_to_file`の引数で上書きできる
|
|
12
|
+
# (マージ順はグローバル → 呼び出し時)。
|
|
13
|
+
#
|
|
14
|
+
# キー名の妥当性は検査しない。オプション定義はRust側(`cli/options.rs`)の
|
|
15
|
+
# 1箇所に集約する方針のため、未知のキーはレンダリング時にclapが`UsageError`をraiseする。
|
|
16
|
+
class Configuration
|
|
17
|
+
def initialize(options = {})
|
|
18
|
+
@options = {}
|
|
19
|
+
# 明示的に設定した値(@options)と、Railtieなどが流し込んだ既定値
|
|
20
|
+
# (@defaults)は分けて持つ。読み出しは常に@optionsが勝つので、
|
|
21
|
+
# イニシャライザの実行順に依存しない。
|
|
22
|
+
@defaults = {}
|
|
23
|
+
options.each { |key, value| self[key] = value }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](key)
|
|
27
|
+
key = key.to_sym
|
|
28
|
+
@options.key?(key) ? @options[key] : @defaults[key]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def []=(key, value)
|
|
32
|
+
@options[key.to_sym] = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param with_defaults [Boolean] 流し込まれた既定値を含めるか。
|
|
36
|
+
# HTTPサーバへ委譲するときは`false`にする。Rails向けの既定値
|
|
37
|
+
# (`base_url`・`allow`)はローカルのファイル解決のためのもので、
|
|
38
|
+
# サーバモードではリクエストから指定できないキーだから
|
|
39
|
+
def to_h(with_defaults: true)
|
|
40
|
+
with_defaults ? @defaults.merge(@options) : @options.dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 既定値を流し込む。Railtieが Rails向けの既定値を入れるのに使う。
|
|
44
|
+
# 明示的に設定された値より弱い(順序に関係なく`[]=`が勝つ)。
|
|
45
|
+
def apply_defaults(defaults)
|
|
46
|
+
defaults.each { |key, value| @defaults[key.to_sym] = value }
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `c.page_size = "A4"`と`c.page_size`を受ける。
|
|
51
|
+
def method_missing(name, *args)
|
|
52
|
+
key = name.to_s
|
|
53
|
+
if key.end_with?("=")
|
|
54
|
+
raise ArgumentError, "#{name}は引数1つを取ります" unless args.size == 1
|
|
55
|
+
|
|
56
|
+
self[key.chomp("=")] = args.first
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError, "#{name}は引数を取りません" unless args.empty?
|
|
59
|
+
|
|
60
|
+
self[key]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Sghtmltopdf
|
|
6
|
+
# オプションハッシュを変換する。
|
|
7
|
+
#
|
|
8
|
+
# * ネイティブ拡張へ渡すCLIの引数列(argv) … [.to_argv]
|
|
9
|
+
# * HTTPサーバモードへ渡すクエリ文字列 … [.to_query]
|
|
10
|
+
module Options
|
|
11
|
+
# 入力は常に標準入力を表す`-`を置く(実際のバイト列はFFIで直接渡すため
|
|
12
|
+
# 読まれない)。出力先はRust側のSinkが決めるので、ここもダミーの`-`。
|
|
13
|
+
# `-`入力のときCLIは`--output`を必須にするため、省略はできない。
|
|
14
|
+
ARGV_PREFIX = ["sghtmltopdf", "-", "--output", "-"].freeze
|
|
15
|
+
|
|
16
|
+
# Ruby側だけで解釈するキー。変換オプションではないので、argvにも
|
|
17
|
+
# クエリにも出さない。
|
|
18
|
+
TRANSPORT_KEYS = %i[server_url server_open_timeout server_read_timeout chunk_size].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param options [Hash] Rubyのオプションハッシュ
|
|
23
|
+
# @return [Array<String>] clapへ渡す引数列
|
|
24
|
+
def to_argv(options)
|
|
25
|
+
argv = ARGV_PREFIX.dup
|
|
26
|
+
each_pair(options) do |name, value|
|
|
27
|
+
argv.push("--#{name}")
|
|
28
|
+
argv.push(value) unless value.nil?
|
|
29
|
+
end
|
|
30
|
+
argv
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param options [Hash] Rubyのオプションハッシュ
|
|
34
|
+
# @return [String] `POST /pdf`のクエリ文字列(先頭に`?`は付けない)
|
|
35
|
+
def to_query(options)
|
|
36
|
+
parts = []
|
|
37
|
+
each_pair(options) do |name, value|
|
|
38
|
+
# 値なしのフラグはキーだけを置く(サーバは値なし=真として扱う)。
|
|
39
|
+
parts << (value.nil? ? escape(name) : "#{escape(name)}=#{escape(value)}")
|
|
40
|
+
end
|
|
41
|
+
parts.join("&")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 1つのキーと値をargvの断片へ変換する。
|
|
45
|
+
#
|
|
46
|
+
# page_size: "A4" → ["--page-size", "A4"]
|
|
47
|
+
# grayscale: true → ["--grayscale"]
|
|
48
|
+
# grayscale: false → []
|
|
49
|
+
# allow: ["/a", "/b"] → ["--allow", "/a", "--allow", "/b"]
|
|
50
|
+
def args_for(key, value)
|
|
51
|
+
pairs_for(key, value).flat_map { |name, arg| arg.nil? ? ["--#{name}"] : ["--#{name}", arg] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 1つのキーと値を「フラグ名と値」のペアの列にする。値が`nil`のペアは
|
|
55
|
+
# 値を取らないフラグ(`--toc`など)。
|
|
56
|
+
def pairs_for(key, value)
|
|
57
|
+
name = flag_name(key)
|
|
58
|
+
return font_pairs(value) if name == "font"
|
|
59
|
+
|
|
60
|
+
case value
|
|
61
|
+
when nil, false then []
|
|
62
|
+
when true then [[name, nil]]
|
|
63
|
+
# 配列は同じオプションの繰り返し。要素ごとに同じ規則を適用する。
|
|
64
|
+
when Array then value.flat_map { |element| pairs_for(key, element) }
|
|
65
|
+
when Hash
|
|
66
|
+
# wicked_pdfの`margin: {top: 10}`のような入れ子は受けない。数値の
|
|
67
|
+
# 単位の解釈が違う(wicked_pdfはmm・こちらはpx)ため、機械的に
|
|
68
|
+
# 平坦化すると黙って別の余白になる。移行時は
|
|
69
|
+
# 移行ガイドの対応表を見て書き換えてもらう。
|
|
70
|
+
example = value.keys.first
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"#{key}にHashは渡せません(pathとindexを取るのは:fontだけです)。" \
|
|
73
|
+
"入れ子のオプションは平坦なキーで指定してください" \
|
|
74
|
+
"#{": 例 #{key}_#{example}: \"…\"" if example}"
|
|
75
|
+
else [[name, value.to_s]]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `--font`と`--font-index`は出現順で対応付けられる(CLIは
|
|
80
|
+
# `ArgMatches#indices_of`で「`--font-index`より手前にある最後の`--font`」
|
|
81
|
+
# へ結び付ける)。そのため、フェイス番号は
|
|
82
|
+
# 必ず対応する`--font`の直後へ置く。
|
|
83
|
+
#
|
|
84
|
+
# font: "a.ttf" → ["--font", "a.ttf"]
|
|
85
|
+
# font: {path: "a.ttc", index: 1} → ["--font", "a.ttc", "--font-index", "1"]
|
|
86
|
+
# font: ["a.ttf", {path: "b.ttc", index: 2}]
|
|
87
|
+
# → ["--font", "a.ttf", "--font", "b.ttc", "--font-index", "2"]
|
|
88
|
+
def font_args(value)
|
|
89
|
+
font_pairs(value).flat_map { |name, arg| ["--#{name}", arg] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def font_pairs(value)
|
|
93
|
+
case value
|
|
94
|
+
when nil, false then []
|
|
95
|
+
when Array then value.flat_map { |element| font_pairs(element) }
|
|
96
|
+
when Hash
|
|
97
|
+
path = value[:path] || value["path"]
|
|
98
|
+
raise ArgumentError, "fontのHashにはpathが必要です: #{value.inspect}" if path.nil?
|
|
99
|
+
|
|
100
|
+
index = value[:index] || value["index"]
|
|
101
|
+
pairs = [["font", path.to_s]]
|
|
102
|
+
pairs << ["font-index", index.to_s] unless index.nil?
|
|
103
|
+
pairs
|
|
104
|
+
else [["font", value.to_s]]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `:page_size` → `page-size`。
|
|
109
|
+
def flag_name(key)
|
|
110
|
+
key.to_s.tr("_", "-")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 変換オプションだけを、渡された順にペアとして列挙する。
|
|
114
|
+
def each_pair(options, &block)
|
|
115
|
+
options.each do |key, value|
|
|
116
|
+
next if TRANSPORT_KEYS.include?(key.to_sym)
|
|
117
|
+
|
|
118
|
+
pairs_for(key, value).each(&block)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def escape(value)
|
|
123
|
+
URI.encode_www_form_component(value.to_s)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require_relative "renderer"
|
|
5
|
+
require_relative "view_helpers"
|
|
6
|
+
|
|
7
|
+
module Sghtmltopdf
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
def self.default_options(root)
|
|
10
|
+
root = root.to_s
|
|
11
|
+
defaults = {allow: [root]}
|
|
12
|
+
public_dir = File.join(root, "public")
|
|
13
|
+
defaults[:base_url] = public_dir if File.directory?(public_dir)
|
|
14
|
+
defaults
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "sghtmltopdf.defaults" do |app|
|
|
18
|
+
Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app.root))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer "sghtmltopdf.renderer" do
|
|
22
|
+
ActiveSupport.on_load(:action_controller) do
|
|
23
|
+
Sghtmltopdf::Renderer.register!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ActiveSupport.on_load(:action_view) do
|
|
27
|
+
include Sghtmltopdf::ViewHelpers
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sghtmltopdf
|
|
4
|
+
# `render pdf: "invoice"`のオプションを、
|
|
5
|
+
#
|
|
6
|
+
# * Railsのビュー描画(`render_to_string`)へ渡すもの
|
|
7
|
+
# * レスポンスの組み立て(`send_data`)へ渡すもの
|
|
8
|
+
# * PDF変換(`Sghtmltopdf.render`)へ渡すもの
|
|
9
|
+
#
|
|
10
|
+
# の3つに振り分ける。
|
|
11
|
+
# Railsに依存しないpure Rubyのクラスなので、Rails無しでも単体テストできる。
|
|
12
|
+
class Renderer
|
|
13
|
+
# `render_to_string`へそのまま渡すキー。
|
|
14
|
+
RAILS_RENDER_KEYS = %i[
|
|
15
|
+
action assigns body collection file formats handlers html inline layout
|
|
16
|
+
locals object partial plain prefixes template variants
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# レスポンスの組み立てに使うキー(`send_data`へ渡す)。
|
|
20
|
+
RESPONSE_KEYS = %i[disposition filename status].freeze
|
|
21
|
+
|
|
22
|
+
# レンダラ自身が解釈するキー(PDFにせずHTMLのまま返すデバッグ用)。
|
|
23
|
+
RENDERER_KEYS = %i[show_as_html].freeze
|
|
24
|
+
|
|
25
|
+
PDF_CONTENT_TYPE = "application/pdf"
|
|
26
|
+
HTML_CONTENT_TYPE = "text/html"
|
|
27
|
+
|
|
28
|
+
attr_reader :name, :options
|
|
29
|
+
|
|
30
|
+
# @param name [String, Symbol, nil] `pdf:`に渡された値(ファイル名の素)
|
|
31
|
+
# @param options [Hash] `render`に渡されたその他のオプション
|
|
32
|
+
# @param default_name [String, nil] `name`が空のときのファイル名
|
|
33
|
+
# (コントローラの`action_name`を想定)
|
|
34
|
+
def initialize(name, options = {}, default_name: nil)
|
|
35
|
+
@name = blank?(name) ? (default_name || "document").to_s : name.to_s
|
|
36
|
+
@options = options.to_h { |key, value| [key.to_sym, value] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `ActionController::Renderers.add(:pdf)`でレンダラを登録する。
|
|
40
|
+
# RailtieのAction Controller読み込みフック(`on_load`)から呼ぶ。
|
|
41
|
+
def self.register!
|
|
42
|
+
::ActionController::Renderers.add(:pdf) do |name, options|
|
|
43
|
+
renderer = ::Sghtmltopdf::Renderer.new(name, options, default_name: action_name)
|
|
44
|
+
html = render_to_string(**renderer.render_options)
|
|
45
|
+
send_data(renderer.body_for(html), **renderer.send_data_options)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ビューの描画に使うオプション。
|
|
50
|
+
def render_options
|
|
51
|
+
options.select { |key, _| RAILS_RENDER_KEYS.include?(key) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# PDF変換に使うオプション。
|
|
55
|
+
def convert_options
|
|
56
|
+
known = RAILS_RENDER_KEYS + RESPONSE_KEYS + RENDERER_KEYS
|
|
57
|
+
options.reject { |key, _| known.include?(key) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 描画したHTMLをレスポンスの本文へ変換する。
|
|
61
|
+
def body_for(html)
|
|
62
|
+
show_as_html? ? html : Sghtmltopdf.render(html, **convert_options)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send_data_options
|
|
66
|
+
opts = {type: content_type, disposition: disposition}
|
|
67
|
+
opts[:filename] = filename unless show_as_html?
|
|
68
|
+
opts[:status] = options[:status] if options.key?(:status)
|
|
69
|
+
opts
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def content_type
|
|
73
|
+
show_as_html? ? HTML_CONTENT_TYPE : PDF_CONTENT_TYPE
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `filename: "x.pdf"` > `pdf: "x"` の順。拡張子は二重に付けない。
|
|
77
|
+
def filename
|
|
78
|
+
base = blank?(options[:filename]) ? name : options[:filename].to_s
|
|
79
|
+
base.downcase.end_with?(".pdf") ? base : "#{base}.pdf"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# wicked_pdfと同じく既定は`inline`(ブラウザ内で開く)。
|
|
83
|
+
def disposition
|
|
84
|
+
blank?(options[:disposition]) ? "inline" : options[:disposition].to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# wicked_pdfの`show_as_html`相当。PDFにせずHTMLをそのまま返すので、
|
|
88
|
+
# ブラウザの開発者ツールでレイアウトを確認できる。
|
|
89
|
+
def show_as_html?
|
|
90
|
+
value = options[:show_as_html]
|
|
91
|
+
!(value.nil? || value == false || value == "false")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def blank?(value)
|
|
97
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|