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,233 @@
|
|
|
1
|
+
//! Images found in documents, as files: what PDFs store as pixels becomes
|
|
2
|
+
//! PNG (or TIFF, for fax-compressed scans); JPEG and JPEG 2000 are kept as
|
|
3
|
+
//! they are; Office documents' media are copied.
|
|
4
|
+
|
|
5
|
+
mod dimensions;
|
|
6
|
+
#[cfg(test)]
|
|
7
|
+
mod tests;
|
|
8
|
+
|
|
9
|
+
use super::content::Bounds;
|
|
10
|
+
|
|
11
|
+
/// An image, encoded as a file of its format.
|
|
12
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
13
|
+
pub struct Image {
|
|
14
|
+
pub data: Vec<u8>,
|
|
15
|
+
pub format: ImageFormat,
|
|
16
|
+
pub width: Option<u32>,
|
|
17
|
+
pub height: Option<u32>,
|
|
18
|
+
pub location: ImageLocation,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Where an image was found.
|
|
22
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
23
|
+
#[non_exhaustive]
|
|
24
|
+
pub enum ImageLocation {
|
|
25
|
+
/// On a PDF page (the first that draws it), placed at `bounds`.
|
|
26
|
+
Page { number: u32, bounds: Option<Bounds> },
|
|
27
|
+
/// A part of a Word or Excel package, and for a workbook the sheet that
|
|
28
|
+
/// shows it.
|
|
29
|
+
Part { name: String, sheet: Option<String> },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
33
|
+
#[non_exhaustive]
|
|
34
|
+
pub enum ImageFormat {
|
|
35
|
+
Png,
|
|
36
|
+
Jpeg,
|
|
37
|
+
Jpeg2000,
|
|
38
|
+
Tiff,
|
|
39
|
+
Gif,
|
|
40
|
+
Bmp,
|
|
41
|
+
Webp,
|
|
42
|
+
Svg,
|
|
43
|
+
Emf,
|
|
44
|
+
Wmf,
|
|
45
|
+
/// A PDF's JBIG2 stream, globals first: viewers do not open it, a JBIG2
|
|
46
|
+
/// decoder in embedded mode does.
|
|
47
|
+
Jbig2,
|
|
48
|
+
Other,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
impl ImageFormat {
|
|
52
|
+
#[must_use]
|
|
53
|
+
pub const fn extension(self) -> &'static str {
|
|
54
|
+
match self {
|
|
55
|
+
Self::Png => "png",
|
|
56
|
+
Self::Jpeg => "jpg",
|
|
57
|
+
Self::Jpeg2000 => "jp2",
|
|
58
|
+
Self::Tiff => "tif",
|
|
59
|
+
Self::Gif => "gif",
|
|
60
|
+
Self::Bmp => "bmp",
|
|
61
|
+
Self::Webp => "webp",
|
|
62
|
+
Self::Svg => "svg",
|
|
63
|
+
Self::Emf => "emf",
|
|
64
|
+
Self::Wmf => "wmf",
|
|
65
|
+
Self::Jbig2 => "jb2",
|
|
66
|
+
Self::Other => "bin",
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The format of a file, by its first bytes, or by its name's
|
|
71
|
+
/// extension when they say nothing.
|
|
72
|
+
pub(crate) fn sniff(data: &[u8], name: &str) -> Self {
|
|
73
|
+
match data {
|
|
74
|
+
[0x89, b'P', b'N', b'G', ..] => Self::Png,
|
|
75
|
+
[0xFF, 0xD8, ..] => Self::Jpeg,
|
|
76
|
+
[b'G', b'I', b'F', b'8', ..] => Self::Gif,
|
|
77
|
+
[b'B', b'M', ..] => Self::Bmp,
|
|
78
|
+
[b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] => Self::Tiff,
|
|
79
|
+
[b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Self::Webp,
|
|
80
|
+
[0xD7, 0xCD, 0xC6, 0x9A, ..] => Self::Wmf,
|
|
81
|
+
[0, 0, 0, 0x0C, b'j', b'P', ..] | [0xFF, 0x4F, 0xFF, 0x51, ..] => Self::Jpeg2000,
|
|
82
|
+
_ if data.get(40..44) == Some(b" EMF") => Self::Emf,
|
|
83
|
+
_ if is_svg(data) => Self::Svg,
|
|
84
|
+
_ => name.rsplit_once('.').map_or(Self::Other, |(_, extension)| {
|
|
85
|
+
match extension.to_ascii_lowercase().as_str() {
|
|
86
|
+
"svg" => Self::Svg,
|
|
87
|
+
"emf" => Self::Emf,
|
|
88
|
+
"wmf" => Self::Wmf,
|
|
89
|
+
_ => Self::Other,
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Width and height, read from the file's header where the format puts
|
|
96
|
+
/// them up front.
|
|
97
|
+
pub(crate) fn dimensions(self, data: &[u8]) -> Option<(u32, u32)> {
|
|
98
|
+
dimensions::dimensions(self, data)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Whether a file is SVG markup: an `<svg` element among its first bytes,
|
|
103
|
+
/// after an XML declaration, comments or a doctype at most.
|
|
104
|
+
fn is_svg(data: &[u8]) -> bool {
|
|
105
|
+
let head = &data[..data.len().min(1024)];
|
|
106
|
+
let head = head.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(head).trim_ascii_start();
|
|
107
|
+
head.starts_with(b"<")
|
|
108
|
+
&& !head.starts_with(b"<html")
|
|
109
|
+
&& head.windows(4).any(|window| window == b"<svg")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// PNG color types (ISO/IEC 15948, 11.2.2).
|
|
113
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
114
|
+
pub enum PngColor {
|
|
115
|
+
Gray = 0,
|
|
116
|
+
Rgb = 2,
|
|
117
|
+
Palette = 3,
|
|
118
|
+
GrayAlpha = 4,
|
|
119
|
+
Rgba = 6,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// A PNG of `rows` packed pixels, `bits` per sample, each row starting on a
|
|
123
|
+
/// byte. `palette` is RGB triples, for [`PngColor::Palette`].
|
|
124
|
+
pub fn png(
|
|
125
|
+
width: u32,
|
|
126
|
+
height: u32,
|
|
127
|
+
color: PngColor,
|
|
128
|
+
bits: u8,
|
|
129
|
+
palette: Option<&[u8]>,
|
|
130
|
+
rows: &[u8],
|
|
131
|
+
) -> Vec<u8> {
|
|
132
|
+
let channels: usize = match color {
|
|
133
|
+
PngColor::Gray | PngColor::Palette => 1,
|
|
134
|
+
PngColor::GrayAlpha => 2,
|
|
135
|
+
PngColor::Rgb => 3,
|
|
136
|
+
PngColor::Rgba => 4,
|
|
137
|
+
};
|
|
138
|
+
let stride = (width as usize * channels * usize::from(bits)).div_ceil(8);
|
|
139
|
+
let mut filtered = Vec::with_capacity((stride + 1) * height as usize);
|
|
140
|
+
// Short data, as damaged files have, is padded with zero rows.
|
|
141
|
+
for row in 0..height as usize {
|
|
142
|
+
filtered.push(0);
|
|
143
|
+
let line = rows.get(row * stride..).unwrap_or_default();
|
|
144
|
+
let line = &line[..line.len().min(stride)];
|
|
145
|
+
filtered.extend_from_slice(line);
|
|
146
|
+
filtered.resize(filtered.len() + stride - line.len(), 0);
|
|
147
|
+
}
|
|
148
|
+
let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&filtered, 6);
|
|
149
|
+
let mut out = Vec::with_capacity(compressed.len() + 128);
|
|
150
|
+
out.extend_from_slice(b"\x89PNG\r\n\x1a\n");
|
|
151
|
+
let mut header = [0_u8; 13];
|
|
152
|
+
header[..4].copy_from_slice(&width.to_be_bytes());
|
|
153
|
+
header[4..8].copy_from_slice(&height.to_be_bytes());
|
|
154
|
+
header[8] = bits;
|
|
155
|
+
header[9] = color as u8;
|
|
156
|
+
chunk(&mut out, *b"IHDR", &header);
|
|
157
|
+
if let Some(palette) = palette {
|
|
158
|
+
chunk(&mut out, *b"PLTE", palette);
|
|
159
|
+
}
|
|
160
|
+
chunk(&mut out, *b"IDAT", &compressed);
|
|
161
|
+
chunk(&mut out, *b"IEND", &[]);
|
|
162
|
+
out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn chunk(out: &mut Vec<u8>, kind: [u8; 4], data: &[u8]) {
|
|
166
|
+
out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes());
|
|
167
|
+
let start = out.len();
|
|
168
|
+
out.extend_from_slice(&kind);
|
|
169
|
+
out.extend_from_slice(data);
|
|
170
|
+
let crc = crc32fast::hash(&out[start..]);
|
|
171
|
+
out.extend_from_slice(&crc.to_be_bytes());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// How a fax-compressed image is coded (`CCITTFaxDecode`'s `K`).
|
|
175
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
176
|
+
pub enum Fax {
|
|
177
|
+
Group3OneDimensional,
|
|
178
|
+
Group3TwoDimensional,
|
|
179
|
+
Group4,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// A one-strip TIFF around CCITT fax data, as PDFs store scans.
|
|
183
|
+
/// `black_is_one` is whether 1 bits are black (`BlackIs1`, after `Decode`).
|
|
184
|
+
pub fn tiff(
|
|
185
|
+
width: u32,
|
|
186
|
+
height: u32,
|
|
187
|
+
fax: Fax,
|
|
188
|
+
black_is_one: bool,
|
|
189
|
+
byte_aligned: bool,
|
|
190
|
+
data: &[u8],
|
|
191
|
+
) -> Vec<u8> {
|
|
192
|
+
let (compression, options_tag, options) = match fax {
|
|
193
|
+
Fax::Group3OneDimensional => (3, 292, u32::from(byte_aligned) << 2),
|
|
194
|
+
Fax::Group3TwoDimensional => (3, 292, 1 | u32::from(byte_aligned) << 2),
|
|
195
|
+
Fax::Group4 => (4, 293, 0),
|
|
196
|
+
};
|
|
197
|
+
// TIFF photometric: 0 is WhiteIsZero, 1 is BlackIsZero.
|
|
198
|
+
let photometric = u32::from(!black_is_one);
|
|
199
|
+
let entries: [(u16, u16, u32); 10] = [
|
|
200
|
+
(256, 4, width),
|
|
201
|
+
(257, 4, height),
|
|
202
|
+
(258, 3, 1),
|
|
203
|
+
(259, 3, compression),
|
|
204
|
+
(262, 3, photometric),
|
|
205
|
+
(273, 4, 0), // strip offset, set below
|
|
206
|
+
(277, 3, 1),
|
|
207
|
+
(278, 4, height),
|
|
208
|
+
(279, 4, u32::try_from(data.len()).unwrap_or(u32::MAX)),
|
|
209
|
+
(options_tag, 4, options),
|
|
210
|
+
];
|
|
211
|
+
let directory = 8;
|
|
212
|
+
let strip = directory + 2 + entries.len() * 12 + 4;
|
|
213
|
+
let mut out = Vec::with_capacity(strip + data.len());
|
|
214
|
+
out.extend_from_slice(b"II*\0");
|
|
215
|
+
out.extend_from_slice(&u32::try_from(directory).unwrap_or(0).to_le_bytes());
|
|
216
|
+
out.extend_from_slice(&u16::try_from(entries.len()).unwrap_or(0).to_le_bytes());
|
|
217
|
+
for (tag, kind, value) in entries {
|
|
218
|
+
let value = if tag == 273 { u32::try_from(strip).unwrap_or(0) } else { value };
|
|
219
|
+
out.extend_from_slice(&tag.to_le_bytes());
|
|
220
|
+
out.extend_from_slice(&kind.to_le_bytes());
|
|
221
|
+
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
222
|
+
// A SHORT sits in the first two bytes of the value field.
|
|
223
|
+
if kind == 3 {
|
|
224
|
+
out.extend_from_slice(&u16::try_from(value).unwrap_or(0).to_le_bytes());
|
|
225
|
+
out.extend_from_slice(&[0, 0]);
|
|
226
|
+
} else {
|
|
227
|
+
out.extend_from_slice(&value.to_le_bytes());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
231
|
+
out.extend_from_slice(data);
|
|
232
|
+
out
|
|
233
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//! Deflate, for zip entries and PDF streams alike: into one buffer sized
|
|
2
|
+
//! up front (exactly, when the size is known), grown only past a guess,
|
|
3
|
+
//! never past the budget. What a damaged stream yields before the damage is
|
|
4
|
+
//! kept; the caller decides whether that is enough.
|
|
5
|
+
|
|
6
|
+
use miniz_oxide::inflate::TINFLStatus;
|
|
7
|
+
use miniz_oxide::inflate::core::{DecompressorOxide, decompress, inflate_flags};
|
|
8
|
+
|
|
9
|
+
use crate::{Error, Result};
|
|
10
|
+
|
|
11
|
+
/// Inflates `data`, zlib-wrapped or raw, starting with room for `expected`
|
|
12
|
+
/// bytes and growing to `limit` at most. Returns the output and whether the
|
|
13
|
+
/// stream ended properly. Checksums are not verified: PDF writers get them
|
|
14
|
+
/// wrong, and zip entries have their CRC checked by the caller.
|
|
15
|
+
pub fn inflate(data: &[u8], zlib: bool, expected: usize, limit: usize) -> Result<(Vec<u8>, bool)> {
|
|
16
|
+
let flags = inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF
|
|
17
|
+
| inflate_flags::TINFL_FLAG_IGNORE_ADLER32
|
|
18
|
+
| if zlib { inflate_flags::TINFL_FLAG_PARSE_ZLIB_HEADER } else { 0 };
|
|
19
|
+
let mut decompressor = Box::<DecompressorOxide>::default();
|
|
20
|
+
let mut out = vec![0_u8; expected.clamp(1, limit.max(1))];
|
|
21
|
+
let (mut read, mut written) = (0, 0);
|
|
22
|
+
loop {
|
|
23
|
+
let (status, consumed, produced) =
|
|
24
|
+
decompress(&mut decompressor, &data[read..], &mut out, written, flags);
|
|
25
|
+
read += consumed;
|
|
26
|
+
written += produced;
|
|
27
|
+
match status {
|
|
28
|
+
TINFLStatus::Done => {
|
|
29
|
+
out.truncate(written);
|
|
30
|
+
return Ok((out, true));
|
|
31
|
+
}
|
|
32
|
+
TINFLStatus::HasMoreOutput => {
|
|
33
|
+
if out.len() >= limit {
|
|
34
|
+
return Err(Error::LimitExceeded("decompressed size"));
|
|
35
|
+
}
|
|
36
|
+
out.resize(out.len().saturating_mul(2).min(limit), 0);
|
|
37
|
+
}
|
|
38
|
+
_ => {
|
|
39
|
+
out.truncate(written);
|
|
40
|
+
return Ok((out, false));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//! Content and images as JSON.
|
|
2
|
+
//!
|
|
3
|
+
//! One schema for every caller: the command line's `--json` and the Ruby
|
|
4
|
+
//! binding. Offsets are byte offsets into the UTF-8 text they belong to;
|
|
5
|
+
//! positions are in points from a page's top-left corner.
|
|
6
|
+
|
|
7
|
+
#[cfg(test)]
|
|
8
|
+
mod tests;
|
|
9
|
+
|
|
10
|
+
use serde_json::{Map, Value as Json, json};
|
|
11
|
+
|
|
12
|
+
use super::{Block, Bounds, Cell, Content, Grid, Image, ImageLocation, Mark, Page, Sheet, Value};
|
|
13
|
+
|
|
14
|
+
/// The document's content, by format: `pages`, `sheets` or `blocks`.
|
|
15
|
+
#[must_use]
|
|
16
|
+
pub fn content(content: &Content) -> Map<String, Json> {
|
|
17
|
+
let mut object = Map::new();
|
|
18
|
+
object.insert("format".into(), content.format().as_str().into());
|
|
19
|
+
match content {
|
|
20
|
+
Content::Pdf(pages) => object.insert("pages".into(), pages.iter().map(page).collect()),
|
|
21
|
+
Content::Xlsx(sheets) => object.insert("sheets".into(), sheets.iter().map(sheet).collect()),
|
|
22
|
+
Content::Docx(blocks) => object.insert("blocks".into(), blocks.iter().map(block).collect()),
|
|
23
|
+
};
|
|
24
|
+
object
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn page(page: &Page) -> Json {
|
|
28
|
+
let blocks: Vec<Json> = page
|
|
29
|
+
.blocks
|
|
30
|
+
.iter()
|
|
31
|
+
.map(|placed| {
|
|
32
|
+
let mut object = block(&placed.block);
|
|
33
|
+
if let (Some(bounds), Json::Object(fields)) = (placed.bounds, &mut object) {
|
|
34
|
+
fields.insert("bounds".into(), self::bounds(bounds));
|
|
35
|
+
}
|
|
36
|
+
object
|
|
37
|
+
})
|
|
38
|
+
.collect();
|
|
39
|
+
json!({ "page": page.number, "images": page.images, "blocks": blocks })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// An image's description, its data left out: `format`, `width`, `height`,
|
|
43
|
+
/// `bytes`, and where it is: `page` and `bounds`, or `part` and `sheet`.
|
|
44
|
+
#[must_use]
|
|
45
|
+
pub fn image(image: &Image) -> Map<String, Json> {
|
|
46
|
+
let mut object = Map::new();
|
|
47
|
+
object.insert("format".into(), image.format.extension().into());
|
|
48
|
+
object.insert("width".into(), image.width.into());
|
|
49
|
+
object.insert("height".into(), image.height.into());
|
|
50
|
+
object.insert("bytes".into(), image.data.len().into());
|
|
51
|
+
match &image.location {
|
|
52
|
+
ImageLocation::Page { number, bounds } => {
|
|
53
|
+
object.insert("page".into(), (*number).into());
|
|
54
|
+
if let Some(bounds) = bounds {
|
|
55
|
+
object.insert("bounds".into(), self::bounds(*bounds));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ImageLocation::Part { name, sheet } => {
|
|
59
|
+
object.insert("part".into(), name.as_str().into());
|
|
60
|
+
if let Some(sheet) = sheet {
|
|
61
|
+
object.insert("sheet".into(), sheet.as_str().into());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
object
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// A rectangle, rounded to hundredths of a point.
|
|
69
|
+
#[must_use]
|
|
70
|
+
pub fn bounds(bounds: Bounds) -> Json {
|
|
71
|
+
let round = |value: f64| (value * 100.0).round() / 100.0;
|
|
72
|
+
json!({
|
|
73
|
+
"left": round(bounds.left),
|
|
74
|
+
"top": round(bounds.top),
|
|
75
|
+
"right": round(bounds.right),
|
|
76
|
+
"bottom": round(bounds.bottom),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn block(block: &Block) -> Json {
|
|
81
|
+
match block {
|
|
82
|
+
Block::Heading { level, text } => {
|
|
83
|
+
json!({ "type": "heading", "level": level, "text": text.text })
|
|
84
|
+
}
|
|
85
|
+
Block::Paragraph { text } => {
|
|
86
|
+
with_marks(json!({ "type": "paragraph", "text": text.text }), &text.marks)
|
|
87
|
+
}
|
|
88
|
+
Block::ListItem { level, ordered, text } => with_marks(
|
|
89
|
+
json!({ "type": "list_item", "level": level, "ordered": ordered, "text": text.text }),
|
|
90
|
+
&text.marks,
|
|
91
|
+
),
|
|
92
|
+
Block::Table(grid) => json!({ "type": "table", "rows": rows(grid, false) }),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn with_marks(mut object: Json, marks: &[Mark]) -> Json {
|
|
97
|
+
if !marks.is_empty()
|
|
98
|
+
&& let Json::Object(fields) = &mut object
|
|
99
|
+
{
|
|
100
|
+
let marks = marks
|
|
101
|
+
.iter()
|
|
102
|
+
.map(|mark| {
|
|
103
|
+
json!({
|
|
104
|
+
"start": mark.range.start,
|
|
105
|
+
"end": mark.range.end,
|
|
106
|
+
"bold": mark.bold,
|
|
107
|
+
"italic": mark.italic,
|
|
108
|
+
"link": mark.link,
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
.collect();
|
|
112
|
+
fields.insert("marks".into(), marks);
|
|
113
|
+
}
|
|
114
|
+
object
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn sheet(sheet: &Sheet) -> Json {
|
|
118
|
+
let comments: Vec<Json> = sheet
|
|
119
|
+
.comments
|
|
120
|
+
.iter()
|
|
121
|
+
.map(|note| {
|
|
122
|
+
let author = note.author.as_deref();
|
|
123
|
+
json!({ "cell": note.reference(), "author": author, "text": note.text })
|
|
124
|
+
})
|
|
125
|
+
.collect();
|
|
126
|
+
json!({
|
|
127
|
+
"name": sheet.name,
|
|
128
|
+
"hidden": sheet.hidden,
|
|
129
|
+
"rows": rows(&sheet.cells, true),
|
|
130
|
+
"comments": comments,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Rows of cells, numbered from 1 in a sheet (where cells are named `B2`)
|
|
135
|
+
/// and from 0 in a Word table (where they are numbered by column).
|
|
136
|
+
fn rows(grid: &Grid, sheet: bool) -> Json {
|
|
137
|
+
grid.rows()
|
|
138
|
+
.map(|cells| {
|
|
139
|
+
let row = cells.first().map_or(0, |cell| cell.row);
|
|
140
|
+
let cells: Vec<Json> = cells.iter().map(|cell| self::cell(grid, cell, sheet)).collect();
|
|
141
|
+
if sheet {
|
|
142
|
+
json!({ "row": row + 1, "cells": cells })
|
|
143
|
+
} else {
|
|
144
|
+
json!({ "row": row, "cells": cells })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
.collect()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fn cell(grid: &Grid, cell: &Cell, sheet: bool) -> Json {
|
|
151
|
+
let text = grid.text(cell);
|
|
152
|
+
let (kind, value) = match cell.value {
|
|
153
|
+
Value::Number(number) => ("number", json!(number)),
|
|
154
|
+
Value::Bool(on) => ("bool", json!(on)),
|
|
155
|
+
Value::Date(date) => ("date", json!(date.to_string().replace(' ', "T"))),
|
|
156
|
+
Value::Error => ("error", json!(text)),
|
|
157
|
+
Value::Formula => ("formula", json!(text)),
|
|
158
|
+
_ => ("text", json!(text)),
|
|
159
|
+
};
|
|
160
|
+
let mut object = Map::new();
|
|
161
|
+
if sheet {
|
|
162
|
+
object.insert("cell".into(), cell.reference().into());
|
|
163
|
+
} else {
|
|
164
|
+
object.insert("column".into(), cell.column.into());
|
|
165
|
+
}
|
|
166
|
+
object.insert("type".into(), kind.into());
|
|
167
|
+
object.insert("value".into(), value);
|
|
168
|
+
if !matches!(kind, "text" | "error" | "formula") {
|
|
169
|
+
object.insert("text".into(), text.into());
|
|
170
|
+
}
|
|
171
|
+
Json::Object(object)
|
|
172
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
//! Content as Markdown. Escaping is minimal on purpose: the text feeds
|
|
2
|
+
//! search indexes and language models, where `snake\_case` would be noise.
|
|
3
|
+
//! Only what would break the structure is escaped: pipes and line breaks
|
|
4
|
+
//! inside table cells, and a paragraph that would read as a heading or a
|
|
5
|
+
//! quote.
|
|
6
|
+
|
|
7
|
+
#[cfg(test)]
|
|
8
|
+
mod tests;
|
|
9
|
+
|
|
10
|
+
use std::fmt::Write as _;
|
|
11
|
+
|
|
12
|
+
use super::content::{Block, Cell, Content, Grid, Page, Sheet, Text, push_reference};
|
|
13
|
+
|
|
14
|
+
/// The deepest Markdown heading.
|
|
15
|
+
const MAX_HEADING: u8 = 6;
|
|
16
|
+
|
|
17
|
+
impl Content {
|
|
18
|
+
/// The content as Markdown: a PDF's pages (from the second, marked
|
|
19
|
+
/// `<!-- page N -->`), each sheet of a workbook under its name, a Word
|
|
20
|
+
/// document's blocks.
|
|
21
|
+
#[must_use]
|
|
22
|
+
pub fn to_markdown(&self) -> String {
|
|
23
|
+
let mut out = String::new();
|
|
24
|
+
match self {
|
|
25
|
+
Self::Pdf(pages) => pdf(&mut out, pages),
|
|
26
|
+
Self::Xlsx(sheets) => {
|
|
27
|
+
for sheet in sheets {
|
|
28
|
+
write_sheet(&mut out, sheet);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
Self::Docx(blocks) => write_blocks(&mut out, blocks.iter(), Mode::Body),
|
|
32
|
+
}
|
|
33
|
+
out
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// How a block's text is written.
|
|
38
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
39
|
+
pub enum Mode {
|
|
40
|
+
/// Emphasis and links.
|
|
41
|
+
Body,
|
|
42
|
+
/// Links only: emphasis in a table cell is mostly a bold header row.
|
|
43
|
+
Cell,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn pdf(out: &mut String, pages: &[Page]) {
|
|
47
|
+
for page in pages.iter().filter(|page| !page.blocks.is_empty()) {
|
|
48
|
+
if page.number > 1 {
|
|
49
|
+
end_block(out);
|
|
50
|
+
// Writing to a String cannot fail.
|
|
51
|
+
let _ = writeln!(out, "<!-- page {} -->", page.number);
|
|
52
|
+
}
|
|
53
|
+
write_blocks(out, page.blocks.iter().map(|placed| &placed.block), Mode::Body);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn write_sheet(out: &mut String, sheet: &Sheet) {
|
|
58
|
+
end_block(out);
|
|
59
|
+
out.push_str("## ");
|
|
60
|
+
out.push_str(&sheet.name);
|
|
61
|
+
if sheet.hidden {
|
|
62
|
+
out.push_str(" (hidden)");
|
|
63
|
+
}
|
|
64
|
+
out.push_str("\n\n");
|
|
65
|
+
if sheet.cells.is_empty() {
|
|
66
|
+
out.push_str("_Empty._\n");
|
|
67
|
+
} else {
|
|
68
|
+
write_table(out, &sheet.cells);
|
|
69
|
+
}
|
|
70
|
+
for (index, note) in sheet.comments.iter().enumerate() {
|
|
71
|
+
if index == 0 {
|
|
72
|
+
end_block(out);
|
|
73
|
+
out.push_str("Comments:\n\n");
|
|
74
|
+
}
|
|
75
|
+
out.push_str("- ");
|
|
76
|
+
push_reference(out, note.row, note.column);
|
|
77
|
+
if let Some(author) = ¬e.author {
|
|
78
|
+
out.push_str(" (");
|
|
79
|
+
out.push_str(author);
|
|
80
|
+
out.push(')');
|
|
81
|
+
}
|
|
82
|
+
out.push_str(": ");
|
|
83
|
+
for (index, line) in note.text.lines().enumerate() {
|
|
84
|
+
if index > 0 {
|
|
85
|
+
out.push_str("\n ");
|
|
86
|
+
}
|
|
87
|
+
out.push_str(line.trim_end());
|
|
88
|
+
}
|
|
89
|
+
out.push('\n');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Blocks in order: consecutive list items form one list; everything else
|
|
94
|
+
/// is a block of its own.
|
|
95
|
+
pub fn write_blocks<'b>(out: &mut String, blocks: impl Iterator<Item = &'b Block>, mode: Mode) {
|
|
96
|
+
let mut in_list = false;
|
|
97
|
+
for block in blocks {
|
|
98
|
+
match block {
|
|
99
|
+
Block::Heading { level, text } => {
|
|
100
|
+
end_block(out);
|
|
101
|
+
for _ in 0..(*level).clamp(1, MAX_HEADING) {
|
|
102
|
+
out.push('#');
|
|
103
|
+
}
|
|
104
|
+
out.push(' ');
|
|
105
|
+
out.extend(text.text.chars().map(|ch| if ch == '\n' { ' ' } else { ch }));
|
|
106
|
+
out.push('\n');
|
|
107
|
+
}
|
|
108
|
+
Block::Paragraph { text } => {
|
|
109
|
+
end_block(out);
|
|
110
|
+
if text.text.starts_with(['#', '>']) {
|
|
111
|
+
out.push('\\');
|
|
112
|
+
}
|
|
113
|
+
write_text(out, text, mode, "");
|
|
114
|
+
out.push('\n');
|
|
115
|
+
}
|
|
116
|
+
Block::ListItem { level, ordered, text } => {
|
|
117
|
+
if !in_list {
|
|
118
|
+
end_block(out);
|
|
119
|
+
}
|
|
120
|
+
let indent = " ".repeat(usize::from(*level));
|
|
121
|
+
let marker = if *ordered { "1. " } else { "- " };
|
|
122
|
+
out.push_str(&indent);
|
|
123
|
+
out.push_str(marker);
|
|
124
|
+
let continuation = format!("{indent}{}", " ".repeat(marker.len()));
|
|
125
|
+
write_text(out, text, mode, &continuation);
|
|
126
|
+
out.push('\n');
|
|
127
|
+
}
|
|
128
|
+
Block::Table(grid) => {
|
|
129
|
+
end_block(out);
|
|
130
|
+
write_table(out, grid);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
in_list = matches!(block, Block::ListItem { .. });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// A cell's blocks as one cell of a table: links kept, emphasis left out,
|
|
138
|
+
/// each line on its own, blank lines dropped.
|
|
139
|
+
pub fn cell_text(blocks: &[Block], out: &mut String) {
|
|
140
|
+
let mut rendered = String::new();
|
|
141
|
+
write_blocks(&mut rendered, blocks.iter(), Mode::Cell);
|
|
142
|
+
for line in rendered.lines().filter(|line| !line.trim().is_empty()) {
|
|
143
|
+
if !out.is_empty() {
|
|
144
|
+
out.push('\n');
|
|
145
|
+
}
|
|
146
|
+
out.push_str(line);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Text with its marks; line breaks continue at `indent`, as a list item's
|
|
151
|
+
/// must. Whitespace at the edges of a marked stretch stays outside it.
|
|
152
|
+
fn write_text(out: &mut String, text: &Text, mode: Mode, indent: &str) {
|
|
153
|
+
let mut at = 0;
|
|
154
|
+
for mark in &text.marks {
|
|
155
|
+
push_lines(out, &text.text[at..mark.range.start], indent);
|
|
156
|
+
let stretch = &text.text[mark.range.clone()];
|
|
157
|
+
at = mark.range.end;
|
|
158
|
+
let core = stretch.trim();
|
|
159
|
+
if core.is_empty() {
|
|
160
|
+
push_lines(out, stretch, indent);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
push_lines(out, &stretch[..stretch.len() - stretch.trim_start().len()], indent);
|
|
164
|
+
let emphasis = mode == Mode::Body;
|
|
165
|
+
let marker = match (mark.bold && emphasis, mark.italic && emphasis) {
|
|
166
|
+
(true, true) => "***",
|
|
167
|
+
(true, false) => "**",
|
|
168
|
+
(false, true) => "*",
|
|
169
|
+
(false, false) => "",
|
|
170
|
+
};
|
|
171
|
+
if mark.link.is_some() {
|
|
172
|
+
out.push('[');
|
|
173
|
+
}
|
|
174
|
+
out.push_str(marker);
|
|
175
|
+
push_lines(out, core, indent);
|
|
176
|
+
out.push_str(marker);
|
|
177
|
+
if let Some(url) = &mark.link {
|
|
178
|
+
out.push_str("](");
|
|
179
|
+
out.push_str(url);
|
|
180
|
+
out.push(')');
|
|
181
|
+
}
|
|
182
|
+
push_lines(out, &stretch[stretch.trim_end().len()..], indent);
|
|
183
|
+
}
|
|
184
|
+
push_lines(out, &text.text[at..], indent);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fn push_lines(out: &mut String, text: &str, indent: &str) {
|
|
188
|
+
for (index, line) in text.split('\n').enumerate() {
|
|
189
|
+
if index > 0 {
|
|
190
|
+
out.push('\n');
|
|
191
|
+
out.push_str(indent);
|
|
192
|
+
}
|
|
193
|
+
out.push_str(line);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// A grid as a table: its first row as the header, rows without cells and
|
|
198
|
+
/// columns without any left out.
|
|
199
|
+
pub fn write_table(out: &mut String, grid: &Grid) {
|
|
200
|
+
let Some(last) = grid.cells().iter().map(|cell| cell.column).max() else { return };
|
|
201
|
+
let mut used = vec![false; last as usize + 1];
|
|
202
|
+
for cell in grid.cells() {
|
|
203
|
+
used[cell.column as usize] = true;
|
|
204
|
+
}
|
|
205
|
+
let columns: Vec<u32> = (0..=last).filter(|&column| used[column as usize]).collect();
|
|
206
|
+
let mut rows = grid.rows();
|
|
207
|
+
let Some(header) = rows.next() else { return };
|
|
208
|
+
write_row(out, grid, header, &columns);
|
|
209
|
+
out.push('|');
|
|
210
|
+
for _ in &columns {
|
|
211
|
+
out.push_str(" --- |");
|
|
212
|
+
}
|
|
213
|
+
out.push('\n');
|
|
214
|
+
for row in rows {
|
|
215
|
+
write_row(out, grid, row, &columns);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn write_row(out: &mut String, grid: &Grid, cells: &[Cell], columns: &[u32]) {
|
|
220
|
+
out.push('|');
|
|
221
|
+
let mut cells = cells.iter().peekable();
|
|
222
|
+
for &column in columns {
|
|
223
|
+
out.push(' ');
|
|
224
|
+
if let Some(cell) = cells.next_if(|cell| cell.column == column) {
|
|
225
|
+
push_cell(out, grid.text(cell));
|
|
226
|
+
}
|
|
227
|
+
out.push_str(" |");
|
|
228
|
+
}
|
|
229
|
+
out.push('\n');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Cell text on one line: pipes escaped, line breaks as `<br>`.
|
|
233
|
+
pub fn push_cell(out: &mut String, text: &str) {
|
|
234
|
+
for (index, line) in text.trim().lines().enumerate() {
|
|
235
|
+
if index > 0 {
|
|
236
|
+
out.push_str("<br>");
|
|
237
|
+
}
|
|
238
|
+
for ch in line.trim_end_matches('\r').chars() {
|
|
239
|
+
if ch == '|' {
|
|
240
|
+
out.push('\\');
|
|
241
|
+
}
|
|
242
|
+
out.push(ch);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Ends the current block with a blank line, unless the output is empty
|
|
248
|
+
/// or already ends with one.
|
|
249
|
+
pub fn end_block(out: &mut String) {
|
|
250
|
+
if out.is_empty() || out.ends_with("\n\n") {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
|
|
254
|
+
}
|