imprimatur 0.5.0 → 0.6.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +473 -5
  4. data/Cargo.toml +17 -1
  5. data/README.md +63 -7
  6. data/ext/imprimatur_native/cli/Cargo.toml +1 -1
  7. data/ext/imprimatur_native/cli/src/app.rs +3 -1
  8. data/ext/imprimatur_native/core/Cargo.toml +30 -13
  9. data/ext/imprimatur_native/core/README.md +18 -1
  10. data/ext/imprimatur_native/core/src/bin/imprimatur-render-worker.rs +10 -0
  11. data/ext/imprimatur_native/core/src/bytes.rs +16 -0
  12. data/ext/imprimatur_native/core/src/doc/picture.rs +3 -7
  13. data/ext/imprimatur_native/core/src/doc/size.rs +14 -0
  14. data/ext/imprimatur_native/core/src/doc/symbol.rs +3 -5
  15. data/ext/imprimatur_native/core/src/doc.rs +24 -0
  16. data/ext/imprimatur_native/core/src/docx.rs +6 -5
  17. data/ext/imprimatur_native/core/src/error.rs +6 -0
  18. data/ext/imprimatur_native/core/src/font.rs +12 -0
  19. data/ext/imprimatur_native/core/src/lib.rs +6 -0
  20. data/ext/imprimatur_native/core/src/pdf/layout.rs +13 -5
  21. data/ext/imprimatur_native/core/src/pdf/objects.rs +1 -1
  22. data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +1 -0
  23. data/ext/imprimatur_native/core/src/read/budget.rs +10 -5
  24. data/ext/imprimatur_native/core/src/read/content.rs +16 -0
  25. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +2 -2
  26. data/ext/imprimatur_native/core/src/read/ole.rs +1 -8
  27. data/ext/imprimatur_native/core/src/read/pdf/bounded.rs +249 -0
  28. data/ext/imprimatur_native/core/src/read/pdf/content/state.rs +50 -0
  29. data/ext/imprimatur_native/core/src/read/pdf/content.rs +43 -54
  30. data/ext/imprimatur_native/core/src/read/pdf/document/pages.rs +140 -0
  31. data/ext/imprimatur_native/core/src/read/pdf/document.rs +20 -110
  32. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +25 -13
  33. data/ext/imprimatur_native/core/src/read/pdf/font.rs +4 -3
  34. data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +224 -0
  35. data/ext/imprimatur_native/core/src/read/pdf/isolation/stream.rs +113 -0
  36. data/ext/imprimatur_native/core/src/read/pdf/isolation/system.rs +98 -0
  37. data/ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs +74 -0
  38. data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +255 -0
  39. data/ext/imprimatur_native/core/src/read/pdf/render.rs +206 -0
  40. data/ext/imprimatur_native/core/src/read/pdf.rs +16 -8
  41. data/ext/imprimatur_native/core/src/read/xls/strings.rs +2 -1
  42. data/ext/imprimatur_native/core/src/read/xls.rs +1 -8
  43. data/ext/imprimatur_native/core/src/read.rs +6 -5
  44. data/ext/imprimatur_native/core/src/test_support/font.rs +37 -0
  45. data/ext/imprimatur_native/core/src/xlsx/validation.rs +28 -18
  46. data/ext/imprimatur_native/core/src/zip/reader.rs +1 -8
  47. data/ext/imprimatur_native/extconf.rb +18 -0
  48. data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
  49. data/ext/imprimatur_native/ffi/src/document.rs +6 -0
  50. data/ext/imprimatur_native/ffi/src/lib.rs +7 -0
  51. data/ext/imprimatur_native/ffi/src/pdf.rs +88 -0
  52. data/ext/imprimatur_native/ffi/src/read.rs +18 -12
  53. data/lib/imprimatur/document.rb +4 -2
  54. data/lib/imprimatur/native.rb +7 -0
  55. data/lib/imprimatur/pdf_reader.rb +29 -0
  56. data/lib/imprimatur/version.rb +1 -1
  57. data/lib/imprimatur.rb +26 -2
  58. metadata +20 -4
data/Cargo.toml CHANGED
@@ -10,20 +10,36 @@ license = "BSD-3-Clause"
10
10
  repository = "https://github.com/seuros/imprimatur"
11
11
 
12
12
  [workspace.dependencies]
