imprimatur 0.1.0 → 0.3.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 +31 -0
- data/Cargo.lock +124 -3
- data/Cargo.toml +1 -1
- data/README.md +123 -23
- data/ext/imprimatur_native/cli/Cargo.toml +2 -2
- data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
- data/ext/imprimatur_native/cli/src/cli.rs +5 -5
- data/ext/imprimatur_native/cli/src/main.rs +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +12 -2
- data/ext/imprimatur_native/core/README.md +15 -1
- data/ext/imprimatur_native/core/src/archive.rs +279 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
- data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
- data/ext/imprimatur_native/core/src/doc.rs +86 -2
- data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
- data/ext/imprimatur_native/core/src/docx.rs +91 -28
- data/ext/imprimatur_native/core/src/error.rs +13 -1
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +5 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
- data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
- data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
- data/ext/imprimatur_native/core/src/pdf.rs +31 -3
- data/ext/imprimatur_native/core/src/read/content.rs +91 -3
- data/ext/imprimatur_native/core/src/read/json.rs +96 -7
- data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
- data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
- data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
- data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
- data/ext/imprimatur_native/core/src/read/package.rs +0 -5
- data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
- data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
- data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
- data/ext/imprimatur_native/core/src/read.rs +68 -21
- data/ext/imprimatur_native/core/src/script.rs +116 -0
- data/ext/imprimatur_native/core/src/style.rs +21 -0
- data/ext/imprimatur_native/core/src/test_support.rs +90 -2
- data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
- data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
- data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
- data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
- data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
- data/ext/imprimatur_native/ffi/src/archive.rs +119 -0
- data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
- data/ext/imprimatur_native/ffi/src/read.rs +9 -14
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -6
- data/lib/imprimatur/error.rb +4 -3
- data/lib/imprimatur/reader.rb +3 -3
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur/zip.rb +127 -0
- data/lib/imprimatur.rb +18 -5
- metadata +23 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
//! Zip archives of any files, for what is not a document.
|
|
2
|
+
//!
|
|
3
|
+
//! Entries are written from disk (streamed) or from memory, with their
|
|
4
|
+
//! modification times and comments, ZIP64 when an entry is large; they are
|
|
5
|
+
//! read one by one, each within a size limit. Behind the `archive` feature,
|
|
6
|
+
//! which brings in the `zip` crate; documents' own zips (XLSX, DOCX) never
|
|
7
|
+
//! need it.
|
|
8
|
+
//!
|
|
9
|
+
//! ```
|
|
10
|
+
//! use std::io::Cursor;
|
|
11
|
+
//! use imprimatur::archive::{Archive, Writer};
|
|
12
|
+
//!
|
|
13
|
+
//! let mut writer = Writer::new(Cursor::new(Vec::new()));
|
|
14
|
+
//! writer.add("notes/readme.txt", b"hello", Some("first"))?;
|
|
15
|
+
//! let bytes = writer.finish()?.into_inner();
|
|
16
|
+
//!
|
|
17
|
+
//! let mut archive = Archive::new(Cursor::new(bytes))?;
|
|
18
|
+
//! assert_eq!(archive.names(), ["notes/readme.txt"]);
|
|
19
|
+
//! assert_eq!(archive.read("notes/readme.txt", 1024)?.as_deref(), Some(&b"hello"[..]));
|
|
20
|
+
//! # Ok::<(), imprimatur::Error>(())
|
|
21
|
+
//! ```
|
|
22
|
+
|
|
23
|
+
#[cfg(test)]
|
|
24
|
+
mod tests;
|
|
25
|
+
|
|
26
|
+
use std::fs::File;
|
|
27
|
+
use std::io::{self, Read, Seek, Write};
|
|
28
|
+
use std::path::Path;
|
|
29
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
30
|
+
|
|
31
|
+
use ::zip::result::ZipError;
|
|
32
|
+
use ::zip::write::FullFileOptions;
|
|
33
|
+
use ::zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
|
|
34
|
+
|
|
35
|
+
use crate::xlsx::civil_from_days;
|
|
36
|
+
use crate::{Error, Result};
|
|
37
|
+
|
|
38
|
+
/// An archive being written; [`finish`](Self::finish) writes its directory.
|
|
39
|
+
pub struct Writer<W: Write + Seek> {
|
|
40
|
+
zip: ZipWriter<W>,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl Writer<File> {
|
|
44
|
+
/// A new archive at `path`, replacing any file there.
|
|
45
|
+
///
|
|
46
|
+
/// # Errors
|
|
47
|
+
/// [`Error::Io`] when the file cannot be created.
|
|
48
|
+
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
|
|
49
|
+
Ok(Self::new(File::create(path)?))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl<W: Write + Seek> Writer<W> {
|
|
54
|
+
pub fn new(sink: W) -> Self {
|
|
55
|
+
Self { zip: ZipWriter::new(sink) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Adds the file at `source` as `name`, streamed from disk, with its
|
|
59
|
+
/// modification time.
|
|
60
|
+
///
|
|
61
|
+
/// # Errors
|
|
62
|
+
/// [`Error::Io`] when the file cannot be read or the archive written.
|
|
63
|
+
pub fn add_file(
|
|
64
|
+
&mut self,
|
|
65
|
+
name: &str,
|
|
66
|
+
source: impl AsRef<Path>,
|
|
67
|
+
comment: Option<&str>,
|
|
68
|
+
) -> Result<()> {
|
|
69
|
+
let mut file = File::open(source)?;
|
|
70
|
+
let metadata = file.metadata()?;
|
|
71
|
+
self.zip
|
|
72
|
+
.start_file(name, options(metadata.modified().ok(), metadata.len(), comment))
|
|
73
|
+
.map_err(error)?;
|
|
74
|
+
io::copy(&mut file, &mut self.zip)?;
|
|
75
|
+
Ok(())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Adds `data` as `name`, dated now.
|
|
79
|
+
///
|
|
80
|
+
/// # Errors
|
|
81
|
+
/// [`Error::Io`] when the archive cannot be written.
|
|
82
|
+
pub fn add(&mut self, name: &str, data: &[u8], comment: Option<&str>) -> Result<()> {
|
|
83
|
+
let size = u64::try_from(data.len()).unwrap_or(u64::MAX);
|
|
84
|
+
self.zip
|
|
85
|
+
.start_file(name, options(Some(SystemTime::now()), size, comment))
|
|
86
|
+
.map_err(error)?;
|
|
87
|
+
self.zip.write_all(data)?;
|
|
88
|
+
Ok(())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The archive's own comment, shown by most zip tools.
|
|
92
|
+
///
|
|
93
|
+
/// # Errors
|
|
94
|
+
/// [`Error::Malformed`] for a comment over 65,535 bytes.
|
|
95
|
+
pub fn set_comment(&mut self, comment: &str) -> Result<()> {
|
|
96
|
+
self.zip.set_comment(comment).map_err(error)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Writes the central directory and gives the sink back.
|
|
100
|
+
///
|
|
101
|
+
/// # Errors
|
|
102
|
+
/// [`Error::Io`] when it cannot be written.
|
|
103
|
+
pub fn finish(self) -> Result<W> {
|
|
104
|
+
self.zip.finish().map_err(error)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// How an entry is written: deflated, dated, ZIP64 when it needs to be.
|
|
109
|
+
fn options(
|
|
110
|
+
modified: Option<SystemTime>,
|
|
111
|
+
size: u64,
|
|
112
|
+
comment: Option<&str>,
|
|
113
|
+
) -> FullFileOptions<'static, 'static> {
|
|
114
|
+
let options = FullFileOptions::default()
|
|
115
|
+
.compression_method(CompressionMethod::Deflated)
|
|
116
|
+
.last_modified_time(dos_time(modified))
|
|
117
|
+
.large_file(size >= u64::from(u32::MAX));
|
|
118
|
+
match comment {
|
|
119
|
+
Some(comment) => options.with_file_comment(comment),
|
|
120
|
+
None => options,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// A time as a zip entry's (UTC, two-second steps, 1980 to 2107); the
|
|
125
|
+
/// format's epoch outside that range.
|
|
126
|
+
fn dos_time(time: Option<SystemTime>) -> DateTime {
|
|
127
|
+
let Some(seconds) =
|
|
128
|
+
time.and_then(|time| time.duration_since(UNIX_EPOCH).ok()).map(|since| since.as_secs())
|
|
129
|
+
else {
|
|
130
|
+
return DateTime::default();
|
|
131
|
+
};
|
|
132
|
+
let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
|
|
133
|
+
let (year, month, day) = civil_from_days(days);
|
|
134
|
+
let clock = seconds % 86_400;
|
|
135
|
+
let part = |value: u64| u8::try_from(value).unwrap_or(0);
|
|
136
|
+
let (month, day) = (u8::try_from(month).unwrap_or(1), u8::try_from(day).unwrap_or(1));
|
|
137
|
+
u16::try_from(year)
|
|
138
|
+
.ok()
|
|
139
|
+
.and_then(|year| {
|
|
140
|
+
DateTime::from_date_and_time(
|
|
141
|
+
year,
|
|
142
|
+
month,
|
|
143
|
+
day,
|
|
144
|
+
part(clock / 3600),
|
|
145
|
+
part(clock / 60 % 60),
|
|
146
|
+
part(clock % 60),
|
|
147
|
+
)
|
|
148
|
+
.ok()
|
|
149
|
+
})
|
|
150
|
+
.unwrap_or_default()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// What the archive's directory says of an entry.
|
|
154
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
155
|
+
pub struct Entry {
|
|
156
|
+
pub name: String,
|
|
157
|
+
/// Its size once decompressed, as the directory states it.
|
|
158
|
+
pub size: u64,
|
|
159
|
+
pub compressed_size: u64,
|
|
160
|
+
pub crc32: u32,
|
|
161
|
+
pub comment: String,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// An archive being read.
|
|
165
|
+
pub struct Archive<R: Read + Seek> {
|
|
166
|
+
zip: ZipArchive<R>,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
impl Archive<File> {
|
|
170
|
+
/// The archive at `path`.
|
|
171
|
+
///
|
|
172
|
+
/// # Errors
|
|
173
|
+
/// [`Error::Io`] when it cannot be opened, [`Error::Malformed`] when it
|
|
174
|
+
/// is no zip.
|
|
175
|
+
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
|
176
|
+
Self::new(File::open(path)?)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
impl<R: Read + Seek> Archive<R> {
|
|
181
|
+
/// # Errors
|
|
182
|
+
/// [`Error::Malformed`] when `source` is no zip.
|
|
183
|
+
pub fn new(source: R) -> Result<Self> {
|
|
184
|
+
Ok(Self { zip: ZipArchive::new(source).map_err(error)? })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// The entries' names, in the archive's order.
|
|
188
|
+
#[must_use]
|
|
189
|
+
pub fn names(&self) -> Vec<String> {
|
|
190
|
+
self.zip
|
|
191
|
+
.file_names()
|
|
192
|
+
.filter_map(std::result::Result::ok)
|
|
193
|
+
.map(std::borrow::Cow::into_owned)
|
|
194
|
+
.collect()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Every entry's name, sizes, checksum and comment, in the archive's
|
|
198
|
+
/// order, without decompressing anything.
|
|
199
|
+
///
|
|
200
|
+
/// # Errors
|
|
201
|
+
/// [`Error::Malformed`] for a damaged directory.
|
|
202
|
+
pub fn entries(&mut self) -> Result<Vec<Entry>> {
|
|
203
|
+
(0..self.zip.len())
|
|
204
|
+
.map(|index| {
|
|
205
|
+
let entry = self.zip.by_index_raw(index).map_err(error)?;
|
|
206
|
+
Ok(Entry {
|
|
207
|
+
name: entry.name().map_err(error)?.into_owned(),
|
|
208
|
+
size: entry.size(),
|
|
209
|
+
compressed_size: entry.compressed_size(),
|
|
210
|
+
crc32: entry.crc32(),
|
|
211
|
+
comment: entry.comment().to_owned(),
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
.collect()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// The entry `name`'s bytes, `None` when there is none.
|
|
218
|
+
///
|
|
219
|
+
/// # Errors
|
|
220
|
+
/// [`Error::LimitExceeded`] past `limit` bytes, [`Error::PasswordRequired`]
|
|
221
|
+
/// for an encrypted entry, [`Error::Malformed`] for a damaged one.
|
|
222
|
+
pub fn read(&mut self, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
|
|
223
|
+
let mut out = Vec::new();
|
|
224
|
+
self.copy(name, &mut out, limit).map(|found| found.then_some(out))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Writes the entry `name` to the file at `path`; whether there was one.
|
|
228
|
+
///
|
|
229
|
+
/// # Errors
|
|
230
|
+
/// As [`read`](Self::read), and [`Error::Io`] for the file.
|
|
231
|
+
pub fn extract(&mut self, name: &str, path: impl AsRef<Path>, limit: u64) -> Result<bool> {
|
|
232
|
+
if !self.names().iter().any(|entry| entry == name) {
|
|
233
|
+
return Ok(false);
|
|
234
|
+
}
|
|
235
|
+
let mut file = File::create(path)?;
|
|
236
|
+
self.copy(name, &mut file, limit)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Copies an entry's bytes to `out`, at most `limit` of them.
|
|
240
|
+
fn copy(&mut self, name: &str, out: &mut impl Write, limit: u64) -> Result<bool> {
|
|
241
|
+
let entry = match self.zip.by_name(name) {
|
|
242
|
+
Ok(entry) => entry,
|
|
243
|
+
Err(ZipError::FileNotFound) => return Ok(false),
|
|
244
|
+
Err(other) => return Err(error(other)),
|
|
245
|
+
};
|
|
246
|
+
// One byte past the limit tells a full entry from a cut one.
|
|
247
|
+
let copied = io::copy(&mut entry.take(limit.saturating_add(1)), out)?;
|
|
248
|
+
if copied > limit {
|
|
249
|
+
return Err(Error::LimitExceeded("decompressed size"));
|
|
250
|
+
}
|
|
251
|
+
Ok(true)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
impl<W: Write + Seek> std::fmt::Debug for Writer<W> {
|
|
256
|
+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
257
|
+
formatter.debug_struct("Writer").finish_non_exhaustive()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
impl<R: Read + Seek> std::fmt::Debug for Archive<R> {
|
|
262
|
+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
263
|
+
formatter.debug_struct("Archive").field("entries", &self.zip.len()).finish()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn error(error: ZipError) -> Error {
|
|
268
|
+
match error {
|
|
269
|
+
ZipError::Io(error) => Error::Io(error),
|
|
270
|
+
ZipError::InvalidPassword => Error::PasswordRequired,
|
|
271
|
+
ZipError::UnsupportedArchive(reason) if reason.contains("assword") => {
|
|
272
|
+
Error::PasswordRequired
|
|
273
|
+
}
|
|
274
|
+
ZipError::UnsupportedArchive(reason) => {
|
|
275
|
+
Error::UnsupportedDocument(format!("a zip entry: {reason}"))
|
|
276
|
+
}
|
|
277
|
+
other => Error::malformed("zip", other.to_string()),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -46,8 +46,9 @@ impl Run {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/// Runs of text laid out together
|
|
50
|
-
///
|
|
49
|
+
/// Runs of text laid out together, in the direction set or else that of
|
|
50
|
+
/// their first letter. Left aligned unless told otherwise, or right aligned
|
|
51
|
+
/// when right to left.
|
|
51
52
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
52
53
|
pub struct Paragraph {
|
|
53
54
|
pub(crate) runs: Vec<Run>,
|
|
@@ -102,9 +103,15 @@ impl Paragraph {
|
|
|
102
103
|
&self.runs
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/// The direction the paragraph reads in: as set, or else that of its
|
|
107
|
+
/// first letter.
|
|
108
|
+
pub(crate) fn effective_direction(&self) -> Option<TextDirection> {
|
|
109
|
+
self.direction.or_else(|| self.runs.iter().find_map(|run| TextDirection::of(&run.text)))
|
|
110
|
+
}
|
|
111
|
+
|
|
105
112
|
/// The alignment the paragraph is laid out with.
|
|
106
113
|
pub(crate) fn effective_align(&self) -> Align {
|
|
107
|
-
self.align.
|
|
114
|
+
self.align.unwrap_or_else(|| match self.effective_direction() {
|
|
108
115
|
Some(TextDirection::RightToLeft) => Align::Right,
|
|
109
116
|
_ => Align::Left,
|
|
110
117
|
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
//! Pictures: PNG or JPEG files, read once for what PDF and DOCX need.
|
|
2
|
+
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
|
|
5
|
+
use crate::style::Align;
|
|
6
|
+
use crate::{Error, Result};
|
|
7
|
+
|
|
8
|
+
/// Pixels per point at 96 pixels an inch: a picture's size when none is set.
|
|
9
|
+
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
|
+
/// The largest side, in pixels, a picture may have.
|
|
13
|
+
const MAX_PIXELS: u32 = 1 << 16;
|
|
14
|
+
|
|
15
|
+
/// A PNG or JPEG drawn in the document's flow.
|
|
16
|
+
///
|
|
17
|
+
/// It is drawn at the width or height set (in points, the other following
|
|
18
|
+
/// its proportions), or else at 96 pixels an inch, never larger than the
|
|
19
|
+
/// page leaves room for.
|
|
20
|
+
///
|
|
21
|
+
/// ```no_run
|
|
22
|
+
/// use imprimatur::doc::{Document, Picture};
|
|
23
|
+
/// use imprimatur::style::Align;
|
|
24
|
+
///
|
|
25
|
+
/// let mut doc = Document::new();
|
|
26
|
+
/// doc.picture(Picture::new(std::fs::read("logo.png")?)?.width(144.0)?.align(Align::Center));
|
|
27
|
+
/// # Ok::<(), imprimatur::Error>(())
|
|
28
|
+
/// ```
|
|
29
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
30
|
+
pub struct Picture {
|
|
31
|
+
pub(crate) data: Arc<[u8]>,
|
|
32
|
+
pub(crate) kind: Kind,
|
|
33
|
+
pub(crate) pixels: (u32, u32),
|
|
34
|
+
width: Option<f64>,
|
|
35
|
+
height: Option<f64>,
|
|
36
|
+
pub(crate) align: Option<Align>,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The format, and what writing it takes.
|
|
40
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
41
|
+
pub enum Kind {
|
|
42
|
+
Png(Png),
|
|
43
|
+
/// Gray (1), RGB (3) or CMYK (4) components; Adobe's CMYK is inverted.
|
|
44
|
+
Jpeg {
|
|
45
|
+
components: u8,
|
|
46
|
+
inverted: bool,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// A PNG's pixel layout and its compressed pixels (its `IDAT` chunks).
|
|
51
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
52
|
+
pub struct Png {
|
|
53
|
+
pub depth: u8,
|
|
54
|
+
/// 0 gray, 2 RGB, 3 palette, 4 gray and alpha, 6 RGB and alpha.
|
|
55
|
+
pub color: u8,
|
|
56
|
+
pub palette: Vec<u8>,
|
|
57
|
+
/// The `tRNS` chunk: a palette's alphas, or the transparent color.
|
|
58
|
+
pub transparency: Vec<u8>,
|
|
59
|
+
pub compressed: Vec<u8>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Picture {
|
|
63
|
+
/// # Errors
|
|
64
|
+
/// [`Error::InvalidImage`] for data that is no PNG or JPEG, a damaged
|
|
65
|
+
/// one, an interlaced PNG, a JPEG other than 8 bits a sample, or one
|
|
66
|
+
/// larger than 65 536 pixels a side.
|
|
67
|
+
pub fn new(data: impl Into<Vec<u8>>) -> Result<Self> {
|
|
68
|
+
let data: Arc<[u8]> = data.into().into();
|
|
69
|
+
let (kind, pixels) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
|
70
|
+
png(&data)?
|
|
71
|
+
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
|
72
|
+
jpeg(&data)?
|
|
73
|
+
} else {
|
|
74
|
+
return Err(Error::InvalidImage("not a PNG or JPEG image"));
|
|
75
|
+
};
|
|
76
|
+
let valid = (1..=MAX_PIXELS).contains(&pixels.0) && (1..=MAX_PIXELS).contains(&pixels.1);
|
|
77
|
+
valid.ok_or(Error::InvalidImage("a picture of 1 to 65 536 pixels a side"))?;
|
|
78
|
+
Ok(Self { data, kind, pixels, width: None, height: None, align: None })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Its width in points; the height follows unless set too.
|
|
82
|
+
///
|
|
83
|
+
/// # Errors
|
|
84
|
+
/// [`Error::InvalidImage`] outside 1 to 14 400 points.
|
|
85
|
+
pub fn width(mut self, points: f64) -> Result<Self> {
|
|
86
|
+
self.width = Some(points_in_range(points)?);
|
|
87
|
+
Ok(self)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Its height in points; the width follows unless set too.
|
|
91
|
+
///
|
|
92
|
+
/// # Errors
|
|
93
|
+
/// [`Error::InvalidImage`] outside 1 to 14 400 points.
|
|
94
|
+
pub fn height(mut self, points: f64) -> Result<Self> {
|
|
95
|
+
self.height = Some(points_in_range(points)?);
|
|
96
|
+
Ok(self)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[must_use]
|
|
100
|
+
pub const fn align(mut self, align: Align) -> Self {
|
|
101
|
+
self.align = Some(align);
|
|
102
|
+
self
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// The size it is drawn at, in points, fitted into `room` (the width
|
|
106
|
+
/// and height between the margins).
|
|
107
|
+
pub(crate) fn size(&self, room: (f64, f64)) -> (f64, f64) {
|
|
108
|
+
let (pixels_wide, pixels_high) = (f64::from(self.pixels.0), f64::from(self.pixels.1));
|
|
109
|
+
let (width, height) = match (self.width, self.height) {
|
|
110
|
+
(Some(width), Some(height)) => (width, height),
|
|
111
|
+
(Some(width), None) => (width, width * pixels_high / pixels_wide),
|
|
112
|
+
(None, Some(height)) => (height * pixels_wide / pixels_high, height),
|
|
113
|
+
(None, None) => (pixels_wide * POINTS_PER_PIXEL, pixels_high * POINTS_PER_PIXEL),
|
|
114
|
+
};
|
|
115
|
+
let scale = (room.0 / width).min(room.1 / height).min(1.0);
|
|
116
|
+
(width * scale, height * scale)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn points_in_range(points: f64) -> Result<f64> {
|
|
121
|
+
(1.0..=MAX_POINTS)
|
|
122
|
+
.contains(&points)
|
|
123
|
+
.then_some(points)
|
|
124
|
+
.ok_or(Error::InvalidImage("a picture of 1 to 14 400 points a side"))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// A PNG's chunks; interlaced ones are refused.
|
|
128
|
+
fn png(data: &[u8]) -> Result<(Kind, (u32, u32))> {
|
|
129
|
+
let damaged = || Error::InvalidImage("a damaged PNG");
|
|
130
|
+
let mut png = Png {
|
|
131
|
+
depth: 0,
|
|
132
|
+
color: 0,
|
|
133
|
+
palette: Vec::new(),
|
|
134
|
+
transparency: Vec::new(),
|
|
135
|
+
compressed: Vec::new(),
|
|
136
|
+
};
|
|
137
|
+
let (mut pixels, mut at) = (None, 8);
|
|
138
|
+
while let Some(head) = data.get(at..at + 8) {
|
|
139
|
+
let length = u32::from_be_bytes([head[0], head[1], head[2], head[3]]) as usize;
|
|
140
|
+
let body = data.get(at + 8..at + 8 + length).ok_or_else(damaged)?;
|
|
141
|
+
match &head[4..8] {
|
|
142
|
+
b"IHDR" if body.len() == 13 => {
|
|
143
|
+
let side = |at: usize| {
|
|
144
|
+
u32::from_be_bytes([body[at], body[at + 1], body[at + 2], body[at + 3]])
|
|
145
|
+
};
|
|
146
|
+
pixels = Some((side(0), side(4)));
|
|
147
|
+
(png.depth, png.color) = (body[8], body[9]);
|
|
148
|
+
(body[12] == 0).ok_or(Error::InvalidImage("an interlaced PNG"))?;
|
|
149
|
+
}
|
|
150
|
+
b"PLTE" => png.palette = body.to_vec(),
|
|
151
|
+
b"tRNS" => png.transparency = body.to_vec(),
|
|
152
|
+
b"IDAT" => png.compressed.extend_from_slice(body),
|
|
153
|
+
b"IEND" => break,
|
|
154
|
+
_ => {}
|
|
155
|
+
}
|
|
156
|
+
at += 12 + length;
|
|
157
|
+
}
|
|
158
|
+
let layout = matches!(
|
|
159
|
+
(png.color, png.depth),
|
|
160
|
+
(0, 1 | 2 | 4 | 8 | 16) | (3, 1 | 2 | 4 | 8) | (2 | 4 | 6, 8 | 16)
|
|
161
|
+
);
|
|
162
|
+
let palette = png.color != 3 || !png.palette.is_empty();
|
|
163
|
+
let pixels = pixels.filter(|_| layout && palette && !png.compressed.is_empty());
|
|
164
|
+
Ok((Kind::Png(png), pixels.ok_or_else(damaged)?))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// A JPEG's frame header (`SOF`), and Adobe's mark, which inverts CMYK.
|
|
168
|
+
fn jpeg(data: &[u8]) -> Result<(Kind, (u32, u32))> {
|
|
169
|
+
let damaged = || Error::InvalidImage("a damaged JPEG");
|
|
170
|
+
let (mut at, mut inverted) = (2, false);
|
|
171
|
+
loop {
|
|
172
|
+
let &[0xFF, marker, high, low] = data.get(at..at + 4).ok_or_else(damaged)? else {
|
|
173
|
+
return Err(damaged());
|
|
174
|
+
};
|
|
175
|
+
let length = usize::from(u16::from_be_bytes([high, low]));
|
|
176
|
+
if marker == 0xEE && data.get(at + 4..at + 9) == Some(b"Adobe") {
|
|
177
|
+
inverted = true;
|
|
178
|
+
}
|
|
179
|
+
// SOF0 to SOF15, but DHT, JPG and DAC share the range.
|
|
180
|
+
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
|
|
181
|
+
let frame = data.get(at + 4..at + 10).ok_or_else(damaged)?;
|
|
182
|
+
(frame[0] == 8).ok_or(Error::InvalidImage("a JPEG of 8 bits a sample"))?;
|
|
183
|
+
let height = u32::from(u16::from_be_bytes([frame[1], frame[2]]));
|
|
184
|
+
let width = u32::from(u16::from_be_bytes([frame[3], frame[4]]));
|
|
185
|
+
let components = frame[5];
|
|
186
|
+
matches!(components, 1 | 3 | 4).ok_or_else(damaged)?;
|
|
187
|
+
let kind = Kind::Jpeg { components, inverted: inverted && components == 4 };
|
|
188
|
+
return Ok((kind, (width, height)));
|
|
189
|
+
}
|
|
190
|
+
at += 2 + length;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
use crate::script::Counts;
|
|
2
|
+
use crate::style::TextDirection;
|
|
3
|
+
|
|
1
4
|
/// A grid of text cells; columns share the page width equally. Short rows
|
|
2
5
|
/// are padded with empty cells.
|
|
6
|
+
///
|
|
7
|
+
/// Each cell reads in the direction of its first letter, a cell of figures
|
|
8
|
+
/// in the table's; a table mostly right to left has its first column on the
|
|
9
|
+
/// right.
|
|
3
10
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
4
11
|
pub struct Table {
|
|
5
12
|
pub(crate) rows: Vec<Vec<String>>,
|
|
@@ -38,4 +45,18 @@ impl Table {
|
|
|
38
45
|
pub(crate) const fn has_header(&self) -> bool {
|
|
39
46
|
self.header
|
|
40
47
|
}
|
|
48
|
+
|
|
49
|
+
/// The direction most of the table's letters read in; `None` without a
|
|
50
|
+
/// letter.
|
|
51
|
+
pub(crate) fn direction(&self) -> Option<TextDirection> {
|
|
52
|
+
let mut counts = Counts::default();
|
|
53
|
+
self.count(&mut counts);
|
|
54
|
+
counts.direction()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub(crate) fn count(&self, counts: &mut Counts) {
|
|
58
|
+
for cell in self.rows.iter().flatten() {
|
|
59
|
+
counts.add(cell);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
41
62
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
//! ```
|
|
23
23
|
|
|
24
24
|
mod paragraph;
|
|
25
|
+
pub(crate) mod picture;
|
|
25
26
|
mod table;
|
|
26
27
|
#[cfg(test)]
|
|
27
28
|
mod tests;
|
|
@@ -31,24 +32,49 @@ use std::io::{BufWriter, Write};
|
|
|
31
32
|
use std::path::Path;
|
|
32
33
|
|
|
33
34
|
pub use paragraph::{Paragraph, Run};
|
|
35
|
+
pub use picture::Picture;
|
|
34
36
|
pub use table::Table;
|
|
35
37
|
|
|
38
|
+
use crate::script::Counts;
|
|
36
39
|
use crate::{Error, Result};
|
|
37
40
|
|
|
38
|
-
/// Paper size; pages are portrait.
|
|
41
|
+
/// Paper size; A4 and Letter pages are portrait.
|
|
39
42
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
40
43
|
pub enum PageSize {
|
|
41
44
|
#[default]
|
|
42
45
|
A4,
|
|
43
46
|
Letter,
|
|
47
|
+
/// Width and height in hundredths of a point; see [`PageSize::custom`].
|
|
48
|
+
Custom(u32, u32),
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
impl PageSize {
|
|
52
|
+
/// A page of this width and height, in points (1/72 inch): 36 to 1584
|
|
53
|
+
/// (half an inch to 22 inches), as both PDF and Word take them.
|
|
54
|
+
///
|
|
55
|
+
/// # Errors
|
|
56
|
+
/// [`Error::InvalidPageSize`] outside that range.
|
|
57
|
+
pub fn custom(width: f64, height: f64) -> Result<Self> {
|
|
58
|
+
let side = |points: f64| {
|
|
59
|
+
#[expect(
|
|
60
|
+
clippy::cast_possible_truncation,
|
|
61
|
+
clippy::cast_sign_loss,
|
|
62
|
+
reason = "36 to 1584"
|
|
63
|
+
)]
|
|
64
|
+
(36.0..=1584.0).contains(&points).then(|| (points * 100.0).round() as u32)
|
|
65
|
+
};
|
|
66
|
+
side(width)
|
|
67
|
+
.zip(side(height))
|
|
68
|
+
.map(|(width, height)| Self::Custom(width, height))
|
|
69
|
+
.ok_or(Error::InvalidPageSize(width, height))
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/// Width and height in PDF points (1/72 inch).
|
|
48
|
-
pub(crate)
|
|
73
|
+
pub(crate) fn points(self) -> (f64, f64) {
|
|
49
74
|
match self {
|
|
50
75
|
Self::A4 => (595.276, 841.89),
|
|
51
76
|
Self::Letter => (612.0, 792.0),
|
|
77
|
+
Self::Custom(width, height) => (f64::from(width) / 100.0, f64::from(height) / 100.0),
|
|
52
78
|
}
|
|
53
79
|
}
|
|
54
80
|
|
|
@@ -57,6 +83,7 @@ impl PageSize {
|
|
|
57
83
|
match self {
|
|
58
84
|
Self::A4 => (11_906, 16_838),
|
|
59
85
|
Self::Letter => (12_240, 15_840),
|
|
86
|
+
Self::Custom(width, height) => (width / 5, height / 5),
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
}
|
|
@@ -68,6 +95,7 @@ pub enum Block {
|
|
|
68
95
|
Heading { level: u8, text: String },
|
|
69
96
|
Paragraph(Paragraph),
|
|
70
97
|
Table(Table),
|
|
98
|
+
Picture(Picture),
|
|
71
99
|
PageBreak,
|
|
72
100
|
}
|
|
73
101
|
|
|
@@ -78,6 +106,8 @@ pub struct Document {
|
|
|
78
106
|
blocks: Vec<Block>,
|
|
79
107
|
page_size: PageSize,
|
|
80
108
|
page_numbers: bool,
|
|
109
|
+
/// Hundredths of a point; one inch when unset.
|
|
110
|
+
margin: Option<u32>,
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
impl Document {
|
|
@@ -91,6 +121,43 @@ impl Document {
|
|
|
91
121
|
self
|
|
92
122
|
}
|
|
93
123
|
|
|
124
|
+
/// The margin on every side, in points: one inch (72) unless set.
|
|
125
|
+
///
|
|
126
|
+
/// # Errors
|
|
127
|
+
/// [`Error::InvalidMargin`] outside 0 to 144 points.
|
|
128
|
+
pub fn set_margin(&mut self, points: f64) -> Result<&mut Self> {
|
|
129
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "0 to 144")]
|
|
130
|
+
let margin = (0.0..=144.0)
|
|
131
|
+
.contains(&points)
|
|
132
|
+
.then(|| (points * 100.0).round() as u32)
|
|
133
|
+
.ok_or(Error::InvalidMargin(points))?;
|
|
134
|
+
self.margin = Some(margin);
|
|
135
|
+
Ok(self)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The margin on every side, in points.
|
|
139
|
+
#[must_use]
|
|
140
|
+
pub fn margin(&self) -> f64 {
|
|
141
|
+
self.margin.map_or(72.0, |margin| f64::from(margin) / 100.0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// The width and height between the margins, in points.
|
|
145
|
+
///
|
|
146
|
+
/// # Errors
|
|
147
|
+
/// [`Error::InvalidMargin`] when the margins leave no room.
|
|
148
|
+
pub(crate) fn room(&self) -> Result<(f64, f64)> {
|
|
149
|
+
let (width, height) = self.page_size.points();
|
|
150
|
+
let margin = self.margin();
|
|
151
|
+
let room = (2.0f64.mul_add(-margin, width), 2.0f64.mul_add(-margin, height));
|
|
152
|
+
(room.0 >= 1.0 && room.1 >= 1.0).then_some(room).ok_or(Error::InvalidMargin(margin))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// A picture, in the flow of the text.
|
|
156
|
+
pub fn picture(&mut self, picture: Picture) -> &mut Self {
|
|
157
|
+
self.blocks.push(Block::Picture(picture));
|
|
158
|
+
self
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/// Numbers pages `1 / 3` at the bottom of each one.
|
|
95
162
|
pub const fn set_page_numbers(&mut self, enabled: bool) -> &mut Self {
|
|
96
163
|
self.page_numbers = enabled;
|
|
@@ -144,6 +211,23 @@ impl Document {
|
|
|
144
211
|
self
|
|
145
212
|
}
|
|
146
213
|
|
|
214
|
+
/// Whether most of the document's letters are right to left: its pages
|
|
215
|
+
/// are then laid out right to left.
|
|
216
|
+
pub(crate) fn reads_right_to_left(&self) -> bool {
|
|
217
|
+
let mut counts = Counts::default();
|
|
218
|
+
for block in &self.blocks {
|
|
219
|
+
match block {
|
|
220
|
+
Block::Heading { text, .. } => counts.add(text),
|
|
221
|
+
Block::Paragraph(paragraph) => {
|
|
222
|
+
paragraph.runs.iter().for_each(|run| counts.add(&run.text));
|
|
223
|
+
}
|
|
224
|
+
Block::Table(table) => table.count(&mut counts),
|
|
225
|
+
Block::Picture(_) | Block::PageBreak => {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
counts.right_to_left()
|
|
229
|
+
}
|
|
230
|
+
|
|
147
231
|
#[must_use]
|
|
148
232
|
pub fn blocks(&self) -> &[Block] {
|
|
149
233
|
&self.blocks
|