fontisan 0.4.46 → 0.4.48

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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -0,0 +1,463 @@
1
+ //! The `:native` tier of the fontisan gem: the fontisan-rs engine through magnus.
2
+ //!
3
+ //! Division of labour (decided here, see `lib/fontisan/native.rb`):
4
+ //! * Rust walks the engine's `Json` documents straight into Ruby Hash / Array /
5
+ //! String / Integer objects. No JSON text is rendered or parsed on either side,
6
+ //! so each batch row is decoded exactly once. Hash keys are inserted bytewise
7
+ //! sorted, like `Reference.normalize`.
8
+ //! * The GVL is released around every engine call. Nothing inside the released
9
+ //! section touches Ruby: inputs are copied into owned Rust values first and
10
+ //! the result is plain Rust data until the GVL is back.
11
+ //! * A failure is raised as `Fontisan::Native::Failure` carrying the engine's
12
+ //! `[class, message, [[cause class, cause message], ...]]`; the Ruby side
13
+ //! rebuilds the Ruby tier's exception class and cause chain from it. A Rust
14
+ //! panic is caught inside the released section and raised as
15
+ //! `Fontisan::Native::Panic`.
16
+ use fontisan::audit;
17
+ use fontisan::convert::{self, Target};
18
+ use fontisan::json::Json;
19
+ use fontisan::read;
20
+ use fontisan::scan::{self, RowStatus};
21
+ use fontisan::sfnt::{self, Format, LoadingMode, Options};
22
+ use fontisan::{instance, subset};
23
+ use magnus::value::ReprValue;
24
+ use magnus::{function, prelude::*, Error, IntoValue, RArray, RModule, Ruby, Value};
25
+ use std::ffi::c_void;
26
+ use std::panic::{catch_unwind, AssertUnwindSafe};
27
+ use std::path::Path;
28
+
29
+ /// Bumped on any change to the functions below. `lib/fontisan/native.rb` refuses a
30
+ /// mismatch (then the tier is simply unavailable).
31
+ const ABI_VERSION: u32 = 1;
32
+
33
+ /// What the engine reported, before it becomes a Ruby exception.
34
+ enum Fail {
35
+ Engine {
36
+ class: String,
37
+ message: String,
38
+ causes: Vec<(String, String)>,
39
+ },
40
+ Panic(String),
41
+ }
42
+
43
+ impl Fail {
44
+ fn simple(class: &str, message: impl Into<String>) -> Self {
45
+ Self::Engine {
46
+ class: class.to_string(),
47
+ message: message.into(),
48
+ causes: Vec::new(),
49
+ }
50
+ }
51
+
52
+ /// From an engine `{class, message, causes}` document (`read::error_json`,
53
+ /// `audit::command_error_json`).
54
+ fn from_doc(doc: &Json) -> Self {
55
+ let Json::Obj(members) = doc else {
56
+ return Self::simple("Fontisan::Error", "unreadable engine error");
57
+ };
58
+ let text = |key: &str| match members.iter().find(|(k, _)| *k == key) {
59
+ Some((_, Json::Str(s))) => s.clone(),
60
+ _ => String::new(),
61
+ };
62
+ let causes = match members.iter().find(|(k, _)| *k == "causes") {
63
+ Some((_, Json::Arr(items))) => items
64
+ .iter()
65
+ .filter_map(|item| match item {
66
+ Json::Obj(m) => {
67
+ let get = |key: &str| match m.iter().find(|(k, _)| *k == key) {
68
+ Some((_, Json::Str(s))) => s.clone(),
69
+ _ => String::new(),
70
+ };
71
+ Some((get("class"), get("message")))
72
+ }
73
+ _ => None,
74
+ })
75
+ .collect(),
76
+ _ => Vec::new(),
77
+ };
78
+ Self::Engine {
79
+ class: text("class"),
80
+ message: text("message"),
81
+ causes,
82
+ }
83
+ }
84
+ }
85
+
86
+ type Outcome<T> = Result<T, Fail>;
87
+
88
+ fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
89
+ if let Some(s) = payload.downcast_ref::<&str>() {
90
+ (*s).to_string()
91
+ } else if let Some(s) = payload.downcast_ref::<String>() {
92
+ s.clone()
93
+ } else {
94
+ "non-string panic payload".to_string()
95
+ }
96
+ }
97
+
98
+ /// Run `work` with the GVL released. `work` must not touch Ruby. A panic in it
99
+ /// is caught here, inside the released section, and returned as [`Fail::Panic`].
100
+ fn nogvl<T, F>(work: F) -> Outcome<T>
101
+ where
102
+ F: FnOnce() -> Outcome<T>,
103
+ {
104
+ struct Slot<F, T> {
105
+ work: Option<F>,
106
+ out: Option<Outcome<T>>,
107
+ }
108
+ unsafe extern "C" fn run<T, F: FnOnce() -> Outcome<T>>(data: *mut c_void) -> *mut c_void {
109
+ // SAFETY: `data` is the `Slot` on the caller's stack below; the call
110
+ // blocks until this function returns, so it outlives the call.
111
+ let slot = unsafe { &mut *data.cast::<Slot<F, T>>() };
112
+ let work = slot.work.take().expect("work runs once");
113
+ slot.out = Some(
114
+ catch_unwind(AssertUnwindSafe(work))
115
+ .unwrap_or_else(|p| Err(Fail::Panic(panic_text(&*p)))),
116
+ );
117
+ std::ptr::null_mut()
118
+ }
119
+ let mut slot = Slot {
120
+ work: Some(work),
121
+ out: None,
122
+ };
123
+ // SAFETY: `run::<T, F>` matches the callback type and only touches `slot`.
124
+ unsafe {
125
+ rb_sys::rb_thread_call_without_gvl(
126
+ Some(run::<T, F>),
127
+ std::ptr::addr_of_mut!(slot).cast(),
128
+ None,
129
+ std::ptr::null_mut(),
130
+ );
131
+ }
132
+ slot.out.expect("the callback always stores an outcome")
133
+ }
134
+
135
+ fn module(ruby: &Ruby) -> Result<RModule, Error> {
136
+ let fontisan = ruby.define_module("Fontisan")?;
137
+ fontisan.define_module("Native")
138
+ }
139
+
140
+ /// Raise `Fontisan::Native::Failure` / `Panic` for a [`Fail`].
141
+ fn raise(ruby: &Ruby, fail: Fail) -> Error {
142
+ let result = (|| -> Result<Error, Error> {
143
+ let native = module(ruby)?;
144
+ match fail {
145
+ Fail::Panic(text) => {
146
+ let class: magnus::ExceptionClass = native.const_get("Panic")?;
147
+ Ok(Error::new(class, format!("engine panic: {text}")))
148
+ }
149
+ Fail::Engine {
150
+ class,
151
+ message,
152
+ causes,
153
+ } => {
154
+ let klass: magnus::ExceptionClass = native.const_get("Failure")?;
155
+ let exc: Value = klass.funcall("new", (message.as_str(),))?;
156
+ let causes_rb = ruby.ary_new_capa(causes.len());
157
+ for (c, m) in &causes {
158
+ let pair = ruby.ary_new_capa(2);
159
+ pair.push(ruby.str_new(c))?;
160
+ pair.push(ruby.str_new(m))?;
161
+ causes_rb.push(pair)?;
162
+ }
163
+ let details = ruby.ary_new_capa(3);
164
+ details.push(ruby.str_new(&class))?;
165
+ details.push(ruby.str_new(&message))?;
166
+ details.push(causes_rb)?;
167
+ exc.funcall::<_, _, Value>("instance_variable_set", ("@details", details))?;
168
+ let exception = magnus::Exception::from_value(exc)
169
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "bad failure"))?;
170
+ Ok(Error::from(exception))
171
+ }
172
+ }
173
+ })();
174
+ result.unwrap_or_else(|e| e)
175
+ }
176
+
177
+ fn finish<T>(ruby: &Ruby, outcome: Outcome<T>) -> Result<T, Error> {
178
+ outcome.map_err(|fail| raise(ruby, fail))
179
+ }
180
+
181
+ /// Engine document -> Ruby objects, keys bytewise sorted.
182
+ fn to_value(ruby: &Ruby, json: &Json) -> Result<Value, Error> {
183
+ Ok(match json {
184
+ Json::Null => ruby.qnil().as_value(),
185
+ Json::Bool(true) => ruby.qtrue().as_value(),
186
+ Json::Bool(false) => ruby.qfalse().as_value(),
187
+ Json::Int(i) => i.into_value_with(ruby),
188
+ Json::Str(s) => ruby.str_new(s).as_value(),
189
+ Json::Arr(items) => {
190
+ let array = ruby.ary_new_capa(items.len());
191
+ for item in items {
192
+ array.push(to_value(ruby, item)?)?;
193
+ }
194
+ array.as_value()
195
+ }
196
+ Json::Obj(members) => {
197
+ hash(ruby, members.iter().map(|(k, v)| (*k, v)).collect())?
198
+ }
199
+ Json::Map(members) => {
200
+ hash(ruby, members.iter().map(|(k, v)| (k.as_str(), v)).collect())?
201
+ }
202
+ })
203
+ }
204
+
205
+ fn hash(ruby: &Ruby, mut members: Vec<(&str, &Json)>) -> Result<Value, Error> {
206
+ members.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
207
+ let out = ruby.hash_new();
208
+ for (key, value) in members {
209
+ out.aset(ruby.str_new(key), to_value(ruby, value)?)?;
210
+ }
211
+ Ok(out.as_value())
212
+ }
213
+
214
+ fn rows(ruby: &Ruby, docs: &[Json]) -> Result<RArray, Error> {
215
+ let array = ruby.ary_new_capa(docs.len());
216
+ for doc in docs {
217
+ array.push(to_value(ruby, doc)?)?;
218
+ }
219
+ Ok(array)
220
+ }
221
+
222
+ fn read_file(path: &str) -> Outcome<Vec<u8>> {
223
+ std::fs::read(path).map_err(|e| {
224
+ let class = if e.kind() == std::io::ErrorKind::NotFound {
225
+ "Errno::ENOENT"
226
+ } else {
227
+ "IOError"
228
+ };
229
+ Fail::simple(class, format!("{e} - {path}"))
230
+ })
231
+ }
232
+
233
+ fn bytes_value(ruby: &Ruby, bytes: &[u8]) -> Value {
234
+ let s = ruby.str_from_slice(bytes);
235
+ // A binary String, like the Ruby tier's `FontWriter` output.
236
+ let _ = s.funcall::<_, _, Value>("force_encoding", ("BINARY",));
237
+ s.as_value()
238
+ }
239
+
240
+ // --- read-type ops -------------------------------------------------------
241
+
242
+ fn native_read(ruby: &Ruby, path: String, metadata: bool, lazy: bool) -> Result<RArray, Error> {
243
+ let options = Options {
244
+ mode: if metadata {
245
+ LoadingMode::Metadata
246
+ } else {
247
+ LoadingMode::Full
248
+ },
249
+ lazy,
250
+ };
251
+ let docs = finish(
252
+ ruby,
253
+ nogvl(move || {
254
+ let bytes = read_file(&path)?;
255
+ read::read(&bytes, options).map_err(|e| Fail::from_doc(&read::error_json(&e)))
256
+ }),
257
+ )?;
258
+ rows(ruby, &docs)
259
+ }
260
+
261
+ fn native_audit(ruby: &Ruby, path: String, validate: bool) -> Result<RArray, Error> {
262
+ let docs = finish(
263
+ ruby,
264
+ nogvl(move || {
265
+ let bytes = read_file(&path)?;
266
+ let name = Path::new(&path)
267
+ .file_name()
268
+ .map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
269
+ let reports = audit::audit_staged(&bytes, &name, validate)
270
+ .map_err(|(e, stage)| Fail::from_doc(&audit::command_error_json(&e, stage)))?;
271
+ Ok(reports.iter().map(audit::Report::to_json).collect::<Vec<_>>())
272
+ }),
273
+ )?;
274
+ rows(ruby, &docs)
275
+ }
276
+
277
+ /// Scan rows of one file (no "status" key on supported rows, like the Ruby tier).
278
+ fn native_scan(ruby: &Ruby, path: String) -> Result<RArray, Error> {
279
+ let docs = finish(
280
+ ruby,
281
+ nogvl(move || {
282
+ Ok(scan::scan_path(Path::new(&path))
283
+ .iter()
284
+ .map(|row| match (row.status, row.to_json()) {
285
+ (RowStatus::Ok, Json::Obj(members)) => {
286
+ Json::Obj(members.into_iter().filter(|(k, _)| *k != "status").collect())
287
+ }
288
+ (_, doc) => doc,
289
+ })
290
+ .collect::<Vec<_>>())
291
+ }),
292
+ )?;
293
+ rows(ruby, &docs)
294
+ }
295
+
296
+ /// One engine row per font file below `dir` (see `audit::dir_row`).
297
+ fn native_audit_dir_rows(ruby: &Ruby, dir: String, validate: bool) -> Result<RArray, Error> {
298
+ let docs = finish(
299
+ ruby,
300
+ nogvl(move || {
301
+ let files = scan::font_files(Path::new(&dir))
302
+ .map_err(|e| Fail::simple("IOError", format!("cannot read {dir}: {e}")))?;
303
+ Ok(files
304
+ .iter()
305
+ .map(|file| audit::dir_row(file, validate))
306
+ .collect::<Vec<_>>())
307
+ }),
308
+ )?;
309
+ rows(ruby, &docs)
310
+ }
311
+
312
+ /// Whether the engine reads the file itself: false for a file that is gone, a
313
+ /// directory, dfont and Type 1 (the Ruby tier serves those).
314
+ fn native_supports_file(ruby: &Ruby, path: String) -> Result<bool, Error> {
315
+ finish(
316
+ ruby,
317
+ nogvl(move || {
318
+ use std::io::Read;
319
+ let Ok(file) = std::fs::File::open(&path) else {
320
+ return Ok(false);
321
+ };
322
+ let mut head = Vec::new();
323
+ if file.take(256).read_to_end(&mut head).is_err() {
324
+ return Ok(false);
325
+ }
326
+ Ok(!matches!(
327
+ sfnt::detect_format(&head),
328
+ Some(Format::Dfont | Format::Pfa | Format::Pfb)
329
+ ))
330
+ }),
331
+ )
332
+ }
333
+
334
+ // --- byte ops (path based; the Ruby gates for these are card 2f) ----------
335
+
336
+ fn convert_failed(e: &convert::ConvertError) -> Fail {
337
+ Fail::simple(e.class, e.message.clone())
338
+ }
339
+
340
+ fn native_convert(ruby: &Ruby, path: String, target: u32, timestamp: i64) -> Result<Value, Error> {
341
+ let bytes = finish(
342
+ ruby,
343
+ nogvl(move || {
344
+ let target = Target::from_code(target)
345
+ .ok_or_else(|| Fail::simple("ArgumentError", format!("unknown target {target}")))?;
346
+ let input = read_file(&path)?;
347
+ convert::convert(&input, target, timestamp).map_err(|e| convert_failed(&e))
348
+ }),
349
+ )?;
350
+ Ok(bytes_value(ruby, &bytes))
351
+ }
352
+
353
+ /// `[basename, bytes]` of one collection member.
354
+ fn native_extract(ruby: &Ruby, path: String, index: usize) -> Result<RArray, Error> {
355
+ let (name, data) = finish(
356
+ ruby,
357
+ nogvl(move || {
358
+ let input = read_file(&path)?;
359
+ let x = convert::extract(&input, index).map_err(|e| convert_failed(&e))?;
360
+ Ok((x.basename, x.data))
361
+ }),
362
+ )?;
363
+ let out = ruby.ary_new_capa(2);
364
+ out.push(ruby.str_new(&name))?;
365
+ out.push(bytes_value(ruby, &data))?;
366
+ Ok(out)
367
+ }
368
+
369
+ /// `coords`: `[[tag, value], ...]` with a 4-byte tag.
370
+ fn native_instance(ruby: &Ruby, path: String, coords: Vec<(String, f64)>) -> Result<Value, Error> {
371
+ let bytes = finish(
372
+ ruby,
373
+ nogvl(move || {
374
+ let mut axes = Vec::with_capacity(coords.len());
375
+ for (tag, value) in &coords {
376
+ let tag: [u8; 4] = tag
377
+ .as_bytes()
378
+ .try_into()
379
+ .map_err(|_| Fail::simple("ArgumentError", format!("bad axis tag {tag:?}")))?;
380
+ axes.push((tag, *value));
381
+ }
382
+ let input = read_file(&path)?;
383
+ instance::instance(&input, &axes).map_err(|e| convert_failed(&e))
384
+ }),
385
+ )?;
386
+ Ok(bytes_value(ruby, &bytes))
387
+ }
388
+
389
+ fn native_subset(ruby: &Ruby, path: String, gids: Vec<u32>, profile: String) -> Result<Value, Error> {
390
+ let bytes = finish(
391
+ ruby,
392
+ nogvl(move || {
393
+ let input = read_file(&path)?;
394
+ subset::subset(&input, &gids, &profile)
395
+ .map_err(|e| Fail::simple(e.class, e.message.clone()))
396
+ }),
397
+ )?;
398
+ Ok(bytes_value(ruby, &bytes))
399
+ }
400
+
401
+ /// Glyph ids kept for `codepoints` (the facade's `gids_for`); `name` is the
402
+ /// file's basename, used in the "None of the requested characters" error.
403
+ fn native_glyph_ids(ruby: &Ruby, path: String, codepoints: Vec<u32>, name: String) -> Result<Vec<u32>, Error> {
404
+ finish(
405
+ ruby,
406
+ nogvl(move || {
407
+ let input = read_file(&path)?;
408
+ subset::glyph_ids_for(&input, &codepoints, &name)
409
+ .map_err(|e| Fail::simple(e.class, e.message.clone()))
410
+ }),
411
+ )
412
+ }
413
+
414
+ // --- probes --------------------------------------------------------------
415
+
416
+ /// Seeded failure for the specs: panics inside the released section.
417
+ fn native_selftest_panic(ruby: &Ruby) -> Result<(), Error> {
418
+ finish(
419
+ ruby,
420
+ nogvl(|| -> Outcome<()> { panic!("seeded panic (selftest)") }),
421
+ )
422
+ }
423
+
424
+ /// Holds the engine call open for `millis` with the GVL released, so a spec can
425
+ /// prove another Ruby thread keeps running (the sleep stands in for a long scan).
426
+ fn native_selftest_block(ruby: &Ruby, millis: u64) -> Result<(), Error> {
427
+ finish(
428
+ ruby,
429
+ nogvl(move || {
430
+ std::thread::sleep(std::time::Duration::from_millis(millis));
431
+ Ok(())
432
+ }),
433
+ )
434
+ }
435
+
436
+ fn extension_abi_version() -> u32 {
437
+ ABI_VERSION
438
+ }
439
+
440
+ /// The engine rev this build was compiled against (see `build.rs`).
441
+ fn extension_engine_rev() -> &'static str {
442
+ env!("FONTISAN_ENGINE_REV")
443
+ }
444
+
445
+ #[magnus::init]
446
+ fn init(ruby: &Ruby) -> Result<(), Error> {
447
+ let native = module(ruby)?;
448
+ native.define_singleton_method("extension_abi_version", function!(extension_abi_version, 0))?;
449
+ native.define_singleton_method("extension_engine_rev", function!(extension_engine_rev, 0))?;
450
+ native.define_singleton_method("native_read", function!(native_read, 3))?;
451
+ native.define_singleton_method("native_audit", function!(native_audit, 2))?;
452
+ native.define_singleton_method("native_scan", function!(native_scan, 1))?;
453
+ native.define_singleton_method("native_audit_dir_rows", function!(native_audit_dir_rows, 2))?;
454
+ native.define_singleton_method("native_supports_file", function!(native_supports_file, 1))?;
455
+ native.define_singleton_method("native_convert", function!(native_convert, 3))?;
456
+ native.define_singleton_method("native_extract", function!(native_extract, 2))?;
457
+ native.define_singleton_method("native_instance", function!(native_instance, 2))?;
458
+ native.define_singleton_method("native_subset", function!(native_subset, 3))?;
459
+ native.define_singleton_method("native_glyph_ids", function!(native_glyph_ids, 3))?;
460
+ native.define_singleton_method("native_selftest_panic", function!(native_selftest_panic, 0))?;
461
+ native.define_singleton_method("native_selftest_block", function!(native_selftest_block, 1))?;
462
+ Ok(())
463
+ }
@@ -46,6 +46,13 @@ module Fontisan
46
46
 