13
- imprimatur = { path = "ext/imprimatur_native/core", version = "0.5.0" }
13
+ aes = "0.9.3"
14
+ crc32fast = "1.5.2"
15
+ datamatrix = "0.3.3"
16
+ font-test-data = "0.10.0"
17
+ hayro = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
18
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.6.1" }
19
+ libproc = "0.14.11"
14
20
  magnus = { version = "0.9.2", default-features = false }
21
+ md-5 = "0.11.0"
22
+ miniz_oxide = "0.9.1"
23
+ nix = { version = "0.31.3", features = ["resource", "process", "signal"] }
24
+ qrcode = { version = "0.14.1", default-features = false }
25
+ read-fonts = "0.45.1"
15
26
  # For rb_thread_call_without_gvl2, which magnus does not wrap; magnus's own
16
27
  # rb-sys, features and all.
17
28
  rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api"] }
18
29
  regex = "1.13.1"
30
+ serde = { version = "1.0.229", features = ["derive"] }
19
31
  serde_json = "1.0.154"
32
+ sha2 = "0.11.1"
20
33
  tempfile = "3"
34
+ thiserror = "2.0.21"
21
35
  usage-parser = { package = "usage-lib", version = "7.0.1" }
22
36
  usage-rs = "7.0.1"
37
+ zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
23
38
 
24
39
  [workspace.lints.rust]
25
40
  unsafe_code = "deny"
26
41
  unused_must_use = "warn"
42
+ dead_code_pub_in_binary = "warn"
27
43
 
28
44
  [workspace.lints.clippy]
29
45
  all = { level = "warn", priority = -1 }
data/README.md CHANGED
@@ -7,10 +7,9 @@ a 40,000-character cell is refused at the door, not discovered by Excel's
7
7
 
8
8
  - **A Ruby DSL over a native core.** The core is also published on its own,
9
9
  as `imprimatur` on crates.io; the gem wraps it.
10
- - **No third-party format stack.** The XML writer and the zip container are
11
- written here, from ECMA-376 and the zip specification; only deflate
12
- (`miniz_oxide`) and CRC-32 (`crc32fast`) come from crates, and QR Code and
13
- Data Matrix encoding (`qrcode`, `datamatrix`) for barcodes.
10
+ - **Own readers and writers.** XML, zip and PDF reading/writing live here.
11
+ Optional full-page PDF rendering uses Hayro, a pure-Rust CPU renderer;
12
+ no Poppler, PDFium, GPU or system fonts are required.
14
13
  - **Deterministic.** The same document always gives the same bytes.
15
14
  - **Native only.** CRuby 4.0+. No pure Ruby fallback, no JRuby, no TruffleRuby.
16
15
 
@@ -178,7 +177,7 @@ Not yet: merged cells, frozen panes, streaming for very large sheets.
178
177
  One document, two formats:
179
178
 
180
179
  ```ruby
181
- doc = Imprimatur::Document.new(page_size: :a4, page_numbers: true)
180
+ doc = Imprimatur::Document.new(page_size: :a4, font_size: 10, page_numbers: true)
182
181
  doc.heading "Quarterly report"
183
182
  doc.heading "Sales", level: 2
184
183
  doc.paragraph do |p|
@@ -195,12 +194,14 @@ doc.paragraph "Appendix."
195
194
  doc.save_pdf("report.pdf")
196
195
  doc.save_docx("report.docx")
197
196
 
198
- Imprimatur.pdf("memo.pdf") { it.paragraph "Or in one go." } # Imprimatur.docx alike
197
+ Imprimatur.pdf("memo.pdf", font_size: 10) { it.paragraph "Or in one go." } # Imprimatur.docx alike
199
198
  Imprimatur.pdf(page_size: [200, 200], margin: 10) { it.image qr_png, width: 180 }
200
199
  ```
201
200
 
202
201
  - Headings 1 to 3 (real Word heading styles in DOCX), paragraphs of runs
203
202
  (bold, italic, underline, color), alignment, `\n` line breaks and tabs.
203
+ - Body text at `font_size:` points (1 to 1638, in steps of 0.5; 11 unless
204
+ set), in both formats; headings keep their own sizes.
204
205
  - Tables: equal columns, borders, a shaded header row that repeats on every
205
206
  page; cells are converted with `to_s`.
206
207
  - Pictures: PNG or JPEG (a path, the bytes or an IO), at `width:` or
@@ -333,6 +334,61 @@ pages of headings and paragraphs with their position, a workbook's typed
333
334
  cells and comments, a Word document's blocks with their bold, italic and
334
335
  linked stretches.
335
336
 
