imprimatur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/Cargo.lock +835 -0
- data/Cargo.toml +44 -0
- data/LICENSE.txt +29 -0
- data/README.md +314 -0
- data/ext/imprimatur_native/cli/Cargo.toml +30 -0
- data/ext/imprimatur_native/cli/src/app.rs +183 -0
- data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
- data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
- data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
- data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
- data/ext/imprimatur_native/cli/src/cli.rs +71 -0
- data/ext/imprimatur_native/cli/src/main.rs +15 -0
- data/ext/imprimatur_native/core/Cargo.toml +33 -0
- data/ext/imprimatur_native/core/LICENSE +29 -0
- data/ext/imprimatur_native/core/README.md +105 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
- data/ext/imprimatur_native/core/src/doc.rs +210 -0
- data/ext/imprimatur_native/core/src/docx.rs +343 -0
- data/ext/imprimatur_native/core/src/error.rs +113 -0
- data/ext/imprimatur_native/core/src/lib.rs +38 -0
- data/ext/imprimatur_native/core/src/opc.rs +51 -0
- data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
- data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
- data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
- data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
- data/ext/imprimatur_native/core/src/pdf.rs +84 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
- data/ext/imprimatur_native/core/src/read/content.rs +266 -0
- data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
- data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
- data/ext/imprimatur_native/core/src/read/image.rs +233 -0
- data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
- data/ext/imprimatur_native/core/src/read/json.rs +172 -0
- data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
- data/ext/imprimatur_native/core/src/read/package.rs +191 -0
- data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
- data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
- data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
- data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
- data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
- data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
- data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
- data/ext/imprimatur_native/core/src/read/span.rs +22 -0
- data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
- data/ext/imprimatur_native/core/src/read.rs +145 -0
- data/ext/imprimatur_native/core/src/style.rs +82 -0
- data/ext/imprimatur_native/core/src/test_support.rs +163 -0
- data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
- data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
- data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
- data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
- data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
- data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
- data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
- data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
- data/ext/imprimatur_native/core/src/xml.rs +95 -0
- data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
- data/ext/imprimatur_native/core/src/zip.rs +161 -0
- data/ext/imprimatur_native/extconf.rb +45 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
- data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
- data/ext/imprimatur_native/ffi/src/document.rs +138 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
- data/ext/imprimatur_native/ffi/src/read.rs +182 -0
- data/ext/imprimatur_native/ffi/src/style.rs +85 -0
- data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
- data/lib/imprimatur/document.rb +127 -0
- data/lib/imprimatur/error.rb +22 -0
- data/lib/imprimatur/formula.rb +13 -0
- data/lib/imprimatur/native.rb +17 -0
- data/lib/imprimatur/reader.rb +59 -0
- data/lib/imprimatur/style.rb +31 -0
- data/lib/imprimatur/validation.rb +35 -0
- data/lib/imprimatur/version.rb +5 -0
- data/lib/imprimatur/xlsx.rb +183 -0
- data/lib/imprimatur.rb +111 -0
- metadata +157 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//! The command line: the root, its global flag, and the commands.
|
|
2
|
+
|
|
3
|
+
mod completion;
|
|
4
|
+
mod grep;
|
|
5
|
+
mod images;
|
|
6
|
+
mod limits;
|
|
7
|
+
mod markdown;
|
|
8
|
+
#[cfg(test)]
|
|
9
|
+
mod tests;
|
|
10
|
+
|
|
11
|
+
use usage_rs::{Cli, Subcommands};
|
|
12
|
+
|
|
13
|
+
pub use completion::Completion;
|
|
14
|
+
pub use grep::Grep;
|
|
15
|
+
pub use images::Images;
|
|
16
|
+
pub use markdown::Markdown;
|
|
17
|
+
|
|
18
|
+
/// The exit statuses, for `--help`; `exit_code` below declares them for the
|
|
19
|
+
/// spec, and `app::Failure` returns them.
|
|
20
|
+
pub const EXIT_STATUS: &str = "Exit status:
|
|
21
|
+
0: every document was read
|
|
22
|
+
1: a document is damaged, or a file could not be read or written
|
|
23
|
+
2: the command line is wrong
|
|
24
|
+
3: a PDF has no text layer: its pages are scans, which need OCR
|
|
25
|
+
4: a PDF is encrypted with a password
|
|
26
|
+
5: not a PDF, XLSX or DOCX (legacy .xls and .doc, .xlsb, presentations)
|
|
27
|
+
6: a limit was hit: size, decompressed size, nesting or time";
|
|
28
|
+
|
|
29
|
+
/// Read PDF, XLSX and DOCX documents: as Markdown or JSON, searched, or their images
|
|
30
|
+
///
|
|
31
|
+
/// The format is told by a document's content, not its name. Documents are
|
|
32
|
+
/// untrusted input: reading is bounded in size, decompressed size, nesting
|
|
33
|
+
/// and time, so a hostile file fails fast instead of hanging.
|
|
34
|
+
///
|
|
35
|
+
/// `grep` exits as grep does: 0 when a line matched, 1 when none did, 2 on
|
|
36
|
+
/// trouble. The other commands exit as listed below.
|
|
37
|
+
#[derive(Cli, Debug)]
|
|
38
|
+
#[usage(
|
|
39
|
+
bin = "imprimatur",
|
|
40
|
+
version,
|
|
41
|
+
completion,
|
|
42
|
+
unknown_flags = "error",
|
|
43
|
+
args_override_self = false,
|
|
44
|
+
exit_code(0, "every document was read"),
|
|
45
|
+
exit_code(1, "a document is damaged, or a file could not be read or written"),
|
|
46
|
+
exit_code(2, "the command line is wrong"),
|
|
47
|
+
exit_code(3, "a PDF has no text layer: its pages are scans, which need OCR"),
|
|
48
|
+
exit_code(4, "a PDF is encrypted with a password"),
|
|
49
|
+
exit_code(5, "not a PDF, XLSX or DOCX: legacy .xls and .doc, .xlsb, presentations"),
|
|
50
|
+
exit_code(6, "a limit was hit: size, decompressed size, nesting or time"),
|
|
51
|
+
after_long_help = EXIT_STATUS
|
|
52
|
+
)]
|
|
53
|
+
pub struct Imprimatur {
|
|
54
|
+
/// One JSON object per document, each on its own line
|
|
55
|
+
#[usage(long, global)]
|
|
56
|
+
pub json: bool,
|
|
57
|
+
|
|
58
|
+
#[usage(subcommand)]
|
|
59
|
+
pub command: Commands,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[derive(Subcommands, Debug)]
|
|
63
|
+
#[usage(run_with)]
|
|
64
|
+
pub enum Commands {
|
|
65
|
+
#[usage(alias = "md")]
|
|
66
|
+
Markdown(Markdown),
|
|
67
|
+
Grep(Grep),
|
|
68
|
+
Images(Images),
|
|
69
|
+
#[usage(no_ctx)]
|
|
70
|
+
Completion(Completion),
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//! `imprimatur`: documents in, Markdown out. Reads PDF, XLSX and DOCX with
|
|
2
|
+
//! the imprimatur crate; see `imprimatur --help`.
|
|
3
|
+
|
|
4
|
+
mod app;
|
|
5
|
+
mod cli;
|
|
6
|
+
|
|
7
|
+
use std::process::ExitCode;
|
|
8
|
+
|
|
9
|
+
use usage_rs::RunWith;
|
|
10
|
+
|
|
11
|
+
fn main() -> ExitCode {
|
|
12
|
+
let cli = cli::Imprimatur::parse();
|
|
13
|
+
let app = app::App { json: cli.json };
|
|
14
|
+
cli.command.run_with(&app)
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "imprimatur"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "XLSX, PDF and DOCX authoring with its own XML, zip and PDF writers."
|
|
5
|
+
keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
|
|
6
|
+
categories = ["encoding", "text-processing"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
edition.workspace = true
|
|
9
|
+
rust-version.workspace = true
|
|
10
|
+
authors.workspace = true
|
|
11
|
+
license.workspace = true
|
|
12
|
+
repository.workspace = true
|
|
13
|
+
include = ["src/**/*.rs", "src/**/*.txt", "src/**/tests/*.pdf", "src/**/tests/*.xml", "src/**/tests/*.rels", "README.md", "LICENSE*"]
|
|
14
|
+
|
|
15
|
+
[lints]
|
|
16
|
+
workspace = true
|
|
17
|
+
|
|
18
|
+
[dependencies]
|
|
19
|
+
aes = "0.9.3"
|
|
20
|
+
crc32fast = "1.5.2"
|
|
21
|
+
md-5 = "0.11.0"
|
|
22
|
+
miniz_oxide = "0.9.1"
|
|
23
|
+
serde_json = { version = "1.0.154", optional = true }
|
|
24
|
+
sha2 = "0.11.1"
|
|
25
|
+
thiserror = "2.0.21"
|
|
26
|
+
|
|
27
|
+
[features]
|
|
28
|
+
# `read::json`: content and images as JSON, the schema the command line and
|
|
29
|
+
# the Ruby binding share.
|
|
30
|
+
json = ["dep:serde_json"]
|
|
31
|
+
|
|
32
|
+
[dev-dependencies]
|
|
33
|
+
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Abdelkader Boudih
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# imprimatur
|
|
2
|
+
|
|
3
|
+
Document authoring with no third-party format stack: the XML and the zip
|
|
4
|
+
container are written here, on `miniz_oxide` (deflate) and `crc32fast`.
|
|
5
|
+
XLSX workbooks, and PDF and DOCX from one document model.
|
|
6
|
+
Output is deterministic: the same document always gives the same bytes.
|
|
7
|
+
|
|
8
|
+
```rust
|
|
9
|
+
use imprimatur::xlsx::{Cell, Color, DateTime, Format, Workbook};
|
|
10
|
+
|
|
11
|
+
let header = Format::new().bold().fill(Color::from_hex("#1F4E79")?).font_color(Color::WHITE);
|
|
12
|
+
let mut book = Workbook::new();
|
|
13
|
+
let sheet = book.add_sheet("Budget")?;
|
|
14
|
+
sheet.append_row_with_format(["Line item", "Promised", "Delivered"], &header)?;
|
|
15
|
+
sheet.append_row([Cell::from("High-speed rail"), 1_000_000_000.into(), 0.into()])?;
|
|
16
|
+
sheet.append_row([Cell::from("Total"), Cell::formula("SUM(B2:B2)"), Cell::formula("SUM(C2:C2)")])?;
|
|
17
|
+
sheet.write(5, 0, "written at A6")?; // zero-based row and column
|
|
18
|
+
sheet.write(6, 0, DateTime::date(2026, 10, 15)?)?; // shows as yyyy-mm-dd
|
|
19
|
+
sheet.set_column_width(0, 24.0)?.set_right_to_left(false);
|
|
20
|
+
|
|
21
|
+
book.save("budget.xlsx")?; // or to_bytes(), or write_to(impl Write)
|
|
22
|
+
# std::fs::remove_file("budget.xlsx")?;
|
|
23
|
+
# Ok::<(), Box<dyn std::error::Error>>(())
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```rust
|
|
27
|
+
use imprimatur::doc::{Document, Paragraph, Run, Table};
|
|
28
|
+
|
|
29
|
+
let mut doc = Document::new();
|
|
30
|
+
doc.set_page_numbers(true);
|
|
31
|
+
doc.heading(1, "Quarterly report")?;
|
|
32
|
+
doc.push_paragraph(Paragraph::new().text("Revenue was ").run(Run::new("up 12%").bold()))?;
|
|
33
|
+
doc.table(Table::new([["Region", "Q1"], ["North", "12.5"]]).header())?;
|
|
34
|
+
let pdf = doc.to_pdf()?; // Helvetica, Windows-1252 text
|
|
35
|
+
let docx = doc.to_docx()?; // any script
|
|
36
|
+
# Ok::<(), imprimatur::Error>(())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```rust
|
|
40
|
+
use imprimatur::xlsx::{Alert, Comment, Format, Validation, VerticalAlign, Workbook};
|
|
41
|
+
|
|
42
|
+
let header = Format::new().font_name("Arial")?.font_size(12.0)?.bold().valign(VerticalAlign::Center);
|
|
43
|
+
let mut book = Workbook::new();
|
|
44
|
+
let sheet = book.add_sheet("Items")?;
|
|
45
|
+
sheet.append_row_with_format(["Name", "Unit"], &header)?;
|
|
46
|
+
sheet.set_comment(0, 0, Comment::new("Required.")?.author("Instructions"))?;
|
|
47
|
+
sheet.add_validation("B2:B1000".parse()?, Validation::list(["g", "kg", "ea"])?)?;
|
|
48
|
+
let short = Validation::custom("LEN(A2)<=100")?.alert(Alert::Warning).prompt("Name", "Short")?;
|
|
49
|
+
sheet.add_validation("A2:A1000".parse()?, short)?;
|
|
50
|
+
# Ok::<(), imprimatur::Error>(())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Everything Excel would refuse is an `Error` before a byte is written: invalid
|
|
54
|
+
or duplicate sheet names, positions beyond 1,048,576 rows or 16,384 columns,
|
|
55
|
+
text over 32,767 characters, formulas over 8,192, NaN and infinity, font
|
|
56
|
+
sizes Excel cannot set, inline validation lists over 255 characters, and
|
|
57
|
+
validations whose ranges overlap. A rejected row writes nothing.
|
|
58
|
+
|
|
59
|
+
Reading goes the other way, any PDF, XLSX or DOCX to Markdown, within
|
|
60
|
+
limits (input size, decompressed size, nesting, time, a cancel flag):
|
|
61
|
+
|
|
62
|
+
```rust
|
|
63
|
+
use imprimatur::read::{Limits, markdown};
|
|
64
|
+
|
|
65
|
+
# let mut book = imprimatur::xlsx::Workbook::new();
|
|
66
|
+
# book.add_sheet("Notes")?.append_row(["Hello"])?;
|
|
67
|
+
# let document = book.to_bytes()?;
|
|
68
|
+
let text = markdown(&document, &Limits::default())?;
|
|
69
|
+
assert!(text.starts_with("## Notes"));
|
|
70
|
+
# Ok::<(), imprimatur::Error>(())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Markdown is one rendering of what a read finds. `content` gives the
|
|
74
|
+
structure itself: a PDF's pages of headings and paragraphs with their
|
|
75
|
+
position on the page, a workbook's sheets of typed cells (number, date,
|
|
76
|
+
bool, text, error, formula) and comments, a Word document's blocks with
|
|
77
|
+
their bold, italic and linked stretches. `images` hands over every image,
|
|
78
|
+
as a file: JPEG and JPEG 2000 as stored, fax scans as TIFF, raw pixels as
|
|
79
|
+
PNG, an Office document's media as they are.
|
|
80
|
+
|
|
81
|
+
```rust
|
|
82
|
+
use imprimatur::read::{Content, Limits, Value, content, images};
|
|
83
|
+
|
|
84
|
+
# let mut book = imprimatur::xlsx::Workbook::new();
|
|
85
|
+
# book.add_sheet("Notes")?.append_row([imprimatur::xlsx::Cell::from(12.5)])?;
|
|
86
|
+
# let document = book.to_bytes()?;
|
|
87
|
+
let Content::Xlsx(sheets) = content(&document, &Limits::default())? else { panic!() };
|
|
88
|
+
let cell = &sheets[0].cells.cells()[0];
|
|
89
|
+
assert_eq!((cell.reference(), cell.value), ("A1".to_owned(), Value::Number(12.5)));
|
|
90
|
+
|
|
91
|
+
let mut found = Vec::new();
|
|
92
|
+
images(&document, &Limits::default(), |image| {
|
|
93
|
+
found.push((image.format.extension(), image.data));
|
|
94
|
+
Ok(())
|
|
95
|
+
})?;
|
|
96
|
+
assert!(found.is_empty(), "no pictures in this one");
|
|
97
|
+
# Ok::<(), imprimatur::Error>(())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Dependencies: `miniz_oxide`, `crc32fast` and `thiserror`; reading encrypted
|
|
101
|
+
PDFs adds `md-5`, `sha2` and `aes`.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
BSD 3-Clause
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
use crate::style::{Align, Color, TextDirection};
|
|
2
|
+
|
|
3
|
+
/// A stretch of text with one look.
|
|
4
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
5
|
+
pub struct Run {
|
|
6
|
+
pub(crate) text: String,
|
|
7
|
+
pub(crate) bold: bool,
|
|
8
|
+
pub(crate) italic: bool,
|
|
9
|
+
pub(crate) underline: bool,
|
|
10
|
+
pub(crate) color: Option<Color>,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl Run {
|
|
14
|
+
#[must_use]
|
|
15
|
+
pub fn new(text: impl Into<String>) -> Self {
|
|
16
|
+
Self { text: text.into(), bold: false, italic: false, underline: false, color: None }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[must_use]
|
|
20
|
+
pub const fn bold(mut self) -> Self {
|
|
21
|
+
self.bold = true;
|
|
22
|
+
self
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[must_use]
|
|
26
|
+
pub const fn italic(mut self) -> Self {
|
|
27
|
+
self.italic = true;
|
|
28
|
+
self
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[must_use]
|
|
32
|
+
pub const fn underline(mut self) -> Self {
|
|
33
|
+
self.underline = true;
|
|
34
|
+
self
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[must_use]
|
|
38
|
+
pub const fn color(mut self, color: Color) -> Self {
|
|
39
|
+
self.color = Some(color);
|
|
40
|
+
self
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[must_use]
|
|
44
|
+
pub fn text(&self) -> &str {
|
|
45
|
+
&self.text
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Runs of text laid out together. Left aligned unless told otherwise, or
|
|
50
|
+
/// right aligned when right to left.
|
|
51
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
52
|
+
pub struct Paragraph {
|
|
53
|
+
pub(crate) runs: Vec<Run>,
|
|
54
|
+
pub(crate) align: Option<Align>,
|
|
55
|
+
pub(crate) direction: Option<TextDirection>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl Paragraph {
|
|
59
|
+
#[must_use]
|
|
60
|
+
pub fn new() -> Self {
|
|
61
|
+
Self::default()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Appends plain text.
|
|
65
|
+
#[must_use]
|
|
66
|
+
pub fn text(self, text: impl Into<String>) -> Self {
|
|
67
|
+
self.run(Run::new(text))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Appends bold text.
|
|
71
|
+
#[must_use]
|
|
72
|
+
pub fn bold(self, text: impl Into<String>) -> Self {
|
|
73
|
+
self.run(Run::new(text).bold())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Appends italic text.
|
|
77
|
+
#[must_use]
|
|
78
|
+
pub fn italic(self, text: impl Into<String>) -> Self {
|
|
79
|
+
self.run(Run::new(text).italic())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[must_use]
|
|
83
|
+
pub fn run(mut self, run: Run) -> Self {
|
|
84
|
+
self.runs.push(run);
|
|
85
|
+
self
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[must_use]
|
|
89
|
+
pub const fn align(mut self, align: Align) -> Self {
|
|
90
|
+
self.align = Some(align);
|
|
91
|
+
self
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[must_use]
|
|
95
|
+
pub const fn direction(mut self, direction: TextDirection) -> Self {
|
|
96
|
+
self.direction = Some(direction);
|
|
97
|
+
self
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[must_use]
|
|
101
|
+
pub fn runs(&self) -> &[Run] {
|
|
102
|
+
&self.runs
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// The alignment the paragraph is laid out with.
|
|
106
|
+
pub(crate) fn effective_align(&self) -> Align {
|
|
107
|
+
self.align.unwrap_or(match self.direction {
|
|
108
|
+
Some(TextDirection::RightToLeft) => Align::Right,
|
|
109
|
+
_ => Align::Left,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// A grid of text cells; columns share the page width equally. Short rows
|
|
2
|
+
/// are padded with empty cells.
|
|
3
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
4
|
+
pub struct Table {
|
|
5
|
+
pub(crate) rows: Vec<Vec<String>>,
|
|
6
|
+
pub(crate) header: bool,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
impl Table {
|
|
10
|
+
pub fn new<R, C>(rows: impl IntoIterator<Item = R>) -> Self
|
|
11
|
+
where
|
|
12
|
+
R: IntoIterator<Item = C>,
|
|
13
|
+
C: Into<String>,
|
|
14
|
+
{
|
|
15
|
+
let rows = rows.into_iter().map(|row| row.into_iter().map(Into::into).collect()).collect();
|
|
16
|
+
Self { rows, header: false }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Makes the first row a header: bold, shaded, and repeated on every
|
|
20
|
+
/// page the table spans.
|
|
21
|
+
#[must_use]
|
|
22
|
+
pub const fn header(mut self) -> Self {
|
|
23
|
+
self.header = true;
|
|
24
|
+
self
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[must_use]
|
|
28
|
+
pub fn rows(&self) -> &[Vec<String>] {
|
|
29
|
+
&self.rows
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The widest row's cell count.
|
|
33
|
+
#[must_use]
|
|
34
|
+
pub fn columns(&self) -> usize {
|
|
35
|
+
self.rows.iter().map(Vec::len).max().unwrap_or(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(crate) const fn has_header(&self) -> bool {
|
|
39
|
+
self.header
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
//! A flowing document (headings, paragraphs, tables, page breaks) that
|
|
2
|
+
//! renders to PDF and DOCX from the same content.
|
|
3
|
+
//!
|
|
4
|
+
//! ```
|
|
5
|
+
//! use imprimatur::doc::{Document, Paragraph, Run, Table};
|
|
6
|
+
//! use imprimatur::style::{Align, Color};
|
|
7
|
+
//!
|
|
8
|
+
//! let mut doc = Document::new();
|
|
9
|
+
//! doc.heading(1, "Quarterly report")?;
|
|
10
|
+
//! doc.push_paragraph(
|
|
11
|
+
//! Paragraph::new()
|
|
12
|
+
//! .text("Revenue was ")
|
|
13
|
+
//! .run(Run::new("up 12%").bold().color(Color::rgb(0x1F_7A_1F)))
|
|
14
|
+
//! .text(" this quarter.")
|
|
15
|
+
//! .align(Align::Left),
|
|
16
|
+
//! )?;
|
|
17
|
+
//! doc.table(Table::new([["Region", "Q1"], ["North", "12.5"]]).header())?;
|
|
18
|
+
//! let pdf = doc.to_pdf()?;
|
|
19
|
+
//! let docx = doc.to_docx()?;
|
|
20
|
+
//! assert!(pdf.starts_with(b"%PDF-") && docx.starts_with(b"PK"));
|
|
21
|
+
//! # Ok::<(), imprimatur::Error>(())
|
|
22
|
+
//! ```
|
|
23
|
+
|
|
24
|
+
mod paragraph;
|
|
25
|
+
mod table;
|
|
26
|
+
#[cfg(test)]
|
|
27
|
+
mod tests;
|
|
28
|
+
|
|
29
|
+
use std::fs::File;
|
|
30
|
+
use std::io::{BufWriter, Write};
|
|
31
|
+
use std::path::Path;
|
|
32
|
+
|
|
33
|
+
pub use paragraph::{Paragraph, Run};
|
|
34
|
+
pub use table::Table;
|
|
35
|
+
|
|
36
|
+
use crate::{Error, Result};
|
|
37
|
+
|
|
38
|
+
/// Paper size; pages are portrait.
|
|
39
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
40
|
+
pub enum PageSize {
|
|
41
|
+
#[default]
|
|
42
|
+
A4,
|
|
43
|
+
Letter,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl PageSize {
|
|
47
|
+
/// Width and height in PDF points (1/72 inch).
|
|
48
|
+
pub(crate) const fn points(self) -> (f64, f64) {
|
|
49
|
+
match self {
|
|
50
|
+
Self::A4 => (595.276, 841.89),
|
|
51
|
+
Self::Letter => (612.0, 792.0),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Width and height in twentieths of a point, as Word measures pages.
|
|
56
|
+
pub(crate) const fn twips(self) -> (u32, u32) {
|
|
57
|
+
match self {
|
|
58
|
+
Self::A4 => (11_906, 16_838),
|
|
59
|
+
Self::Letter => (12_240, 15_840),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// One piece of a document, in reading order.
|
|
65
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
66
|
+
#[non_exhaustive]
|
|
67
|
+
pub enum Block {
|
|
68
|
+
Heading { level: u8, text: String },
|
|
69
|
+
Paragraph(Paragraph),
|
|
70
|
+
Table(Table),
|
|
71
|
+
PageBreak,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Content and page setup; render it with [`to_pdf`](Self::to_pdf) or
|
|
75
|
+
/// [`to_docx`](Self::to_docx).
|
|
76
|
+
#[derive(Debug, Clone, Default, PartialEq)]
|
|
77
|
+
pub struct Document {
|
|
78
|
+
blocks: Vec<Block>,
|
|
79
|
+
page_size: PageSize,
|
|
80
|
+
page_numbers: bool,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
impl Document {
|
|
84
|
+
#[must_use]
|
|
85
|
+
pub fn new() -> Self {
|
|
86
|
+
Self::default()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pub const fn set_page_size(&mut self, size: PageSize) -> &mut Self {
|
|
90
|
+
self.page_size = size;
|
|
91
|
+
self
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Numbers pages `1 / 3` at the bottom of each one.
|
|
95
|
+
pub const fn set_page_numbers(&mut self, enabled: bool) -> &mut Self {
|
|
96
|
+
self.page_numbers = enabled;
|
|
97
|
+
self
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// A heading, level 1 (the title) to 3.
|
|
101
|
+
///
|
|
102
|
+
/// # Errors
|
|
103
|
+
/// [`Error::InvalidHeadingLevel`], or [`Error::ControlCharacter`] in the text.
|
|
104
|
+
pub fn heading(&mut self, level: u8, text: impl Into<String>) -> Result<&mut Self> {
|
|
105
|
+
(1..=3).contains(&level).ok_or(Error::InvalidHeadingLevel(level))?;
|
|
106
|
+
let text = text.into();
|
|
107
|
+
validate_text(&text)?;
|
|
108
|
+
self.blocks.push(Block::Heading { level, text });
|
|
109
|
+
Ok(self)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// A paragraph of plain text. `\n` breaks the line, `\t` is a tab.
|
|
113
|
+
///
|
|
114
|
+
/// # Errors
|
|
115
|
+
/// [`Error::ControlCharacter`] for any other control character.
|
|
116
|
+
pub fn paragraph(&mut self, text: impl Into<String>) -> Result<&mut Self> {
|
|
117
|
+
self.push_paragraph(Paragraph::new().text(text))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// # Errors
|
|
121
|
+
/// [`Error::ControlCharacter`] in any run.
|
|
122
|
+
pub fn push_paragraph(&mut self, paragraph: Paragraph) -> Result<&mut Self> {
|
|
123
|
+
for run in ¶graph.runs {
|
|
124
|
+
validate_text(&run.text)?;
|
|
125
|
+
}
|
|
126
|
+
self.blocks.push(Block::Paragraph(paragraph));
|
|
127
|
+
Ok(self)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// # Errors
|
|
131
|
+
/// [`Error::EmptyTable`] without a row or a column, and
|
|
132
|
+
/// [`Error::ControlCharacter`] in any cell.
|
|
133
|
+
pub fn table(&mut self, table: Table) -> Result<&mut Self> {
|
|
134
|
+
(table.columns() > 0).ok_or(Error::EmptyTable)?;
|
|
135
|
+
for cell in table.rows.iter().flatten() {
|
|
136
|
+
validate_text(cell)?;
|
|
137
|
+
}
|
|
138
|
+
self.blocks.push(Block::Table(table));
|
|
139
|
+
Ok(self)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
pub fn page_break(&mut self) -> &mut Self {
|
|
143
|
+
self.blocks.push(Block::PageBreak);
|
|
144
|
+
self
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[must_use]
|
|
148
|
+
pub fn blocks(&self) -> &[Block] {
|
|
149
|
+
&self.blocks
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[must_use]
|
|
153
|
+
pub const fn page_size(&self) -> PageSize {
|
|
154
|
+
self.page_size
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[must_use]
|
|
158
|
+
pub const fn has_page_numbers(&self) -> bool {
|
|
159
|
+
self.page_numbers
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// # Errors
|
|
163
|
+
/// [`Error::UnsupportedCharacter`] for text the PDF standard fonts cannot
|
|
164
|
+
/// encode, [`Error::RowTooTall`], or [`Error::Io`] when `out` fails.
|
|
165
|
+
pub fn write_pdf<W: Write>(&self, out: W) -> Result<W> {
|
|
166
|
+
crate::pdf::write(self, out)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// # Errors
|
|
170
|
+
/// As [`write_pdf`](Self::write_pdf).
|
|
171
|
+
pub fn to_pdf(&self) -> Result<Vec<u8>> {
|
|
172
|
+
self.write_pdf(Vec::new())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// # Errors
|
|
176
|
+
/// As [`write_pdf`](Self::write_pdf).
|
|
177
|
+
pub fn save_pdf(&self, path: impl AsRef<Path>) -> Result<()> {
|
|
178
|
+
self.write_pdf(BufWriter::new(File::create(path)?))?;
|
|
179
|
+
Ok(())
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// # Errors
|
|
183
|
+
/// [`Error::Io`] when `out` fails.
|
|
184
|
+
pub fn write_docx<W: Write>(&self, out: W) -> Result<W> {
|
|
185
|
+
crate::docx::write(self, out)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// # Errors
|
|
189
|
+
/// As [`write_docx`](Self::write_docx).
|
|
190
|
+
pub fn to_docx(&self) -> Result<Vec<u8>> {
|
|
191
|
+
self.write_docx(Vec::new())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// # Errors
|
|
195
|
+
/// As [`write_docx`](Self::write_docx).
|
|
196
|
+
pub fn save_docx(&self, path: impl AsRef<Path>) -> Result<()> {
|
|
197
|
+
self.write_docx(BufWriter::new(File::create(path)?))?;
|
|
198
|
+
Ok(())
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Newlines and tabs mean something in every format; other control
|
|
203
|
+
/// characters mean nothing in any of them.
|
|
204
|
+
fn validate_text(text: &str) -> Result<()> {
|
|
205
|
+
let invalid =
|
|
206
|
+
|ch: char| (ch.is_control() && ch != '\n' && ch != '\t') || crate::xml::is_forbidden(ch);
|
|
207
|
+
text.chars()
|
|
208
|
+
.find(|&ch| invalid(ch))
|
|
209
|
+
.map_or(Ok(()), |ch| Err(Error::ControlCharacter(u32::from(ch))))
|
|
210
|
+
}
|