typst 0.15.1.6 → 0.15.1.9.pre

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97e4afa751e5a0bded609e423ea893b263ac5676afe79fe4bcde12761bd242dd
4
- data.tar.gz: 112c45f8dbebcdcbcec318948b879dc47f44dc5c2bd5a143e4c035928dec6473
3
+ metadata.gz: 3c7cc49e13eced386fc44cfb914ebf07dc5892490730f3fd4fbed7448e974f74
4
+ data.tar.gz: 1f7e43e5d752bf8817f22ad3a3da673f1370f697d93effe05d69fe15128960e9
5
5
  SHA512:
6
- metadata.gz: d825ef788371496a9b354cf1b22d3ea06c9f7fe2e3d1cc56b18310a9af1fede42fe449b23964c20f2b34df98a31155846d21750da0eaed97d146abbd4a7ad425
7
- data.tar.gz: 0c561636ed80e29170ba849853ecd0c8342cb3abc609fb1d7ffeadc466eda702d537a454e1becd36e61f7339adc320ab6177adaf84d3bd8ef048ba6e7ddab222
6
+ metadata.gz: c44932e11673f6881589eb84e1e7a736e1116adf8c4f9200d4e14da7624eeb24df01320dac16faea7f5ac3e0d8567ea44c400422c94c57e250e7c705d289643c
7
+ data.tar.gz: 8b5689a033596181456b73a321f7df85b3ecd66b67865a33b6030b9560d0474b596f330564b7c335bf0f15bb9e469129fea424d01f1ac97ef1f13f595a7511c8
data/Cargo.lock CHANGED
@@ -2060,18 +2060,18 @@ dependencies = [
2060
2060
 
2061
2061
  [[package]]
2062
2062
  name = "rb-sys"
2063
- version = "0.9.128"
2063
+ version = "0.9.130"
2064
2064
  source = "registry+https://github.com/rust-lang/crates.io-index"
2065
- checksum = "45ca28513560e56cfb79a62b1fce363c73af170a182024ce880c77ee9429920a"
2065
+ checksum = "02faf625bb10ba893e3ae620f19c9fb1b5f8fcae0fe4eb86bb3f2230fad75edb"
2066
2066
  dependencies = [
2067
2067
  "rb-sys-build",
2068
2068
  ]
2069
2069
 
2070
2070
  [[package]]
2071
2071
  name = "rb-sys-build"
2072
- version = "0.9.128"
2072
+ version = "0.9.130"
2073
2073
  source = "registry+https://github.com/rust-lang/crates.io-index"
2074
- checksum = "ce04b2c55eff3a21aaa623fcc655d94373238e72cac6b3e1a3641ff31649f99a"
2074
+ checksum = "05be6f9c86fe5482808162826f6c047d093b3670582296c770de1d94f8066694"
2075
2075
  dependencies = [
2076
2076
  "bindgen",
2077
2077
  "lazy_static",
data/Cargo.toml CHANGED
@@ -1,3 +1,6 @@
1
1
  [workspace]
2
2
  members = ["ext/typst"]
3
- resolver = "2"
3
+ resolver = "2"
4
+
5
+ [profile.release]
6
+ strip = true
data/README.md CHANGED
@@ -263,13 +263,67 @@ max_age = 10
263
263
  Typst::clear_cache(max_age)
264
264
  ```
265
265
 
266
+ ### Experimental
267
+
268
+ #### Threads and concurrency
269
+
270
+ A new experimental `concurrent` flag is added to work around Ruby's global VM lock and speed things up for batch workloads.
271
+
272
+ Set the option globally
273
+ ```ruby
274
+ Typst.concurrent = true
275
+ ```
276
+
277
+ Or set the option per call
278
+ ```ruby
279
+ Typst(body: invoice, concurrent: true)
280
+ ```
281
+
282
+ Compiling releases Ruby's global VM lock, so several threads can compile at the
283
+ same time and genuinely use several cores.
284
+
285
+ ```ruby
286
+ docs = invoices.map { |invoice| Thread.new { Typst(body: invoice, concurrent: true).compile(:pdf) } }.map(&:value)
287
+ ```
288
+
289
+ Eight threads compiling the same document, measured on a 24-core machine:
290
+
291
+ | threads | documents/second | cores busy |
292
+ |--------:|-----------------:|-----------:|
293
+ | 1 | 9.9 | 1.00 |
294
+ | 8 | 36.0 | 7.65 |
295
+
296
+ That is throughput across documents, not speed within one. typst parallelizes
297
+ page runs, so a document with a single page layout is one run and stays on one
298
+ core no matter how many threads it is given.
299
+
300
+ Threads share the compilation cache and the font discovery. Compiling different
301
+ sources, with different `sys_inputs`, font paths and roots, at the same time is
302
+ safe.
303
+
304
+ Three things to know:
305
+
306
+ * A compile cannot be interrupted. `Thread#kill` and `Ctrl-C` take effect once
307
+ it returns.
308
+ * Package storage is not safe to populate concurrently. Threads that need the
309
+ same not-yet-cached package each download their own copy and untar it
310
+ straight into the same directory, over each other; on Windows that fails
311
+ outright rather than clobbering. Compile once to warm the cache before
312
+ fanning out. Reading a package already on disk from many threads is safe.
313
+ * `clear_cache` takes the cache's write lock, so calling it in a loop while other
314
+ threads compile will starve them. Measured with four threads compiling and one
315
+ evicting as fast as it could, the compiles took 400 times longer. Call it
316
+ between batches of documents, not between documents.
317
+
266
318
  ## Contributors & Acknowledgements
267
319
  typst-rb is based on [typst-py](https://github.com/messense/typst-py) by [messense](https://github.com/messense)\
268
320
  clear_cache was contributed by [NRicciVestmark](https://github.com/NRicciVestmark)\
269
321
  CI improvements were contributed by [am1006](https://github.com/am1006)\
270
322
  Defect resolutions by [adam12](https://github.com/adam12) and [walterdavis](https://github.com/walterdavis)\
271
323
  Design suggestions by [alec-c4](https://github.com/alec-c4)\
272
- Compiler warnings were contributed by [TheSoloHacker47](https://github.com/TheSoloHacker47)
324
+ Compiler warnings were contributed by [TheSoloHacker47](https://github.com/TheSoloHacker47) \
325
+ Font optimization patches were contributed by [dmke](https://github.com/dmke) \
326
+ Concurrency patches were contributed by [dmke](https://github.com/dmke)
273
327
 
274
328
  ## License
275
329
 
data/README.typ CHANGED
@@ -270,6 +270,61 @@ max_age = 10
270
270
  Typst::clear_cache(max_age)
271
271
  ```
272
272
 
273
+ === Experimental
274
+
275
+ ==== Threads and concurrency
276
+
277
+ A new experimental `concurrent` flag is added to work around Ruby's global VM lock and speed things up for batch workloads.
278
+
279
+ Set the option globally
280
+ ```ruby
281
+ Typst.concurrent = true
282
+ ```
283
+
284
+ Or set the option per call
285
+ ```ruby
286
+ Typst(body: invoice, concurrent: true)
287
+ ```
288
+
289
+ Compiling releases Ruby's global VM lock, so several threads can compile at the
290
+ same time and genuinely use several cores.
291
+
292
+ ```ruby
293
+ docs = invoices.map { |invoice| Thread.new { Typst(body: invoice, concurrent: true).compile(:pdf) } }.map(&:value)
294
+ ```
295
+
296
+ Eight threads compiling the same document, measured on a 24-core machine:
297
+
298
+ #table(
299
+ columns: 3,
300
+ align: (right, right, right),
301
+ table.header([threads], [documents/second], [cores busy]),
302
+ [1], [9.9], [1.00],
303
+ [8], [36.0], [7.65],
304
+ )
305
+
306
+ That is throughput across documents, not speed within one. typst parallelizes
307
+ page runs, so a document with a single page layout is one run and stays on one
308
+ core no matter how many threads it is given.
309
+
310
+ Threads share the compilation cache and the font discovery. Compiling different
311
+ sources, with different `sys_inputs`, font paths and roots, at the same time is
312
+ safe.
313
+
314
+ Three things to know:
315
+
316
+ - A compile cannot be interrupted. `Thread#kill` and `Ctrl-C` take effect once
317
+ it returns.
318
+ - Package storage is not safe to populate concurrently. Threads that need the
319
+ same not-yet-cached package each download their own copy and untar it
320
+ straight into the same directory, over each other; on Windows that fails
321
+ outright rather than clobbering. Compile once to warm the cache before
322
+ fanning out. Reading a package already on disk from many threads is safe.
323
+ - `clear_cache` takes the cache's write lock, so calling it in a loop while other
324
+ threads compile will starve them. Measured with four threads compiling and one
325
+ evicting as fast as it could, the compiles took 400 times longer. Call it
326
+ between batches of documents, not between documents.
327
+
273
328
  == Contributors & Acknowledgements
274
329
  typst-rb is based on #link("https://github.com/messense/typst-py")[typst-py] by #link("https://github.com/messense")[messense]\
275
330
  clear_cache was contributed by #link("https://github.com/NRicciVestmark")[NRicciVestmark]\
@@ -277,6 +332,7 @@ CI improvements were contributed by #link("https://github.com/am1006")[am1006]\
277
332
  Defect resolutions by #link("https://github.com/adam12")[adam12] and #link("https://github.com/walterdavis")[walterdavis]\
278
333
  Design suggestions by #link("https://github.com/alec-c4")[alec-c4]\
279
334
  Compiler warnings were contributed by #link("https://github.com/TheSoloHacker47")[TheSoloHacker47]
335
+ Concurrency patches were contributed by #link("https://github.com/dmke")[dmke]
280
336
 
281
337
  == License
282
338
 
data/Rakefile CHANGED
@@ -30,10 +30,18 @@ end
30
30
 
31
31
  Rake::TestTask.new do |t|
32
32
  t.libs << "test"
33
- t.test_files = FileList['test/*_test.rb']
33
+ # Absolute, because test/typst_test.rb chdirs into its own directory as it
34
+ # loads and the loader resolves what follows it against the working directory.
35
+ t.test_files = FileList['test/*_test.rb'].map { |f| File.expand_path(f) }
34
36
  t.verbose = true
35
37
  end
36
38
 
39
+ task 'benchmark' do |t|
40
+ Dir.glob("benchmarks/*.rb").each do |b|
41
+ sh "bundle exec ruby #{b}"
42
+ end
43
+ end
44
+
37
45
  task 'gem:native' do |t|
38
46
  CROSS_PLATFORMS.each do |platform|
39
47
  sh "bundle exec rb-sys-dock --platform #{platform} --build"
@@ -45,4 +53,4 @@ task 'gem:native:push' do |t|
45
53
  CROSS_PLATFORMS.each do |platform|
46
54
  sh "gem push pkg/typst-#{spec.version}-#{platform}.gem"
47
55
  end
48
- end
56
+ end
data/ext/typst/Cargo.toml CHANGED
@@ -52,4 +52,4 @@ walkdir = "2.4.0"
52
52
 
53
53
  # enable rb-sys feature to test against Ruby head. This is only needed if you
54
54
  # want to work with the unreleased, in-development, next version of Ruby
55
- rb-sys = { version = "0.9.128", default-features = false, features = ["stable-api-compiled-fallback"] }
55
+ rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api-compiled-fallback"] }
data/ext/typst/extconf.rb CHANGED
@@ -1,4 +1,15 @@
1
1
  require "mkmf"
2
2
  require "rb_sys/mkmf"
3
3
 
4
+ module RbSys
5
+ class CargoBuilder < Gem::Ext::Builder
6
+ alias_method :old_platform_specific_rustc_args, :platform_specific_rustc_args
7
+ def new_platform_specific_rustc_args(dest_dir, flags = [])
8
+ flags += ["-l", "kernel32"] if mingw_target? # add kernel32 before libruby to avoid the sleep bug
9
+ old_platform_specific_rustc_args(dest_dir, flags)
10
+ end
11
+ alias_method :platform_specific_rustc_args, :new_platform_specific_rustc_args
12
+ end
13
+ end
14
+
4
15
  create_rust_makefile("typst/typst")
data/ext/typst/src/lib.rs CHANGED
@@ -7,15 +7,16 @@ use query::{query as typst_query, QueryCommand, SerializationFormat};
7
7
  use typst::foundations::{Dict, Value};
8
8
  use typst_library::Feature;
9
9
  use typst_pdf::PdfStandard;
10
+ use nogvl::without_gvl;
10
11
  use world::SystemWorld;
11
12
 
12
13
  mod compiler;
13
14
  mod download;
15
+ mod nogvl;
14
16
  mod query;
15
17
  mod world;
16
18
 
17
19
  fn to_html(
18
- ruby: &Ruby,
19
20
  input: PathBuf,
20
21
  root: Option<PathBuf>,
21
22
  font_paths: Vec<PathBuf>,
@@ -24,13 +25,13 @@ fn to_html(
24
25
  render_bleed: bool,
25
26
  pretty: bool,
26
27
  sys_inputs: HashMap<String, String>,
27
- ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
28
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
28
29
  let input = input.canonicalize()
29
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?;
30
+ .map_err(|err| err.to_string())?;
30
31
 
31
32
  let root = if let Some(root) = root {
32
33
  root.canonicalize()
33
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?
34
+ .map_err(|err| err.to_string())?
34
35
  } else if let Some(dir) = input.parent() {
35
36
  dir.into()
36
37
  } else {
@@ -60,32 +61,53 @@ fn to_html(
60
61
  .ignore_system_fonts(ignore_system_fonts)
61
62
  .ignore_embedded_fonts(ignore_embedded_fonts)
62
63
  .build()
63
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
64
+ .map_err(|msg| msg.to_string())?;
64
65
 
65
66
  let compiled = world
66
67
  .compile(Some("html"), None, &Vec::new(), pretty, render_bleed)
67
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
68
+ .map_err(|msg| msg.to_string())?;
68
69
 
69
70
  Ok(compiled)
70
71
  }
71
72
 
72
- fn to_svg(
73
+ fn route_to_html(
73
74
  ruby: &Ruby,
74
75
  input: PathBuf,
75
76
  root: Option<PathBuf>,
76
77
  font_paths: Vec<PathBuf>,
77
78
  ignore_system_fonts: bool,
78
79
  ignore_embedded_fonts: bool,
80
+ concurrent: bool,
79
81
  render_bleed: bool,
80
82
  pretty: bool,
81
83
  sys_inputs: HashMap<String, String>,
82
84
  ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
85
+ if concurrent {
86
+ without_gvl(move || -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
87
+ to_html(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, pretty, sys_inputs)
88
+ })
89
+ } else {
90
+ to_html(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, pretty, sys_inputs)
91
+ }
92
+ .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg))
93
+ }
94
+
95
+ fn to_svg(
96
+ input: PathBuf,
97
+ root: Option<PathBuf>,
98
+ font_paths: Vec<PathBuf>,
99
+ ignore_system_fonts: bool,
100
+ ignore_embedded_fonts: bool,
101
+ render_bleed: bool,
102
+ pretty: bool,
103
+ sys_inputs: HashMap<String, String>,
104
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
83
105
  let input = input.canonicalize()
84
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?;
106
+ .map_err(|msg| msg.to_string())?;
85
107
 
86
108
  let root = if let Some(root) = root {
87
109
  root.canonicalize()
88
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?
110
+ .map_err(|msg| msg.to_string())?
89
111
  } else if let Some(dir) = input.parent() {
90
112
  dir.into()
91
113
  } else {
@@ -102,32 +124,53 @@ fn to_svg(
102
124
  .ignore_system_fonts(ignore_system_fonts)
103
125
  .ignore_embedded_fonts(ignore_embedded_fonts)
104
126
  .build()
105
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
127
+ .map_err(|msg| msg.to_string())?;
106
128
 
107
129
  let compiled = world
108
130
  .compile(Some("svg"), None, &Vec::new(), pretty, render_bleed)
109
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
131
+ .map_err(|msg| msg.to_string())?;
110
132
 
111
133
  Ok(compiled)
112
134
  }
113
135
 
114
- fn to_png(
136
+ fn route_to_svg(
115
137
  ruby: &Ruby,
116
138
  input: PathBuf,
117
139
  root: Option<PathBuf>,
118
140
  font_paths: Vec<PathBuf>,
119
141
  ignore_system_fonts: bool,
120
142
  ignore_embedded_fonts: bool,
143
+ concurrent: bool,
121
144
  render_bleed: bool,
145
+ pretty: bool,
122
146
  sys_inputs: HashMap<String, String>,
123
- ppi: Option<f32>,
124
147
  ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
148
+ if concurrent {
149
+ without_gvl(move || -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
150
+ to_svg(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, pretty, sys_inputs)
151
+ })
152
+ } else {
153
+ to_svg(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, pretty, sys_inputs)
154
+ }
155
+ .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg))
156
+ }
157
+
158
+ fn to_png(
159
+ input: PathBuf,
160
+ root: Option<PathBuf>,
161
+ font_paths: Vec<PathBuf>,
162
+ ignore_system_fonts: bool,
163
+ ignore_embedded_fonts: bool,
164
+ render_bleed: bool,
165
+ sys_inputs: HashMap<String, String>,
166
+ ppi: Option<f32>,
167
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
125
168
  let input = input.canonicalize()
126
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?;
169
+ .map_err(|msg| msg.to_string())?;
127
170
 
128
171
  let root = if let Some(root) = root {
129
172
  root.canonicalize()
130
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?
173
+ .map_err(|msg| msg.to_string())?
131
174
  } else if let Some(dir) = input.parent() {
132
175
  dir.into()
133
176
  } else {
@@ -144,17 +187,38 @@ fn to_png(
144
187
  .ignore_system_fonts(ignore_system_fonts)
145
188
  .ignore_embedded_fonts(ignore_embedded_fonts)
146
189
  .build()
147
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
190
+ .map_err(|msg| msg.to_string())?;
148
191
 
149
192
  let compiled = world
150
193
  .compile(Some("png"), ppi, &Vec::new(), false, render_bleed)
151
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
194
+ .map_err(|msg| msg.to_string())?;
152
195
 
153
196
  Ok(compiled)
154
197
  }
155
198
 
156
- fn to_pdf(
199
+ fn route_to_png(
157
200
  ruby: &Ruby,
201
+ input: PathBuf,
202
+ root: Option<PathBuf>,
203
+ font_paths: Vec<PathBuf>,
204
+ ignore_system_fonts: bool,
205
+ ignore_embedded_fonts: bool,
206
+ concurrent: bool,
207
+ render_bleed: bool,
208
+ sys_inputs: HashMap<String, String>,
209
+ ppi: Option<f32>,
210
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
211
+ if concurrent {
212
+ without_gvl(move || -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
213
+ to_png(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, sys_inputs, ppi)
214
+ })
215
+ } else {
216
+ to_png(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, render_bleed, sys_inputs, ppi)
217
+ }
218
+ .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg))
219
+ }
220
+
221
+ fn to_pdf(
158
222
  input: PathBuf,
159
223
  root: Option<PathBuf>,
160
224
  font_paths: Vec<PathBuf>,
@@ -163,13 +227,13 @@ fn to_pdf(
163
227
  pretty: bool,
164
228
  sys_inputs: HashMap<String, String>,
165
229
  pdf_standards: Vec<String>,
166
- ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
230
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
167
231
  let input = input.canonicalize()
168
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?;
232
+ .map_err(|msg| msg.to_string())?;
169
233
 
170
234
  let root = if let Some(root) = root {
171
235
  root.canonicalize()
172
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?
236
+ .map_err(|msg| msg.to_string())?
173
237
  } else if let Some(dir) = input.parent() {
174
238
  dir.into()
175
239
  } else {
@@ -186,7 +250,7 @@ fn to_pdf(
186
250
  .ignore_system_fonts(ignore_system_fonts)
187
251
  .ignore_embedded_fonts(ignore_embedded_fonts)
188
252
  .build()
189
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
253
+ .map_err(|msg| msg.to_string())?;
190
254
 
191
255
  let pdf_standards_lookup: HashMap::<&str, PdfStandard> = HashMap::from([
192
256
  ("1.4", PdfStandard::V_1_4),
@@ -213,19 +277,40 @@ fn to_pdf(
213
277
  let result = pdf_standards_lookup.get(pdf_standard.as_str());
214
278
  match result {
215
279
  Some(value) => pdf_standards_vec.push(*value),
216
- _ => return Err(magnus::Error::new(ruby.exception_arg_error(), "Unknown PdfStandard")),
280
+ _ => return Err("Unknown PdfStandard".to_string()),
217
281
  }
218
282
  }
219
283
 
220
284
  let compiled = world
221
285
  .compile(Some("pdf"), None, &pdf_standards_vec, pretty, true)
222
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
286
+ .map_err(|msg| msg.to_string())?;
223
287
 
224
288
  Ok(compiled)
225
289
  }
226
290
 
227
- fn query(
291
+ fn route_to_pdf(
228
292
  ruby: &Ruby,
293
+ input: PathBuf,
294
+ root: Option<PathBuf>,
295
+ font_paths: Vec<PathBuf>,
296
+ ignore_system_fonts: bool,
297
+ ignore_embedded_fonts: bool,
298
+ concurrent: bool,
299
+ pretty: bool,
300
+ sys_inputs: HashMap<String, String>,
301
+ pdf_standards: Vec<String>,
302
+ ) -> Result<(Vec<Vec<u8>>, Vec<String>), Error> {
303
+ if concurrent {
304
+ without_gvl(move || -> Result<(Vec<Vec<u8>>, Vec<String>), String> {
305
+ to_pdf(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, pretty, sys_inputs, pdf_standards)
306
+ })
307
+ } else {
308
+ to_pdf(input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, pretty, sys_inputs, pdf_standards)
309
+ }
310
+ .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg))
311
+ }
312
+
313
+ fn query(
229
314
  selector: String,
230
315
  field: Option<String>,
231
316
  one: bool,
@@ -236,19 +321,19 @@ fn query(
236
321
  ignore_system_fonts: bool,
237
322
  ignore_embedded_fonts: bool,
238
323
  sys_inputs: HashMap<String, String>,
239
- ) -> Result<String, Error> {
324
+ ) -> Result<String, String> {
240
325
  let format = match format.unwrap().to_ascii_lowercase().as_str() {
241
326
  "json" => SerializationFormat::Json,
242
327
  "yaml" => SerializationFormat::Yaml,
243
- _ => return Err(magnus::Error::new(ruby.exception_arg_error(), "unsupported serialization format"))?,
328
+ _ => return Err("unsupported serialization format".to_string()),
244
329
  };
245
330
 
246
331
  let input = input.canonicalize()
247
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?;
332
+ .map_err(|msg| msg.to_string())?;
248
333
 
249
334
  let root = if let Some(root) = root {
250
335
  root.canonicalize()
251
- .map_err(|err| magnus::Error::new(ruby.exception_arg_error(), err.to_string()))?
336
+ .map_err(|msg| msg.to_string())?
252
337
  } else if let Some(dir) = input.parent() {
253
338
  dir.into()
254
339
  } else {
@@ -265,7 +350,7 @@ fn query(
265
350
  .ignore_system_fonts(ignore_system_fonts)
266
351
  .ignore_embedded_fonts(ignore_embedded_fonts)
267
352
  .build()
268
- .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg.to_string()))?;
353
+ .map_err(|msg| msg.to_string())?;
269
354
 
270
355
  let result = typst_query(
271
356
  &mut world,
@@ -279,24 +364,53 @@ fn query(
279
364
 
280
365
  match result {
281
366
  Ok(data) => Ok(data),
282
- Err(msg) => Err(magnus::Error::new(ruby.exception_arg_error(), msg.to_string())),
367
+ Err(msg) => return Err(msg.to_string()),
368
+ }
369
+ }
370
+
371
+ fn route_query(
372
+ ruby: &Ruby,
373
+ selector: String,
374
+ field: Option<String>,
375
+ one: bool,
376
+ format: Option<String>,
377
+ input: PathBuf,
378
+ root: Option<PathBuf>,
379
+ font_paths: Vec<PathBuf>,
380
+ ignore_system_fonts: bool,
381
+ ignore_embedded_fonts: bool,
382
+ concurrent: bool,
383
+ sys_inputs: HashMap<String, String>,
384
+ ) -> Result<String, Error> {
385
+ if concurrent {
386
+ without_gvl(move || -> Result<String, String> {
387
+ query(selector, field, one, format, input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, sys_inputs)
388
+ })
389
+ } else {
390
+ query(selector, field, one, format, input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, sys_inputs)
283
391
  }
392
+ .map_err(|msg| magnus::Error::new(ruby.exception_arg_error(), msg))
284
393
  }
285
394
 
286
395
  fn clear_cache(_ruby: &Ruby, max_age: usize) {
287
396
  comemo::evict(max_age);
288
397
  }
289
398
 
399
+ fn clear_font_cache(_ruby: &Ruby) {
400
+ world::clear_font_cache();
401
+ }
402
+
290
403
  #[magnus::init]
291
404
  fn init(ruby: &Ruby) -> Result<(), Error> {
292
405
  env_logger::init();
293
406
 
294
407
  let module = ruby.define_module("Typst")?;
295
- module.define_singleton_method("_to_pdf", function!(to_pdf, 8))?;
296
- module.define_singleton_method("_to_svg", function!(to_svg, 8))?;
297
- module.define_singleton_method("_to_png", function!(to_png, 8))?;
298
- module.define_singleton_method("_to_html", function!(to_html, 8))?;
299
- module.define_singleton_method("_query", function!(query, 10))?;
408
+ module.define_singleton_method("_to_pdf", function!(route_to_pdf, 9))?;
409
+ module.define_singleton_method("_to_svg", function!(route_to_svg, 9))?;
410
+ module.define_singleton_method("_to_png", function!(route_to_png, 9))?;
411
+ module.define_singleton_method("_to_html", function!(route_to_html, 9))?;
412
+ module.define_singleton_method("_query", function!(route_query, 11))?;
300
413
  module.define_singleton_method("_clear_cache", function!(clear_cache, 1))?;
414
+ module.define_singleton_method("_clear_font_cache", function!(clear_font_cache, 0))?;
301
415
  Ok(())
302
416
  }
@@ -0,0 +1,49 @@
1
+ use std::ffi::c_void;
2
+ use std::panic::{catch_unwind, AssertUnwindSafe};
3
+
4
+ /// Runs `f` with the GVL released, so other Ruby threads keep running while
5
+ /// Typst compiles.
6
+ ///
7
+ /// `f` runs on the calling OS thread and must not touch the Ruby API — no
8
+ /// `Ruby` handle, no `Value`, no allocation of Ruby objects. Convert to and
9
+ /// from Ruby types outside of this call.
10
+ ///
11
+ /// No unblocking function is registered, so the call cannot be interrupted;
12
+ /// a compile always runs to completion. That matches the previous behaviour,
13
+ /// where holding the GVL made the compile uninterruptible anyway.
14
+ pub fn without_gvl<F, T>(f: F) -> T
15
+ where
16
+ F: FnOnce() -> T,
17
+ {
18
+ struct Payload<F, T> {
19
+ f: Option<F>,
20
+ result: Option<std::thread::Result<T>>,
21
+ }
22
+
23
+ // A Rust panic must not unwind across the C frame that Ruby pushes here,
24
+ // so it is caught and resumed once we hold the GVL again.
25
+ unsafe extern "C" fn trampoline<F, T>(data: *mut c_void) -> *mut c_void
26
+ where
27
+ F: FnOnce() -> T,
28
+ {
29
+ let payload = &mut *data.cast::<Payload<F, T>>();
30
+ let f = payload.f.take().expect("closure called twice");
31
+ payload.result = Some(catch_unwind(AssertUnwindSafe(f)));
32
+ std::ptr::null_mut()
33
+ }
34
+
35
+ let mut payload = Payload { f: Some(f), result: None };
36
+ unsafe {
37
+ rb_sys::rb_thread_call_without_gvl(
38
+ Some(trampoline::<F, T>),
39
+ (&raw mut payload).cast::<c_void>(),
40
+ None,
41
+ std::ptr::null_mut(),
42
+ );
43
+ }
44
+
45
+ match payload.result.take().expect("closure was not called") {
46
+ Ok(value) => value,
47
+ Err(panic) => std::panic::resume_unwind(panic),
48
+ }
49
+ }
@@ -7,10 +7,10 @@ use chrono::{DateTime, Datelike, FixedOffset, Local};
7
7
  use typst::diag::{FileError, FileResult, StrResult};
8
8
  use typst::foundations::{Bytes, Datetime, Dict, Duration};
9
9
  use typst::syntax::{FileId, Lines, Source, VirtualPath, VirtualRoot, RootedPath};
10
- use typst::text::{Font, FontBook};
10
+ use typst::text::{Font, FontBook, FontInfo};
11
11
  use typst::utils::LazyHash;
12
12
  use typst::{Features, Library, LibraryExt, World};
13
- use typst_kit::fonts::{self, FontStore};
13
+ use typst_kit::fonts::{self, FontPath, FontStore};
14
14
  use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
15
15
 
16
16
  /// A world that provides access to the operating system.
@@ -195,7 +195,7 @@ impl SystemWorldBuilder {
195
195
  }
196
196
 
197
197
  pub fn build(self) -> StrResult<SystemWorld> {
198
- let fonts = Arc::new(build_font_store(self.ignore_system_fonts, self.ignore_embedded_fonts, self.font_paths));
198
+ let fonts = font_store(self.ignore_system_fonts, self.ignore_embedded_fonts, self.font_paths);
199
199
 
200
200
  let package_storage = system_packages(self.package_path, self.package_cache_path);
201
201
 
@@ -217,13 +217,84 @@ impl SystemWorldBuilder {
217
217
  }
218
218
  }
219
219
 
220
+ /// Font discovery, which is far too slow to repeat on every compile.
221
+ ///
222
+ /// A full system font scan takes about 95 ms, which for a small document
223
+ /// dwarfs the compilation itself. `FontStore` cannot be cloned, so the
224
+ /// discovered fonts are kept alongside the assembled stores.
225
+ struct FontCache {
226
+ /// The locations found by walking the system font directories.
227
+ system: Option<Arc<Vec<(PathBuf, u32, FontInfo)>>>,
228
+ /// The fonts shipped with typst, already parsed.
229
+ embedded: Option<Arc<Vec<(Font, FontInfo)>>>,
230
+ /// A store per combination of the two ignore flags, for compiles that
231
+ /// bring no font paths of their own. Sharing one keeps the fonts a
232
+ /// document uses loaded between compiles and hashes the book only once.
233
+ stores: [Option<Arc<FontStore>>; 4],
234
+ }
235
+
236
+ const EMPTY_FONT_CACHE: FontCache =
237
+ FontCache { system: None, embedded: None, stores: [const { None }; 4] };
238
+
239
+ static FONT_CACHE: Mutex<FontCache> = Mutex::new(EMPTY_FONT_CACHE);
240
+
241
+ /// Forgets everything discovered so far, so that the next compile picks up
242
+ /// fonts installed or removed since.
243
+ pub fn clear_font_cache() {
244
+ *FONT_CACHE.lock().unwrap() = EMPTY_FONT_CACHE;
245
+ }
246
+
247
+ /// Reads a cache field, filling it on a miss.
248
+ ///
249
+ /// The lock is not held while building, so two threads missing at once both
250
+ /// build and one result is dropped. That wastes a scan but keeps a 95 ms
251
+ /// build off the lock.
252
+ fn cached<T>(
253
+ select: impl Fn(&mut FontCache) -> &mut Option<Arc<T>>,
254
+ build: impl FnOnce() -> T,
255
+ ) -> Arc<T> {
256
+ let hit = select(&mut FONT_CACHE.lock().unwrap()).clone();
257
+ if let Some(value) = hit {
258
+ return value;
259
+ }
260
+
261
+ let value = Arc::new(build());
262
+ *select(&mut FONT_CACHE.lock().unwrap()) = Some(value.clone());
263
+ value
264
+ }
265
+
266
+ /// Obtains a font store for the given configuration.
267
+ ///
268
+ /// Extra font paths are scanned on every compile. They are usually a
269
+ /// temporary directory written by `Typst.build_world_from_s`, so their
270
+ /// contents can change between compiles and caching on them would grow
271
+ /// without bound.
272
+ fn font_store(ignore_system_fonts: bool, ignore_embedded_fonts: bool, font_paths: Vec<PathBuf>) -> Arc<FontStore> {
273
+ if !font_paths.is_empty() {
274
+ return Arc::new(build_font_store(ignore_system_fonts, ignore_embedded_fonts, font_paths));
275
+ }
276
+
277
+ let index = usize::from(ignore_system_fonts) << 1 | usize::from(ignore_embedded_fonts);
278
+ cached(
279
+ move |cache| &mut cache.stores[index],
280
+ || build_font_store(ignore_system_fonts, ignore_embedded_fonts, Vec::new()),
281
+ )
282
+ }
283
+
220
284
  fn build_font_store(ignore_system_fonts: bool, ignore_embedded_fonts: bool, font_paths: Vec<PathBuf>) -> FontStore {
221
285
  let mut fonts = FontStore::new();
222
286
  if !ignore_system_fonts {
223
- fonts.extend(fonts::system());
287
+ let system = cached(
288
+ |cache| &mut cache.system,
289
+ || fonts::system().map(|(path, info)| (path.path, path.index, info)).collect(),
290
+ );
291
+ fonts.extend(system.iter().map(|(path, index, info)| {
292
+ (FontPath { path: path.clone(), index: *index }, info.clone())
293
+ }));
224
294
  }
225
295
  if !ignore_embedded_fonts {
226
- fonts.extend(fonts::embedded());
296
+ let embedded = cached(|cache| &mut cache.embedded, || fonts::embedded().collect());
297
+ fonts.extend(embedded.iter().cloned());
227
298
  }
228
299
  for path in font_paths {
229
300
  fonts.extend(fonts::scan(&path));
data/lib/base.rb CHANGED
@@ -38,12 +38,13 @@ module Typst
38
38
  options[:ignore_embedded_fonts] ||= false
39
39
  options[:pretty] ||= false
40
40
  options[:render_bleed] ||= false
41
+ options[:concurrent] ||= Typst.concurrent
41
42
 
42
43
  self.options = options
43
44
  end
44
45
 
45
46
  def typst_options
46
- [:file, :root, :font_paths, :ignore_system_fonts, :ignore_embedded_fonts, :render_bleed]
47
+ [:file, :root, :font_paths, :ignore_system_fonts, :ignore_embedded_fonts, :concurrent, :render_bleed]
47
48
  end
48
49
 
49
50
  def typst_args(opts)
data/lib/query.rb CHANGED
@@ -2,9 +2,9 @@ module Typst
2
2
  class Query < Base
3
3
  attr_accessor :format
4
4
 
5
- def initialize(selector, input, field: nil, one: false, format: "json", root: ".", font_paths: [], ignore_system_fonts: false, ignore_embedded_fonts: false, sys_inputs: {})
5
+ def initialize(selector, input, field: nil, one: false, format: "json", root: ".", font_paths: [], ignore_system_fonts: false, ignore_embedded_fonts: false, concurrent: false, sys_inputs: {})
6
6
  self.format = format
7
- @result = Typst::_query(selector, field, one, format, input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, sys_inputs)
7
+ @result = Typst::_query(selector, field, one, format, input, root, font_paths, ignore_system_fonts, ignore_embedded_fonts, concurrent, sys_inputs)
8
8
  end
9
9
 
10
10
  def result(raw: false)
data/lib/typst.rb CHANGED
@@ -4,6 +4,7 @@ end
4
4
 
5
5
  module Typst
6
6
  @@formats = {}
7
+ @@concurrent = false
7
8
 
8
9
  def self.register_format(**format)
9
10
  @@formats.merge!(format)
@@ -13,10 +14,24 @@ module Typst
13
14
  @@formats
14
15
  end
15
16
 
17
+ def self.concurrent
18
+ @@concurrent
19
+ end
20
+
21
+ def self.concurrent=(value)
22
+ @@concurrent = value
23
+ end
24
+
16
25
  def self.clear_cache(max_age = 0)
17
26
  Typst::_clear_cache(max_age)
18
27
  end
19
28
 
29
+ # Discards the discovered system and embedded fonts, so that the next
30
+ # compile picks up fonts installed or removed since the first one.
31
+ def self.clear_font_cache
32
+ Typst::_clear_font_cache
33
+ end
34
+
20
35
  def self.build_world_from_s(main_source, **options, &blk)
21
36
  dependencies = options[:dependencies] ||= {}
22
37
  fonts = options[:fonts] ||= {}
@@ -30,16 +45,18 @@ module Typst
30
45
  File.binwrite(tmp_dep_file, dep_source)
31
46
  end
32
47
 
33
- relative_font_path = Pathname.new(tmp_dir).join("fonts")
34
- relative_font_path.mkpath
35
- fonts.each do |font_name, font_bytes|
36
- tmp_font_file = relative_font_path.join(font_name)
37
- File.binwrite(tmp_font_file, font_bytes)
48
+ unless fonts.empty?
49
+ relative_font_path = Pathname.new(tmp_dir).join("fonts")
50
+ relative_font_path.mkpath
51
+ fonts.each do |font_name, font_bytes|
52
+ tmp_font_file = relative_font_path.join(font_name)
53
+ File.binwrite(tmp_font_file, font_bytes)
54
+ end
55
+ options[:font_paths] = (options[:font_paths] || []) + [relative_font_path]
38
56
  end
39
57
 
40
58
  options[:file] = tmp_main_file
41
59
  options[:root] = tmp_dir
42
- options[:font_paths] = [relative_font_path]
43
60
 
44
61
  blk.call(options)
45
62
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typst
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1.6
4
+ version: 0.15.1.9.pre
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flinn
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: '0.9'
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 0.9.128
21
+ version: 0.9.130
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,7 +28,7 @@ dependencies:
28
28
  version: '0.9'
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 0.9.128
31
+ version: 0.9.130
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: rubyzip
34
34
  requirement: !ruby/object:Gem::Requirement
@@ -88,6 +88,7 @@ files:
88
88
  - ext/typst/src/compiler.rs
89
89
  - ext/typst/src/download.rs
90
90
  - ext/typst/src/lib.rs
91
+ - ext/typst/src/nogvl.rs
91
92
  - ext/typst/src/package.rs
92
93
  - ext/typst/src/query.rs
93
94
  - ext/typst/src/world.rs
@@ -117,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
117
118
  - !ruby/object:Gem::Version
118
119
  version: '0'
119
120
  requirements: []
120
- rubygems_version: 4.0.19
121
+ rubygems_version: 4.0.20
121
122
  specification_version: 4
122
123
  summary: Ruby binding to typst, a new markup-based typesetting system that is powerful
123
124
  and easy to learn.