337
+ For a PDF without text, `content` still returns every page, its empty
338
+ `blocks` and its `images` count. `markdown` and the text-oriented CLI commands
339
+ still report `NoTextLayer`;
340
+ OCR callers need the structure of scanned and blank pages too.
341
+
342
+ ### Bounded PDF pages and OCR rendering
343
+
344
+ ```ruby
345
+ Imprimatur.pdf_info(upload.download) # => {page_count: 20, encrypted: false}
346
+ result = Imprimatur.pdf_pages(upload.download, first_page: 2, last_page: 10, max_pages: 5)
347
+ result[:pages].each { |page| puts [page[:page], page[:status], page[:text]] }
348
+ png = Imprimatur.render_pdf_page(upload.download, page: 2, max_side: 1568)
349
+ ```
350
+
351
+ `pdf_info` counts actual pages without interpreting their content. `pdf_pages`
352
+ only interprets the selected streams, at most 500 pages by default. Each page
353
+ has `status` (`text`, `empty`, `failed`, `skipped`), `text`, `images` (nil if
354
+ unknown), `truncated` and `reason`. It keeps healthy pages when another is
355
+ damaged. Unlike the tolerant Markdown reader, corrupt Flate streams fail
356
+ explicitly. Limits are 256 KiB of text per page and 8 MiB total
357
+ (`max_page_bytes:`, `max_output_bytes:`); truncation respects UTF-8.
358
+
359
+ `render_pdf_page` returns an opaque grayscale PNG of the **whole page**, not
360
+ just its scan image; `grayscale: false` keeps color. It bounds the output surface
361
+ before allocation (`max_pixels:`, 4 million) and encoded output
362
+ (`max_output_bytes:`, 8 MiB). Preflight rejects pages drawing more than 40 million
363
+ source-image pixels. Renderer warnings fail instead of silently dropping content.
364
+ The crate exposes `read::pdf::{info, pages, render_page}` (`render` feature) and
365
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`
366
+ (`isolation` feature). These take an absolute path to the trusted
367
+ `imprimatur-render-worker` executable. The gem bundles that worker and always
368
+ isolates all three PDF operations; it never falls back to in-process parsing
369
+ or searches PATH.
370
+
371
+ All three accept the normal read limits and `memory_bytes:`
372
+ (1 GiB by default): Linux/FreeBSD enforce an address-space cap; macOS uses a
373
+ sampled RSS watchdog, which can briefly overshoot. A worker is killed and reaped
374
+ on timeout, cancellation or supervisor failure. Each operation has a mandatory
375
+ 60-second process ceiling even with `timeout: nil`; a shorter `timeout:` still
376
+ applies. CPU time, output files and core dumps are also limited.
377
+
378
+ The page worker flushes completed records as it goes. An exhausted stream
379
+ budget triggers individual-page recovery inside that worker: each attempt has
380
+ its own `max_inflated` allowance, while the original overall deadline, process
381
+ memory limit and aggregate text cap remain in force. An oversized page is
382
+ `failed`, not blank, without discarding healthy neighbors. A worker terminated
383
+ after metadata was read preserves completed pages and explicitly marks the
384
+ unreached pages; an expired overall deadline marks them `skipped`. Cancellation
385
+ still raises rather than returning a partial successful call.
386
+
387
+ This is resource containment, **not a filesystem/network security sandbox**.
388
+ The raw crate `info`, `pages` and `render_page` APIs remain in-process;
389
+ `render_page` cannot interrupt Hayro.
390
+ `max_inflated` bounds our preflight, not Hayro's internal decoders.
391
+
336
392
  ```ruby
337
393
  sheet = Imprimatur.content("sales.xlsx")[:sheets].first
338
394
  sheet[:rows][1][:cells]
@@ -441,7 +497,7 @@ The core works without Ruby too; see
441
497
 
442
498
  ```sh
443
499
  bundle install
444
- rake test:all # cargo test, then the Ruby suite on the compiled extension
500
+ rake test:all # core, CLI and Ruby suites on the compiled extension
445
501
  rake lint # dictator, cargo fmt --check, clippy
446
502
  rake gems:native # the precompiled gem for this machine, in pkg/
447
503
  ```
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  description = "Read PDF, XLSX, XLS, ODS and DOCX documents as Markdown or JSON, search them, extract their images."
5
5
  keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
6
6
  categories = ["command-line-utilities", "text-processing"]