47
47
  def self.validate_header(raw)
48
48
  issues = []
49
+ if raw.bytesize < 4
50
+ return [issue(severity: :error,
51
+ message: "cmap table is #{raw.bytesize} bytes, " \
52
+ "shorter than its 4-byte header",
53
+ location: "cmap.header")]
54
+ end
55
+
49
56
  version, num_tables = raw.unpack("nn")
50
57
  if version != 0
51
58
  issues << issue(severity: :error,
@@ -43,7 +43,7 @@ module Fontisan
43
43
  issues = []
44
44
  has_fpgm = font.has_table?("fpgm")
45
45
  has_prep = font.has_table?("prep")
46
- has_cvt = font.has_table?("cvt")
46
+ has_cvt = font.has_table?("cvt ")
47
47
  has_glyf_data = table_has_data?(font, "glyf")
48
48
 
49
49
  if has_fpgm && has_glyf_data
@@ -26,7 +26,7 @@ module Fontisan
26
26
  end
27
27
 
28
28
  def self.validate_version(maxp, font)
29
- version_raw = maxp.version_raw
29
+ version_raw = maxp.version_raw.to_i
30
30
  is_truetype = font.has_table?("glyf")
31
31
  tt_version = 0x00010000 # 1.0 as Fixed 16.16
32
32
  cff_version = 0x00005000 # 0.5 as Fixed 16.16
@@ -65,7 +65,8 @@ module Fontisan
65
65
 
66
66
  head = font.table("head")
67
67
  loca = font.table("loca")
68
- return [] unless head && loca
68
+ loca_entry = font.tables.find { |entry| entry.tag == "loca" }
69
+ return [] unless head && loca && loca_entry
69
70
 
70
71
  format = head.index_to_loc_format
71
72
  maxp = font.table("maxp")
@@ -73,7 +74,9 @@ module Fontisan
73
74
  return [] if num_glyphs.zero?
74
75
 
75
76
  expected_size = format.zero? ? (num_glyphs + 1) * 2 : (num_glyphs + 1) * 4
76
- actual_size = loca.to_binary_s.bytesize
77
+ # The loca record is parsed lazily and serialises to 0 bytes, so the
78
+ # table directory length is the real size.
79
+ actual_size = loca_entry.table_length
77
80
  return [] if actual_size == expected_size
78
81
 
79
82
  [issue(severity: :warning,
@@ -38,6 +38,36 @@ module Fontisan
38
38
  uint32 :num_fonts
39
39
  array :font_offsets, type: :uint32, initial_length: :num_fonts
40
40
 
41
+ HEADER_BYTES = 12
42
+ OFFSET_BYTES = 4
43
+
44
+ # Parse the header, refusing a +num_fonts+ whose offset table cannot fit in
45
+ # the remaining bytes. BinData reads the table eagerly, so an unchecked
46
+ # count of 0xFFFFFFFF would spin through 4 GiB of uint32s instead of
47
+ # failing on the first missing offset.
48
+ #
49
+ # @raise [BinData::ValidityError] if the offset table exceeds the input
50
+ def self.read(io, *, &)
51
+ verify_offset_table_fits!(io)
52
+ super
53
+ end
54
+
55
+ def self.verify_offset_table_fits!(io)
56
+ return unless io.respond_to?(:pos) && io.respond_to?(:size)
57
+
58
+ start = io.pos
59
+ head = io.read(HEADER_BYTES)
60
+ io.seek(start)
61
+ return unless head && head.bytesize == HEADER_BYTES
62
+
63
+ count = head.byteslice(8, 4).unpack1("N")
64
+ return if HEADER_BYTES + (count * OFFSET_BYTES) <= io.size - start
65
+
66
+ raise BinData::ValidityError,
67
+ "#{count} fonts declared but the file is too short for their offsets"
68
+ end
69
+ private_class_method :verify_offset_table_fits!
70
+
41
71
  # Abstract method: Get the font class for this collection type
42
72
  #
43
73
  # Subclasses must override this to return their specific font class