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.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. 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 &paragraph.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
+ }