@@ -89,7 +89,9 @@ pub fn read(file: &Path) -> imprimatur::Result<Vec<u8>> {
89
89
 
90
90
  /// A document's content: the file read, then the document.
91
91
  pub fn content(file: &Path, limits: &Limits) -> imprimatur::Result<Content> {
92
- read(file).and_then(|document| imprimatur::read::content(&document, limits))
92
+ let content = read(file).and_then(|document| imprimatur::read::content(&document, limits))?;
93
+ content.ensure_text_layer()?;
94
+ Ok(content)
93
95
  }
94
96
 
95
97
  /// What a document's outputs are named after: its file's stem, `stdin`
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  description = "XLSX, PDF and DOCX authoring, and PDF, XLSX, XLS, ODS and DOCX reading, with its own XML, zip and PDF code."
5
5
  keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
6
6
  categories = ["encoding", "text-processing"]
@@ -25,17 +25,20 @@ include = [
25
25
  workspace = true
26
26
 
27
27
  [dependencies]
28
- aes = "0.9.3"
29
- crc32fast = "1.5.2"
30
- datamatrix = "0.3.3"
31
- md-5 = "0.11.0"
32
- miniz_oxide = "0.9.1"
33
- qrcode = { version = "0.14.1", default-features = false }
34
- read-fonts = { version = "0.45.1", optional = true }
28
+ aes.workspace = true
29
+ crc32fast.workspace = true
30
+ datamatrix.workspace = true
31
+ hayro = { workspace = true, optional = true }
32
+ md-5.workspace = true
33
+ miniz_oxide.workspace = true
34
+ qrcode.workspace = true
35
+ read-fonts = { workspace = true, optional = true }
36
+ serde = { workspace = true, optional = true }
35
37
  serde_json = { workspace = true, optional = true }
36
- sha2 = "0.11.1"
37
- thiserror = "2.0.21"
38
- zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"], optional = true }
38
+ sha2.workspace = true
39
+ thiserror.workspace = true
40
+ tempfile = { workspace = true, optional = true }
41
+ zip = { workspace = true, optional = true }
39
42
 
40
43
  [features]
41
44
  # `read::json`: content and images as JSON, the schema the command line and
@@ -47,8 +50,22 @@ archive = ["dep:zip"]
47
50
  # `font`: static TrueType font data (glyphs, advances, line metrics),
48
51
  # through `read-fonts`.
49
52
  fonts = ["dep:read-fonts"]
53
+ # Pure-Rust full-page PDF rasterization; readers and writers remain independent.
54
+ render = ["dep:hayro"]
55
+ isolation = ["render", "json", "dep:serde", "dep:tempfile", "dep:nix", "dep:libproc"]
56
+
57
+ [target.'cfg(unix)'.dependencies]
58
+ nix = { workspace = true, optional = true }
59
+
60
+ [target.'cfg(target_os = "macos")'.dependencies]
61
+ libproc = { workspace = true, optional = true }
62
+
63
+ [[bin]]
64
+ name = "imprimatur-render-worker"
65
+ path = "src/bin/imprimatur-render-worker.rs"
66
+ required-features = ["isolation"]
50
67
 
51
68
  [dev-dependencies]
52
- font-test-data = "0.10.0"
69
+ font-test-data.workspace = true
53
70
  tempfile.workspace = true
54
- zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
71
+ zip.workspace = true
@@ -1,10 +1,26 @@
1
1
  # imprimatur
2
2
 
3
- Document authoring with no third-party format stack: the XML and the zip
3
+ Document authoring with its own format stack: the XML and the zip
4
4
  container are written here, on `miniz_oxide` (deflate) and `crc32fast`.
5
5
  XLSX workbooks, and PDF and DOCX from one document model.
6
6
  Output is deterministic: the same document always gives the same bytes.
7
7
 
8
+ `read::pdf::info` inspects a PDF without interpreting its content.
9
+ `read::pdf::pages` reads a bounded, one-based selection, with per-page failure
10
+ states and UTF-8-safe text limits. `read::pdf::render_page`, under the optional
11
+ `render` feature, rasterizes a full page through Hayro (pure Rust).
12
+ That raw API is not preemptible. For untrusted documents, enable `isolation`,
13
+ build the `imprimatur-render-worker` binary and pass its absolute path to
14
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`.
15
+ Rust supervises finite wall/CPU,
16
+ memory and file-output limits, killing and reaping failed/cancelled workers.
17
+ Linux/FreeBSD enforce address-space limits; macOS samples RSS and can briefly
18
+ overshoot. This is resource containment, not a filesystem/network sandbox.
19
+ Isolated page reads flush completed records, retain them on worker termination,
20
+ and recover healthy neighbors after page-local decompression failures. Recovery
21
+ attempts renew only the individual decompression allowance, never the overall
22
+ deadline, process-memory limit or aggregate text cap.
23
+
8
24
  ```rust
9
25
  use imprimatur::xlsx::{Cell, Color, DateTime, Format, Workbook};
10
26
 
@@ -27,6 +43,7 @@ book.save("budget.xlsx")?; // or to_bytes(), or write_to(impl Write)
27
43
  use imprimatur::doc::{Document, Paragraph, Run, Table};
28
44
 
29
45
  let mut doc = Document::new();
46
+ doc.set_font_size(10.0)?; // body text in points; 11 unless set
30
47
  doc.set_page_numbers(true);
31
48
  doc.heading(1, "Quarterly report")?;
32
49
  doc.push_paragraph(Paragraph::new().text("Revenue was ").run(Run::new("up 12%").bold()))?;
@@ -0,0 +1,10 @@
1
+ #[cfg(unix)]
2
+ fn main() -> std::process::ExitCode {
3
+ imprimatur::read::pdf::isolation::worker_main()
4
+ }
5
+
6
+ #[cfg(not(unix))]
7
+ fn main() -> std::process::ExitCode {
8
+ eprintln!("isolated PDF rendering requires a supported Unix platform");
9
+ std::process::ExitCode::FAILURE
10
+ }
@@ -0,0 +1,16 @@
1
+ //! Checked integer reads shared by binary format parsers.
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ fn array<const N: usize>(data: &[u8], at: usize) -> Option<[u8; N]> {
7
+ data.get(at..at.checked_add(N)?)?.try_into().ok()
8
+ }
9
+
10
+ pub fn u16_le(data: &[u8], at: usize) -> Option<u16> {
11
+ array(data, at).map(u16::from_le_bytes)
12
+ }
13
+
14
+ pub fn u32_le(data: &[u8], at: usize) -> Option<u32> {
15
+ array(data, at).map(u32::from_le_bytes)
16
+ }
@@ -2,13 +2,12 @@
2
2
 
3
3
  use std::sync::Arc;
4
4
 
5
+ use super::size;
5
6
  use crate::style::Align;
6
7
  use crate::{Error, Result};
7
8
 
8
9
  /// Pixels per point at 96 pixels an inch: a picture's size when none is set.
9
10
  const POINTS_PER_PIXEL: f64 = 0.75;
10
- /// The largest side, in points, a picture may be set to: PDF's page limit.
11
- const MAX_POINTS: f64 = 14_400.0;
12
11
  /// The largest side, in pixels, a picture may have.
13
12
  const MAX_PIXELS: u32 = 1 << 16;
14
13
 
@@ -112,15 +111,12 @@ impl Picture {
112
111
  (None, Some(height)) => (height * pixels_wide / pixels_high, height),
113
112
  (None, None) => (pixels_wide * POINTS_PER_PIXEL, pixels_high * POINTS_PER_PIXEL),
114
113
  };
115
- let scale = (room.0 / width).min(room.1 / height).min(1.0);
116
- (width * scale, height * scale)
114
+ size::fit((width, height), room)
117
115
  }
118
116
  }
