imprimatur 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/Cargo.lock +472 -5
- data/Cargo.toml +16 -1
- data/README.md +59 -7
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/cli/src/app.rs +3 -1
- data/ext/imprimatur_native/core/Cargo.toml +29 -13
- data/ext/imprimatur_native/core/README.md +13 -1
- data/ext/imprimatur_native/core/src/bin/imprimatur-render-worker.rs +10 -0
- data/ext/imprimatur_native/core/src/bytes.rs +16 -0
- data/ext/imprimatur_native/core/src/doc/picture.rs +3 -7
- data/ext/imprimatur_native/core/src/doc/size.rs +14 -0
- data/ext/imprimatur_native/core/src/doc/symbol.rs +3 -5
- data/ext/imprimatur_native/core/src/doc.rs +24 -0
- data/ext/imprimatur_native/core/src/docx.rs +6 -5
- data/ext/imprimatur_native/core/src/error.rs +6 -0
- data/ext/imprimatur_native/core/src/font.rs +12 -0
- data/ext/imprimatur_native/core/src/lib.rs +6 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +13 -5
- data/ext/imprimatur_native/core/src/pdf/objects.rs +1 -1
- data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +1 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +10 -5
- data/ext/imprimatur_native/core/src/read/content.rs +16 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +2 -2
- data/ext/imprimatur_native/core/src/read/ole.rs +1 -8
- data/ext/imprimatur_native/core/src/read/pdf/bounded.rs +190 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/state.rs +50 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +43 -54
- data/ext/imprimatur_native/core/src/read/pdf/document/pages.rs +140 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +20 -110
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +25 -13
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +4 -3
- data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +155 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/system.rs +71 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs +43 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +166 -0
- data/ext/imprimatur_native/core/src/read/pdf/render.rs +206 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +16 -8
- data/ext/imprimatur_native/core/src/read/xls/strings.rs +2 -1
- data/ext/imprimatur_native/core/src/read/xls.rs +1 -8
- data/ext/imprimatur_native/core/src/read.rs +6 -5
- data/ext/imprimatur_native/core/src/test_support/font.rs +37 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +28 -18
- data/ext/imprimatur_native/core/src/zip/reader.rs +1 -8
- data/ext/imprimatur_native/extconf.rb +18 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
- data/ext/imprimatur_native/ffi/src/document.rs +6 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +7 -0
- data/ext/imprimatur_native/ffi/src/pdf.rs +73 -0
- data/ext/imprimatur_native/ffi/src/read.rs +18 -12
- data/lib/imprimatur/document.rb +4 -2
- data/lib/imprimatur/native.rb +7 -0
- data/lib/imprimatur/pdf_reader.rb +27 -0
- data/lib/imprimatur/reader.rb +48 -11
- data/lib/imprimatur/spreadsheet.rb +1 -1
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur.rb +28 -3
- metadata +19 -4
data/Cargo.toml
CHANGED
|
@@ -10,20 +10,35 @@ license = "BSD-3-Clause"
|
|
|
10
10
|
repository = "https://github.com/seuros/imprimatur"
|
|
11
11
|
|
|
12
12
|
[workspace.dependencies]
|
|
13
|
-
|
|
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.0" }
|
|
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"
|
|
19
30
|
serde_json = "1.0.154"
|
|
31
|
+
sha2 = "0.11.1"
|
|
20
32
|
tempfile = "3"
|
|
33
|
+
thiserror = "2.0.21"
|
|
21
34
|
usage-parser = { package = "usage-lib", version = "7.0.1" }
|
|
22
35
|
usage-rs = "7.0.1"
|
|
36
|
+
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
|
23
37
|
|
|
24
38
|
[workspace.lints.rust]
|
|
25
39
|
unsafe_code = "deny"
|
|
26
40
|
unused_must_use = "warn"
|
|
41
|
+
dead_code_pub_in_binary = "warn"
|
|
27
42
|
|
|
28
43
|
[workspace.lints.clippy]
|
|
29
44
|
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
|
-
- **
|
|
11
|
-
|
|
12
|
-
|
|
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." }
|
|
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
|
|
@@ -318,6 +319,13 @@ fibers keep running, and `Timeout` or `Thread#raise` cancels it.
|
|
|
318
319
|
`Imprimatur::PasswordRequired` and `Imprimatur::UnsupportedDocument` (legacy
|
|
319
320
|
`.doc`, `.xlsb`, presentations) are the other errors to expect.
|
|
320
321
|
|
|
322
|
+
A binary String is always the document's bytes, never a path: an upload
|
|
323
|
+
holding `/etc/passwd` or `/dev/zero` is refused as `UnsupportedDocument`,
|
|
324
|
+
not read from the server. A path (a `Pathname`, or a String that is not
|
|
325
|
+
binary and has no document signature) must name a regular file; devices,
|
|
326
|
+
pipes and directories are refused. An IO or a path is read no further than
|
|
327
|
+
`max_bytes:`, so an endless stream or a huge file stops there.
|
|
328
|
+
|
|
321
329
|
### Structure and images
|
|
322
330
|
|
|
323
331
|
Markdown is one rendering of what a read finds. `Imprimatur.content` gives
|
|
@@ -326,6 +334,50 @@ pages of headings and paragraphs with their position, a workbook's typed
|
|
|
326
334
|
cells and comments, a Word document's blocks with their bold, italic and
|
|
327
335
|
linked stretches.
|
|
328
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::render_page_isolated` (`isolation` feature). The latter
|
|
366
|
+
takes an absolute path to the trusted `imprimatur-render-worker` executable.
|
|
367
|
+
The gem bundles that worker and always uses isolated rendering; it never falls
|
|
368
|
+
back to an in-process render or searches PATH.
|
|
369
|
+
|
|
370
|
+
All three accept the normal read limits. Rendering also accepts `memory_bytes:`
|
|
371
|
+
(1 GiB by default): Linux/FreeBSD enforce an address-space cap; macOS uses a
|
|
372
|
+
sampled RSS watchdog, which can briefly overshoot. A worker is killed and reaped
|
|
373
|
+
on timeout, cancellation or supervisor failure. Rendering has a mandatory
|
|
374
|
+
60-second process ceiling even with `timeout: nil`; a shorter `timeout:` still
|
|
375
|
+
applies. CPU time, output files and core dumps are also limited.
|
|
376
|
+
|
|
377
|
+
This is resource containment, **not a filesystem/network security sandbox**.
|
|
378
|
+
The raw crate `render_page` remains in-process and cannot interrupt Hayro.
|
|
379
|
+
`max_inflated` bounds our preflight, not Hayro's internal decoders.
|
|
380
|
+
|
|
329
381
|
```ruby
|
|
330
382
|
sheet = Imprimatur.content("sales.xlsx")[:sheets].first
|
|
331
383
|
sheet[:rows][1][:cells]
|
|
@@ -434,7 +486,7 @@ The core works without Ruby too; see
|
|
|
434
486
|
|
|
435
487
|
```sh
|
|
436
488
|
bundle install
|
|
437
|
-
rake test:all #
|
|
489
|
+
rake test:all # core, CLI and Ruby suites on the compiled extension
|
|
438
490
|
rake lint # dictator, cargo fmt --check, clippy
|
|
439
491
|
rake gems:native # the precompiled gem for this machine, in pkg/
|
|
440
492
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
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.
|
|
3
|
+
version = "0.6.0"
|
|
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,19 @@ include = [
|
|
|
25
25
|
workspace = true
|
|
26
26
|
|
|
27
27
|
[dependencies]
|
|
28
|
-
aes =
|
|
29
|
-
crc32fast =
|
|
30
|
-
datamatrix =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 }
|
|
35
36
|
serde_json = { workspace = true, optional = true }
|
|
36
|
-
sha2 =
|
|
37
|
-
thiserror =
|
|
38
|
-
|
|
37
|
+
sha2.workspace = true
|
|
38
|
+
thiserror.workspace = true
|
|
39
|
+
tempfile = { workspace = true, optional = true }
|
|
40
|
+
zip = { workspace = true, optional = true }
|
|
39
41
|
|
|
40
42
|
[features]
|
|
41
43
|
# `read::json`: content and images as JSON, the schema the command line and
|
|
@@ -47,8 +49,22 @@ archive = ["dep:zip"]
|
|
|
47
49
|
# `font`: static TrueType font data (glyphs, advances, line metrics),
|
|
48
50
|
# through `read-fonts`.
|
|
49
51
|
fonts = ["dep:read-fonts"]
|
|
52
|
+
# Pure-Rust full-page PDF rasterization; readers and writers remain independent.
|
|
53
|
+
render = ["dep:hayro"]
|
|
54
|
+
isolation = ["render", "dep:tempfile", "dep:nix", "dep:libproc"]
|
|
55
|
+
|
|
56
|
+
[target.'cfg(unix)'.dependencies]
|
|
57
|
+
nix = { workspace = true, optional = true }
|
|
58
|
+
|
|
59
|
+
[target.'cfg(target_os = "macos")'.dependencies]
|
|
60
|
+
libproc = { workspace = true, optional = true }
|
|
61
|
+
|
|
62
|
+
[[bin]]
|
|
63
|
+
name = "imprimatur-render-worker"
|
|
64
|
+
path = "src/bin/imprimatur-render-worker.rs"
|
|
65
|
+
required-features = ["isolation"]
|
|
50
66
|
|
|
51
67
|
[dev-dependencies]
|
|
52
|
-
font-test-data =
|
|
68
|
+
font-test-data.workspace = true
|
|
53
69
|
tempfile.workspace = true
|
|
54
|
-
zip
|
|
70
|
+
zip.workspace = true
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# imprimatur
|
|
2
2
|
|
|
3
|
-
Document authoring with
|
|
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::render_page_isolated`. Rust supervises finite wall/CPU,
|
|
15
|
+
memory and file-output limits, killing and reaping failed/cancelled workers.
|
|
16
|
+
Linux/FreeBSD enforce address-space limits; macOS samples RSS and can briefly
|
|
17
|
+
overshoot. This is resource containment, not a filesystem/network sandbox.
|
|
18
|
+
|
|
8
19
|
```rust
|
|
9
20
|
use imprimatur::xlsx::{Cell, Color, DateTime, Format, Workbook};
|
|
10
21
|
|
|
@@ -27,6 +38,7 @@ book.save("budget.xlsx")?; // or to_bytes(), or write_to(impl Write)
|
|
|
27
38
|
use imprimatur::doc::{Document, Paragraph, Run, Table};
|
|
28
39
|
|
|
29
40
|
let mut doc = Document::new();
|
|
41
|
+
doc.set_font_size(10.0)?; // body text in points; 11 unless set
|
|
30
42
|
doc.set_page_numbers(true);
|
|
31
43
|
doc.heading(1, "Quarterly report")?;
|
|
32
44
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
362
|
-
xml.empty("w:
|
|
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 {
|
|
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 *
|
|
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,
|
|
87
|
-
self.lines(&lines,
|
|
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 {
|
|
@@ -37,7 +37,7 @@ impl Bytes<'_> {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
fn u16_le(&self, at: usize) -> Option<u16> {
|
|
40
|
-
self.
|
|
40
|
+
crate::bytes::u16_le(self.0, at)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
fn u16_be(&self, at: usize) -> Option<u16> {
|
|
@@ -45,7 +45,7 @@ impl Bytes<'_> {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
fn u32_le(&self, at: usize) -> Option<u32> {
|
|
48
|
-
self.
|
|
48
|
+
crate::bytes::u32_le(self.0, at)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
fn u32_be(&self, at: usize) -> Option<u32> {
|