119
117
 
120
118
  fn points_in_range(points: f64) -> Result<f64> {
121
- (1.0..=MAX_POINTS)
122
- .contains(&points)
123
- .then_some(points)
119
+ size::points_in_range(points)
124
120
  .ok_or(Error::InvalidImage("a picture of 1 to 14 400 points a side"))
125
121
  }
126
122
 
@@ -0,0 +1,14 @@
1
+ //! Physical sizing shared by pictures and barcode symbols.
2
+
3
+ /// The largest side in points allowed by PDF.
4
+ const MAX_POINTS: f64 = 14_400.0;
5
+
6
+ pub(super) fn points_in_range(points: f64) -> Option<f64> {
7
+ (1.0..=MAX_POINTS).contains(&points).then_some(points)
8
+ }
9
+
10
+ /// Fits into the available room without enlarging or changing proportions.
11
+ pub(super) fn fit((width, height): (f64, f64), room: (f64, f64)) -> (f64, f64) {
12
+ let scale = (room.0 / width).min(room.1 / height).min(1.0);
13
+ (width * scale, height * scale)
14
+ }
@@ -1,12 +1,11 @@
1
1
  //! Barcodes in the flow: shapes in a PDF, a picture in DOCX.
2
2
 
3
+ use super::size;
3
4
  use crate::Result;
4
5
  use crate::barcode::{Barcode, Look, Shapes};
5
6
  use crate::doc::Picture;
6
7
  use crate::style::{Align, Color};
7
8
 
8
- /// The largest side, in points, a symbol may be set to: PDF's page limit.
9
- const MAX_POINTS: f64 = 14_400.0;
10
9
  /// Pixels an inch a DOCX picture of a symbol has at least.
11
10
  const DOCX_DPI: f64 = 300.0;
12
11
 
@@ -96,8 +95,7 @@ impl Symbol {
96
95
  };
97
96
  (columns * module, rows * module)
98
97
  };
99
- let scale = (room.0 / width).min(room.1 / height).min(1.0);
100
- (shapes, (width * scale, height * scale))
98
+ (shapes, size::fit((width, height), room))
101
99
  }
102
100
 
103
101
  /// A PNG of it at 300 pixels an inch or more, for formats that take
@@ -125,7 +123,7 @@ impl Symbol {
125
123
 
126
124
  fn points_in_range(&self, points: f64) -> Result<f64> {
127
125
  let invalid = || self.barcode.symbology().invalid("a size of 1 to 14 400 points");
128
- (1.0..=MAX_POINTS).contains(&points).then_some(points).ok_or_else(invalid)
126
+ size::points_in_range(points).ok_or_else(invalid)
129
127
  }
130
128
  }
131
129
 
@@ -23,6 +23,7 @@
23
23
 
24
24
  mod paragraph;
25
25
  pub(crate) mod picture;
26
+ mod size;
26
27
  mod symbol;
27
28
  mod table;
28
29
  #[cfg(test)]
@@ -111,6 +112,8 @@ pub struct Document {
111
112
  page_numbers: bool,
112
113
  /// Hundredths of a point; one inch when unset.
113
114
  margin: Option<u32>,
115
+ /// Half points; 11 points when unset.
116
+ font_size: Option<u16>,
114
117
  }
115
118
 
116
119
  impl Document {
@@ -144,6 +147,27 @@ impl Document {
144
147
  self.margin.map_or(72.0, |margin| f64::from(margin) / 100.0)
145
148
  }
146
149
 
150
+ /// Body text size in points, 11 unless set.
151
+ ///
152
+ /// # Errors
153
+ /// [`Error::InvalidDocumentFontSize`] outside 1 to 1638, or between half points.
154
+ pub fn set_font_size(&mut self, points: f64) -> Result<&mut Self> {
155
+ let half_points = points * 2.0;
156
+ let valid = (1.0..=1638.0).contains(&points) && half_points.fract() == 0.0;
157
+ valid.ok_or(Error::InvalidDocumentFontSize(points))?;
158
+ #[expect(clippy::cast_possible_truncation, reason = "a whole number from 2 to 3276")]
159
+ #[expect(clippy::cast_sign_loss, reason = "a whole number from 2 to 3276")]
160
+ let half_points = half_points as u16;
161
+ self.font_size = Some(half_points);
162
+ Ok(self)
163
+ }
164
+
165
+ /// Body text size in points.
166
+ #[must_use]
167
+ pub fn font_size(&self) -> f64 {
168
+ f64::from(self.font_size.unwrap_or(22)) / 2.0
169
+ }
170
+
147
171
  /// The width and height between the margins, in points.
148
172
  ///
149
173
  /// # Errors
@@ -50,7 +50,7 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
50
50
  zip.add("_rels/.rels", package.as_bytes())?;
51
51
  zip.add("word/document.xml", body(document, room, first_media, &pictures).as_bytes())?;
52
52
  zip.add("word/_rels/document.xml.rels", opc::relationships(&parts).as_bytes())?;
53
- zip.add("word/styles.xml", styles().as_bytes())?;
53
+ zip.add("word/styles.xml", styles(document).as_bytes())?;
54
54
  if footer {
55
55
  zip.add("word/footer1.xml", page_number_footer().as_bytes())?;
56
56
  }
@@ -350,16 +350,17 @@ const HEADINGS: [(&str, &str, Option<&str>, &str); 3] = [
350
350
  ];
351
351
 
352
352
  /// `word/styles.xml`: Normal text, Heading 1-3 and a bordered table style,
353
- /// with a Calibri 11pt body.
354
- fn styles() -> String {
353
+ /// with a Calibri body at the document's font size.
354
+ fn styles(document: &Document) -> String {
355
355
  let mut xml = XmlWriter::new();
356
356
  xml.open("w:styles", &[("xmlns:w", W_NS)]);
357
357
  xml.open("w:docDefaults", &[]);
358
358
  xml.open("w:rPrDefault", &[]);
359
359
  xml.open("w:rPr", &[]);
360
360
  xml.empty("w:rFonts", &[("w:ascii", "Calibri"), ("w:hAnsi", "Calibri"), ("w:cs", "Arial")]);
361
- xml.empty("w:sz", &[("w:val", "22")]);
362
- xml.empty("w:szCs", &[("w:val", "22")]);
361
+ let size = (document.font_size() * 2.0).to_string();
362
+ xml.empty("w:sz", &[("w:val", &size)]);
363
+ xml.empty("w:szCs", &[("w:val", &size)]);
363
364
  xml.close("w:rPr");
364
365
  xml.close("w:rPrDefault");
365
366
  xml.open("w:pPrDefault", &[]);
@@ -78,6 +78,9 @@ pub enum Error {
78
78
  #[error("margin {0} points is out of range (0 to 144), or leaves no room on the page")]
79
79
  InvalidMargin(f64),
80
80
 
81
+ #[error("document font size {0} is out of range (1 to 1638 points, in steps of 0.5)")]
82
+ InvalidDocumentFontSize(f64),
83
+
81
84
  #[error(
82
85
  "{character:?} (U+{code:04X}) cannot be written with the PDF standard font {font}: \
83
86
  only Windows-1252 (Latin) text is supported until embedded fonts land",
@@ -118,6 +121,9 @@ pub enum Error {
118
121
  #[error("the document is encrypted with a password")]
119
122
  PasswordRequired,
120
123
 
124
+ #[error("invalid PDF option: {0}")]
125
+ InvalidPdfOption(&'static str),
126
+
121
127
  #[error(transparent)]
122
128
  Io(#[from] io::Error),
123
129
  }
@@ -7,6 +7,8 @@
7
7
  #[cfg(test)]
8
8
  mod tests;
9
9
 
10
+ use std::fmt;
11
+
10
12
  use read_fonts::{
11
13
  FontRead, FontRef, MinByteRange, TableProvider,
12
14
  tables::{
@@ -45,6 +47,16 @@ pub struct TrueTypeFont<'a> {
45
47
  glyph_count: u16,
46
48
  }
47
49
 
50
+ impl fmt::Debug for TrueTypeFont<'_> {
51
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52
+ f.debug_struct("TrueTypeFont")
53
+ .field("data_len", &self.data.len())
54
+ .field("metrics", &self.metrics)
55
+ .field("glyph_count", &self.glyph_count)
56
+ .finish_non_exhaustive()
57
+ }
58
+ }
59
+
48
60
  impl<'a> TrueTypeFont<'a> {
49
61
  /// Load font data without copying it or accessing system fonts.
50
62
  ///
@@ -19,9 +19,15 @@
19
19
  //! # Ok::<(), imprimatur::Error>(())
20
20
  //! ```
21
21
 
22
+ #![cfg_attr(
23
+ all(test, feature = "isolation", unix),
24
+ expect(dead_code_pub_in_binary, reason = "worker entry point is integration-tested")
25
+ )]
26
+
22
27
  #[cfg(feature = "archive")]
23
28
  pub mod archive;
24
29
  pub mod barcode;
30
+ mod bytes;
25
31
  pub mod doc;
26
32
  mod docx;
27
33
  mod error;
@@ -13,7 +13,6 @@ use crate::style::{Align, Color};
13
13
  use crate::{Error, Result};
14
14
  use wrap::{Line, Look, wrap};
15
15
 
16
- const BODY_SIZE: f64 = 11.0;
17
16
  const TABLE_SIZE: f64 = 10.0;
18
17
  /// Line height as a multiple of the font size.
19
18
  const LEADING: f64 = 1.25;
@@ -31,6 +30,7 @@ pub struct Layout {
31
30
  width: f64,
32
31
  height: f64,
33
32
  margin: f64,
33
+ body_size: f64,
34
34
  pages: Vec<Vec<u8>>,
35
35
  /// The top of the free space on the current page, from the bottom edge.
36
36
  y: f64,
@@ -46,7 +46,15 @@ impl Layout {
46
46
  document.room()?;
47
47
  let (width, height) = document.page_size().points();
48
48
  let margin = document.margin();
49
- Ok(Self { width, height, margin, pages: vec![Vec::new()], y: height - margin, blank: true })
49
+ Ok(Self {
50
+ width,
51
+ height,
52
+ margin,
53
+ body_size: document.font_size(),
54
+ pages: vec![Vec::new()],
55
+ y: height - margin,
56
+ blank: true,
57
+ })
50
58
  }
51
59
 
52
60
  pub const fn page_size(&self) -> (f64, f64) {
@@ -63,7 +71,7 @@ impl Layout {
63
71
  let look = Look { face: Face::Bold, color, underline: false };
64
72
  let lines = wrap(&[(Face::Bold.encode(text)?, look)], size, self.content_width());
65
73
  // Keep the heading with at least two lines of what follows.
66
- let body_lines = 2.0 * BODY_SIZE * LEADING;
74
+ let body_lines = 2.0 * self.body_size * LEADING;
67
75
  let needed = (line_count(&lines) * size).mul_add(LEADING, before) + body_lines;
68
76
  self.ensure_room(needed);
69
77
  if !self.blank {
@@ -83,8 +91,8 @@ impl Layout {
83
91
  Look { face, color: run.color, underline: run.underline },
84
92
  ));
85
93
  }
86
- let lines = wrap(&runs, BODY_SIZE, self.content_width());
87
- self.lines(&lines, BODY_SIZE, paragraph.effective_align());
94
+ let lines = wrap(&runs, self.body_size, self.content_width());
95
+ self.lines(&lines, self.body_size, paragraph.effective_align());
88
96
  self.y -= PARAGRAPH_GAP;
89
97
  Ok(())
90
98
  }
@@ -127,7 +127,7 @@ impl<W: Write> Write for Counted<'_, W> {
127
127
 
128
128
  /// A number for a content stream: two decimals at most, no trailing zeros,
129
129
  /// formatted without an intermediate `String`.
130
- #[derive(Clone, Copy)]
130
+ #[derive(Debug, Clone, Copy)]
131
131
  pub struct Num(pub f64);
132
132
 
133
133
  impl fmt::Display for Num {
@@ -15,6 +15,7 @@ use crate::font::{TrueTypeFont, invalid};
15
15
  use crate::pdf::objects::{Num, PdfWriter};
16
16
  use crate::{Error, Result};
17
17
 
18
+ #[derive(Debug)]
18
19
  pub struct Descriptor {
19
20
  /// PostScript name, escaped as a PDF name (without its leading slash).
20
21
  pub name: String,
@@ -23,11 +23,11 @@ const TICKS_PER_CHECK: u32 = 4096;
23
23
  /// ```
24
24
  #[derive(Debug, Clone)]
25
25
  pub struct Limits {
26
- max_input: usize,
27
- max_inflated: u64,
28
- max_depth: usize,
29
- timeout: Option<Duration>,
30
- cancel: Option<Arc<AtomicBool>>,
26
+ pub(crate) max_input: usize,
27
+ pub(crate) max_inflated: u64,
28
+ pub(crate) max_depth: usize,
29
+ pub(crate) timeout: Option<Duration>,
30
+ pub(crate) cancel: Option<Arc<AtomicBool>>,
31
31
  }
32
32
 
33
33
  impl Default for Limits {
@@ -127,6 +127,11 @@ impl<'a> Budget<'a> {
127
127
  if self.limits.cancel.as_deref().is_some_and(|flag| flag.load(Ordering::Relaxed)) {
128
128
  return Err(Error::Cancelled);
129
129
  }
130
+ self.check_time()
131
+ }
132
+
133
+ /// Check elapsed time after a completed worker (SIGCHLD may wake its caller).
134
+ pub(crate) fn check_time(&self) -> Result<()> {
130
135
  if self.deadline.is_some_and(|deadline| Instant::now() >= deadline) {
131
136
  return Err(Error::LimitExceeded("time"));
132
137
  }
@@ -27,6 +27,22 @@ pub enum Content {
27
27
  }
28
28
 
29
29
  impl Content {
30
+ /// Requires readable PDF text for text-oriented callers, while structured
31
+ /// callers can still inspect scanned and blank pages.
32
+ ///
33
+ /// # Errors
34
+ /// [`crate::Error::NoTextLayer`] for a PDF without readable blocks.
35
+ pub fn ensure_text_layer(&self) -> crate::Result<()> {
36
+ if let Self::Pdf(pages) = self
37
+ && pages.iter().all(|page| {
38
+ page.blocks.iter().all(|block| block.block.plain_text().trim().is_empty())
39
+ })
40
+ {
41
+ return Err(crate::Error::NoTextLayer);
42
+ }
43
+ Ok(())
44
+ }
45
+
30
46
  /// The document's format.
31
47
  #[must_use]
32
48
  pub const fn format(&self) -> super::Format {