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,72 @@
|
|
|
1
|
+
//! Pictures as inline drawings, their files in `word/media`.
|
|
2
|
+
|
|
3
|
+
use crate::doc::Picture;
|
|
4
|
+
use crate::doc::picture::Kind;
|
|
5
|
+
use crate::xml::XmlWriter;
|
|
6
|
+
|
|
7
|
+
pub const WP_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
|
8
|
+
pub const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
9
|
+
pub const PIC_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture";
|
|
10
|
+
/// English Metric Units a point, as drawings measure.
|
|
11
|
+
const EMU_PER_POINT: f64 = 12_700.0;
|
|
12
|
+
|
|
13
|
+
/// Its file's extension.
|
|
14
|
+
pub const fn extension(picture: &Picture) -> &'static str {
|
|
15
|
+
match picture.kind {
|
|
16
|
+
Kind::Png(_) => "png",
|
|
17
|
+
Kind::Jpeg { .. } => "jpeg",
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Its part in `word/media`, by its number from 1.
|
|
22
|
+
pub fn file_name(picture: &Picture, number: usize) -> String {
|
|
23
|
+
format!("media/image{number}.{}", extension(picture))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A paragraph holding picture `number`, its file `relationship`, at its
|
|
27
|
+
/// size within `room`.
|
|
28
|
+
pub fn paragraph(
|
|
29
|
+
xml: &mut XmlWriter,
|
|
30
|
+
picture: &Picture,
|
|
31
|
+
room: (f64, f64),
|
|
32
|
+
number: usize,
|
|
33
|
+
relationship: &str,
|
|
34
|
+
) {
|
|
35
|
+
let (width, height) = picture.size(room);
|
|
36
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "under 2**31")]
|
|
37
|
+
let emu = |points: f64| ((points * EMU_PER_POINT).round() as u64).to_string();
|
|
38
|
+
let (cx, cy, id, name) =
|
|
39
|
+
(emu(width), emu(height), number.to_string(), format!("Picture {number}"));
|
|
40
|
+
let extent = [("cx", cx.as_str()), ("cy", cy.as_str())];
|
|
41
|
+
super::open_paragraph(xml, None, false, picture.align);
|
|
42
|
+
xml.open("w:r", &[]);
|
|
43
|
+
xml.open("w:drawing", &[]);
|
|
44
|
+
xml.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
|
45
|
+
xml.empty("wp:extent", &extent);
|
|
46
|
+
xml.empty("wp:docPr", &[("id", &id), ("name", &name)]);
|
|
47
|
+
xml.open("a:graphic", &[]);
|
|
48
|
+
xml.open("a:graphicData", &[("uri", PIC_NS)]);
|
|
49
|
+
xml.open("pic:pic", &[]);
|
|
50
|
+
xml.open("pic:nvPicPr", &[]);
|
|
51
|
+
xml.empty("pic:cNvPr", &[("id", &id), ("name", &name)]);
|
|
52
|
+
xml.empty("pic:cNvPicPr", &[]);
|
|
53
|
+
xml.close("pic:nvPicPr");
|
|
54
|
+
xml.open("pic:blipFill", &[]);
|
|
55
|
+
xml.empty("a:blip", &[("r:embed", relationship)]);
|
|
56
|
+
xml.open("a:stretch", &[]);
|
|
57
|
+
xml.empty("a:fillRect", &[]);
|
|
58
|
+
xml.close("a:stretch");
|
|
59
|
+
xml.close("pic:blipFill");
|
|
60
|
+
xml.open("pic:spPr", &[]);
|
|
61
|
+
xml.open("a:xfrm", &[]);
|
|
62
|
+
xml.empty("a:off", &[("x", "0"), ("y", "0")]);
|
|
63
|
+
xml.empty("a:ext", &extent);
|
|
64
|
+
xml.close("a:xfrm");
|
|
65
|
+
xml.open("a:prstGeom", &[("prst", "rect")]);
|
|
66
|
+
xml.empty("a:avLst", &[]);
|
|
67
|
+
xml.close("a:prstGeom");
|
|
68
|
+
xml.close("pic:spPr");
|
|
69
|
+
for tag in ["pic:pic", "a:graphicData", "a:graphic", "wp:inline", "w:drawing", "w:r", "w:p"] {
|
|
70
|
+
xml.close(tag);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
//! Word paragraphs, runs and tables, with real Heading 1-3 styles so Word's
|
|
3
3
|
//! navigation pane and table of contents see them.
|
|
4
4
|
|
|
5
|
+
mod picture;
|
|
5
6
|
#[cfg(test)]
|
|
6
7
|
mod tests;
|
|
7
8
|
|
|
8
9
|
use std::io::Write;
|
|
9
10
|
|
|
10
11
|
use crate::Result;
|
|
11
|
-
use crate::doc::{Block, Document, Paragraph, Run, Table};
|
|
12
|
+
use crate::doc::{Block, Document, Paragraph, Picture, Run, Table};
|
|
12
13
|
use crate::opc;
|
|
13
14
|
use crate::style::{Align, Color, TextDirection};
|
|
14
15
|
use crate::xml::XmlWriter;
|
|
@@ -16,39 +17,58 @@ use crate::zip::ZipWriter;
|
|
|
16
17
|
|
|
17
18
|
const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
18
19
|
const WORD_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml";
|
|
19
|
-
/// One inch, Word's default margin, in twips.
|
|
20
|
-
const MARGIN: u32 = 1_440;
|
|
21
20
|
/// A header row's shading.
|
|
22
21
|
const HEADER_FILL: &str = "D9E2F3";
|
|
23
22
|
|
|
24
23
|
pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
|
|
24
|
+
let room = document.room()?;
|
|
25
25
|
let footer = document.has_page_numbers();
|
|
26
|
-
let mut parts = vec![("styles", "styles.xml")];
|
|
26
|
+
let mut parts = vec![("styles", "styles.xml".to_owned())];
|
|
27
27
|
if footer {
|
|
28
|
-
parts.push(("footer", "footer1.xml"));
|
|
28
|
+
parts.push(("footer", "footer1.xml".to_owned()));
|
|
29
|
+
}
|
|
30
|
+
// Pictures' files follow, their relationships numbered from here.
|
|
31
|
+
let first_media = parts.len() + 1;
|
|
32
|
+
let pictures: Vec<&Picture> = document
|
|
33
|
+
.blocks()
|
|
34
|
+
.iter()
|
|
35
|
+
.filter_map(|block| if let Block::Picture(picture) = block { Some(picture) } else { None })
|
|
36
|
+
.collect();
|
|
37
|
+
for (number, picture) in (1..).zip(&pictures) {
|
|
38
|
+
parts.push(("image", picture::file_name(picture, number)));
|
|
29
39
|
}
|
|
30
40
|
let mut zip = ZipWriter::new(out);
|
|
31
|
-
zip.add("[Content_Types].xml", content_types(footer).as_bytes())?;
|
|
41
|
+
zip.add("[Content_Types].xml", content_types(footer, &pictures).as_bytes())?;
|
|
32
42
|
let package = opc::relationships(&[("officeDocument", "word/document.xml")]);
|
|
33
43
|
zip.add("_rels/.rels", package.as_bytes())?;
|
|
34
|
-
zip.add("word/document.xml", body(document).as_bytes())?;
|
|
44
|
+
zip.add("word/document.xml", body(document, room, first_media).as_bytes())?;
|
|
35
45
|
zip.add("word/_rels/document.xml.rels", opc::relationships(&parts).as_bytes())?;
|
|
36
46
|
zip.add("word/styles.xml", styles().as_bytes())?;
|
|
37
47
|
if footer {
|
|
38
48
|
zip.add("word/footer1.xml", page_number_footer().as_bytes())?;
|
|
39
49
|
}
|
|
50
|
+
for (number, picture) in (1..).zip(&pictures) {
|
|
51
|
+
zip.add(&format!("word/{}", picture::file_name(picture, number)), &picture.data)?;
|
|
52
|
+
}
|
|
40
53
|
zip.finish()
|
|
41
54
|
}
|
|
42
55
|
|
|
43
|
-
/// One override per part: the document, its styles and the footer
|
|
44
|
-
|
|
56
|
+
/// One override per part: the document, its styles and the footer; the
|
|
57
|
+
/// pictures' types by extension.
|
|
58
|
+
fn content_types(footer: bool, pictures: &[&Picture]) -> String {
|
|
45
59
|
let mut parts = vec![("/word/document.xml", "document.main"), ("/word/styles.xml", "styles")];
|
|
46
60
|
if footer {
|
|
47
61
|
parts.push(("/word/footer1.xml", "footer"));
|
|
48
62
|
}
|
|
49
63
|
let overrides: Vec<(&str, String)> =
|
|
50
64
|
parts.into_iter().map(|(part, kind)| (part, format!("{WORD_TYPE}.{kind}+xml"))).collect();
|
|
51
|
-
|
|
65
|
+
let mut defaults = Vec::new();
|
|
66
|
+
for (extension, content_type) in [("png", "image/png"), ("jpeg", "image/jpeg")] {
|
|
67
|
+
if pictures.iter().any(|picture| picture::extension(picture) == extension) {
|
|
68
|
+
defaults.push((extension, content_type));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
opc::content_types(&defaults, &overrides)
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
/// `word/footer1.xml`: a centered `PAGE / NUMPAGES`, which Word computes.
|
|
@@ -82,18 +102,34 @@ fn page_number_footer() -> String {
|
|
|
82
102
|
xml.finish()
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
fn body(document: &Document) -> String {
|
|
105
|
+
fn body(document: &Document, room: (f64, f64), first_media: usize) -> String {
|
|
106
|
+
// What reads in no direction of its own (figures, punctuation) takes the
|
|
107
|
+
// document's.
|
|
108
|
+
let rtl = document.reads_right_to_left();
|
|
86
109
|
let mut xml = XmlWriter::new();
|
|
87
|
-
|
|
110
|
+
let namespaces = [
|
|
111
|
+
("xmlns:w", W_NS),
|
|
112
|
+
("xmlns:r", opc::RELATIONSHIPS),
|
|
113
|
+
("xmlns:wp", picture::WP_NS),
|
|
114
|
+
("xmlns:a", picture::A_NS),
|
|
115
|
+
("xmlns:pic", picture::PIC_NS),
|
|
116
|
+
];
|
|
117
|
+
xml.open("w:document", &namespaces);
|
|
88
118
|
xml.open("w:body", &[]);
|
|
119
|
+
let mut pictures = 0;
|
|
89
120
|
for block in document.blocks() {
|
|
90
121
|
match block {
|
|
91
122
|
Block::Heading { level, text } => {
|
|
92
123
|
let style = format!("Heading{level}");
|
|
93
|
-
simple_paragraph(&mut xml, Some(&style), text, Look::default());
|
|
124
|
+
simple_paragraph(&mut xml, Some(&style), text, Look::default(), rtl);
|
|
125
|
+
}
|
|
126
|
+
Block::Paragraph(content) => paragraph(&mut xml, content, rtl),
|
|
127
|
+
Block::Table(content) => table(&mut xml, content, document, rtl),
|
|
128
|
+
Block::Picture(content) => {
|
|
129
|
+
pictures += 1;
|
|
130
|
+
let relationship = format!("rId{}", first_media + pictures - 1);
|
|
131
|
+
picture::paragraph(&mut xml, content, room, pictures, &relationship);
|
|
94
132
|
}
|
|
95
|
-
Block::Paragraph(content) => paragraph(&mut xml, content),
|
|
96
|
-
Block::Table(content) => table(&mut xml, content, document),
|
|
97
133
|
Block::PageBreak => {
|
|
98
134
|
xml.open("w:p", &[]);
|
|
99
135
|
xml.open("w:r", &[]);
|
|
@@ -107,25 +143,37 @@ fn body(document: &Document) -> String {
|
|
|
107
143
|
if matches!(document.blocks().last(), Some(Block::Table(_))) {
|
|
108
144
|
xml.empty("w:p", &[]);
|
|
109
145
|
}
|
|
110
|
-
section(&mut xml, document);
|
|
146
|
+
section(&mut xml, document, rtl);
|
|
111
147
|
xml.close("w:body");
|
|
112
148
|
xml.close("w:document");
|
|
113
149
|
xml.finish()
|
|
114
150
|
}
|
|
115
151
|
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
/// Each run reads in the direction of its own first letter, as Word sets
|
|
153
|
+
/// them: the Latin runs of a Hebrew paragraph left to right, the Arabic
|
|
154
|
+
/// ones of an English paragraph right to left.
|
|
155
|
+
fn paragraph(xml: &mut XmlWriter, content: &Paragraph, document_rtl: bool) {
|
|
156
|
+
let rtl = right_to_left(content.effective_direction(), document_rtl);
|
|
118
157
|
open_paragraph(xml, None, rtl, content.align);
|
|
119
158
|
for item in &content.runs {
|
|
120
|
-
run(xml, &item.text, Look::of(item), rtl);
|
|
159
|
+
run(xml, &item.text, Look::of(item), right_to_left(TextDirection::of(&item.text), rtl));
|
|
121
160
|
}
|
|
122
161
|
xml.close("w:p");
|
|
123
162
|
}
|
|
124
163
|
|
|
125
|
-
///
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
164
|
+
/// Whether `direction` is right to left; `otherwise` when there is none:
|
|
165
|
+
/// text with no letter (figures, punctuation) takes the direction of what
|
|
166
|
+
/// holds it.
|
|
167
|
+
fn right_to_left(direction: Option<TextDirection>, otherwise: bool) -> bool {
|
|
168
|
+
direction.map_or(otherwise, |direction| direction == TextDirection::RightToLeft)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// A one-run paragraph straight from borrowed text: headings and table
|
|
172
|
+
/// cells, in the direction of their first letter, or of what holds them.
|
|
173
|
+
fn simple_paragraph(xml: &mut XmlWriter, style: Option<&str>, text: &str, look: Look, rtl: bool) {
|
|
174
|
+
let rtl = right_to_left(TextDirection::of(text), rtl);
|
|
175
|
+
open_paragraph(xml, style, rtl, None);
|
|
176
|
+
run(xml, text, look, rtl);
|
|
129
177
|
xml.close("w:p");
|
|
130
178
|
}
|
|
131
179
|
|
|
@@ -207,16 +255,20 @@ fn run(xml: &mut XmlWriter, text: &str, look: Look, rtl: bool) {
|
|
|
207
255
|
xml.close("w:r");
|
|
208
256
|
}
|
|
209
257
|
|
|
210
|
-
fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
258
|
+
fn table(xml: &mut XmlWriter, content: &Table, document: &Document, document_rtl: bool) {
|
|
259
|
+
let rtl = right_to_left(content.direction(), document_rtl);
|
|
211
260
|
let columns = content.columns();
|
|
212
261
|
let (page_width, _) = document.page_size().twips();
|
|
213
|
-
let usable = page_width - 2 *
|
|
262
|
+
let usable = page_width - 2 * margin(document);
|
|
214
263
|
// Never 0: a table has at least one column.
|
|
215
264
|
let column_width = (usable / u32::try_from(columns).unwrap_or(1).max(1)).to_string();
|
|
216
265
|
|
|
217
266
|
xml.open("w:tbl", &[]);
|
|
218
267
|
xml.open("w:tblPr", &[]);
|
|
219
268
|
xml.empty("w:tblStyle", &[("w:val", "TableGrid")]);
|
|
269
|
+
if rtl {
|
|
270
|
+
xml.empty("w:bidiVisual", &[]);
|
|
271
|
+
}
|
|
220
272
|
xml.empty("w:tblW", &[("w:w", "5000"), ("w:type", "pct")]);
|
|
221
273
|
xml.close("w:tblPr");
|
|
222
274
|
xml.open("w:tblGrid", &[]);
|
|
@@ -244,7 +296,7 @@ fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
|
244
296
|
}
|
|
245
297
|
xml.close("w:tcPr");
|
|
246
298
|
let text = row.get(column).map_or("", String::as_str);
|
|
247
|
-
simple_paragraph(xml, None, text, Look { bold: header, ..Look::default() });
|
|
299
|
+
simple_paragraph(xml, None, text, Look { bold: header, ..Look::default() }, rtl);
|
|
248
300
|
xml.close("w:tc");
|
|
249
301
|
}
|
|
250
302
|
xml.close("w:tr");
|
|
@@ -252,9 +304,17 @@ fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
|
252
304
|
xml.close("w:tbl");
|
|
253
305
|
}
|
|
254
306
|
|
|
255
|
-
|
|
307
|
+
/// The document's margin in twips.
|
|
308
|
+
fn margin(document: &Document) -> u32 {
|
|
309
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "0 to 2880")]
|
|
310
|
+
let twips = (document.margin() * 20.0).round() as u32;
|
|
311
|
+
twips
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn section(xml: &mut XmlWriter, document: &Document, rtl: bool) {
|
|
256
315
|
let (width, height) = document.page_size().twips();
|
|
257
|
-
let (width, height, margin) =
|
|
316
|
+
let (width, height, margin) =
|
|
317
|
+
(width.to_string(), height.to_string(), margin(document).to_string());
|
|
258
318
|
xml.open("w:sectPr", &[]);
|
|
259
319
|
if document.has_page_numbers() {
|
|
260
320
|
// rId2: the footer, after the styles (see `write`).
|
|
@@ -264,6 +324,9 @@ fn section(xml: &mut XmlWriter, document: &Document) {
|
|
|
264
324
|
let margins =
|
|
265
325
|
[("w:top", &margin), ("w:right", &margin), ("w:bottom", &margin), ("w:left", &margin)];
|
|
266
326
|
xml.empty("w:pgMar", &margins.map(|(key, value)| (key, value.as_str())));
|
|
327
|
+
if rtl {
|
|
328
|
+
xml.empty("w:bidi", &[]);
|
|
329
|
+
}
|
|
267
330
|
xml.close("w:sectPr");
|
|
268
331
|
}
|
|
269
332
|
|
|
@@ -45,6 +45,9 @@ pub enum Error {
|
|
|
45
45
|
#[error("font size {0} is out of range (1 to 409 points, in steps of 0.5)")]
|
|
46
46
|
InvalidFontSize(f64),
|
|
47
47
|
|
|
48
|
+
#[error("unsupported font: {0}")]
|
|
49
|
+
UnsupportedFont(&'static str),
|
|
50
|
+
|
|
48
51
|
#[error("invalid cell range {0:?}: expected a reference such as B2 or B2:D9")]
|
|
49
52
|
InvalidRange(String),
|
|
50
53
|
|
|
@@ -66,6 +69,15 @@ pub enum Error {
|
|
|
66
69
|
#[error("a table needs at least one row and one column")]
|
|
67
70
|
EmptyTable,
|
|
68
71
|
|
|
72
|
+
#[error("invalid picture: {0}")]
|
|
73
|
+
InvalidImage(&'static str),
|
|
74
|
+
|
|
75
|
+
#[error("page size {0} x {1} points is out of range (36 to 1584 points a side)")]
|
|
76
|
+
InvalidPageSize(f64, f64),
|
|
77
|
+
|
|
78
|
+
#[error("margin {0} points is out of range (0 to 144), or leaves no room on the page")]
|
|
79
|
+
InvalidMargin(f64),
|
|
80
|
+
|
|
69
81
|
#[error(
|
|
70
82
|
"{character:?} (U+{code:04X}) cannot be written with the PDF standard font {font}: \
|
|
71
83
|
only Windows-1252 (Latin) text is supported until embedded fonts land",
|
|
@@ -97,7 +109,7 @@ pub enum Error {
|
|
|
97
109
|
#[error("the PDF has no text layer: its pages are images, which need OCR")]
|
|
98
110
|
NoTextLayer,
|
|
99
111
|
|
|
100
|
-
#[error("the
|
|
112
|
+
#[error("the document is encrypted with a password")]
|
|
101
113
|
PasswordRequired,
|
|
102
114
|
|
|
103
115
|
#[error(transparent)]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
//! Static TrueType font data, Unicode glyph lookup and unscaled metrics.
|
|
2
|
+
//!
|
|
3
|
+
//! This does not select or embed fonts in documents, shape text, or perform
|
|
4
|
+
//! bidirectional layout. A character's nominal glyph is not necessarily the
|
|
5
|
+
//! glyph used when shaping a script such as Arabic.
|
|
6
|
+
|
|
7
|
+
#[cfg(test)]
|
|
8
|
+
mod tests;
|
|
9
|
+
|
|
10
|
+
use read_fonts::{
|
|
11
|
+
FontRead, FontRef, MinByteRange, TableProvider,
|
|
12
|
+
tables::{
|
|
13
|
+
cmap::{Cmap, CmapSubtable, PlatformId},
|
|
14
|
+
hmtx::Hmtx,
|
|
15
|
+
},
|
|
16
|
+
types::{GlyphId, Tag, Version16Dot16},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
use crate::{Error, Result};
|
|
20
|
+
|
|
21
|
+
/// Horizontal line metrics in font design units, taken from `head` and `hhea`.
|
|
22
|
+
///
|
|
23
|
+
/// Multiply a metric by the point size and divide by `units_per_em` to convert
|
|
24
|
+
/// it to points. These are the raw `hhea` metrics, not an OS/2 line-height policy.
|
|
25
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
26
|
+
pub struct FontMetrics {
|
|
27
|
+
pub units_per_em: u16,
|
|
28
|
+
pub ascender: i16,
|
|
29
|
+
/// Usually negative: the position below the baseline.
|
|
30
|
+
pub descender: i16,
|
|
31
|
+
pub line_gap: i16,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// A borrowed, standalone static TrueType font with a Unicode character map.
|
|
35
|
+
///
|
|
36
|
+
/// Loading checks table bounds and the data needed for glyph lookup and metrics,
|
|
37
|
+
/// including the `loca` offsets into `glyf`. It is not a full font sanitizer:
|
|
38
|
+
/// checksums, outline programs and shaping tables are not validated.
|
|
39
|
+
#[derive(Clone)]
|
|
40
|
+
pub struct TrueTypeFont<'a> {
|
|
41
|
+
data: &'a [u8],
|
|
42
|
+
cmap: CmapSubtable<'a>,
|
|
43
|
+
hmtx: Hmtx<'a>,
|
|
44
|
+
metrics: FontMetrics,
|
|
45
|
+
glyph_count: u16,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl<'a> TrueTypeFont<'a> {
|
|
49
|
+
/// Load font data without copying it or accessing system fonts.
|
|
50
|
+
///
|
|
51
|
+
/// # Errors
|
|
52
|
+
/// [`Error::UnsupportedFont`] for collections, WOFF, CFF, variable fonts or
|
|
53
|
+
/// fonts without a supported Unicode cmap. [`Error::Malformed`] for invalid
|
|
54
|
+
/// headers, missing required tables or inconsistent metrics/outline offsets.
|
|
55
|
+
pub fn from_bytes(data: &'a [u8]) -> Result<Self> {
|
|
56
|
+
match data.get(..4) {
|
|
57
|
+
Some(b"\0\x01\0\0" | b"true") => {}
|
|
58
|
+
Some(b"ttcf") => return Err(Error::UnsupportedFont("font collections")),
|
|
59
|
+
Some(b"wOFF" | b"wOF2") => return Err(Error::UnsupportedFont("WOFF/WOFF2")),
|
|
60
|
+
Some(b"OTTO") => return Err(Error::UnsupportedFont("CFF outlines")),
|
|
61
|
+
_ => return Err(invalid("invalid or truncated sfnt header")),
|
|
62
|
+
}
|
|
63
|
+
let font = FontRef::new(data).map_err(invalid)?;
|
|
64
|
+
for record in font.table_directory().table_records() {
|
|
65
|
+
if u64::from(record.offset()) + u64::from(record.length()) > data.len() as u64 {
|
|
66
|
+
return Err(invalid(format!(
|
|
67
|
+
"{} table extends beyond the font data",
|
|
68
|
+
record.tag()
|
|
69
|
+
)));
|
|
70
|
+
}
|
|
71
|
+
match record.tag() {
|
|
72
|
+
tag if tag == Tag::new(b"fvar") || tag == Tag::new(b"gvar") => {
|
|
73
|
+
return Err(Error::UnsupportedFont("variable fonts"));
|
|
74
|
+
}
|
|
75
|
+
tag if tag == Tag::new(b"CFF ") || tag == Tag::new(b"CFF2") => {
|
|
76
|
+
return Err(Error::UnsupportedFont("CFF outlines"));
|
|
77
|
+
}
|
|
78
|
+
_ => {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let head = font.head().map_err(invalid)?;
|
|
83
|
+
if head.magic_number() != 0x5F0F_3CF5 || !(16..=16384).contains(&head.units_per_em()) {
|
|
84
|
+
return Err(invalid("invalid head magic number or units per em"));
|
|
85
|
+
}
|
|
86
|
+
let maxp = font.maxp().map_err(invalid)?;
|
|
87
|
+
let glyph_count = maxp.num_glyphs();
|
|
88
|
+
if maxp.version() != Version16Dot16::VERSION_1_0
|
|
89
|
+
|| maxp.max_component_depth().is_none()
|
|
90
|
+
|| glyph_count == 0
|
|
91
|
+
{
|
|
92
|
+
return Err(invalid("expected a complete TrueType maxp table with at least one glyph"));
|
|
93
|
+
}
|
|
94
|
+
let hhea = font.hhea().map_err(invalid)?;
|
|
95
|
+
let count = hhea.number_of_h_metrics();
|
|
96
|
+
if count == 0 || count > glyph_count {
|
|
97
|
+
return Err(invalid("hhea metric count is outside the glyph range"));
|
|
98
|
+
}
|
|
99
|
+
let hmtx = font.hmtx().map_err(invalid)?;
|
|
100
|
+
if hmtx.h_metrics().len() != usize::from(count)
|
|
101
|
+
|| hmtx.left_side_bearings().len() < usize::from(glyph_count - count)
|
|
102
|
+
{
|
|
103
|
+
return Err(invalid("truncated hmtx table"));
|
|
104
|
+
}
|
|
105
|
+
validate_outlines(&font, head.index_to_loc_format(), glyph_count)?;
|
|
106
|
+
let cmap = font.cmap().map_err(invalid)?;
|
|
107
|
+
if cmap.version() != 0 || cmap.encoding_records().len() != usize::from(cmap.num_tables()) {
|
|
108
|
+
return Err(invalid("invalid or truncated cmap header"));
|
|
109
|
+
}
|
|
110
|
+
Ok(Self {
|
|
111
|
+
data,
|
|
112
|
+
cmap: unicode_cmap(&cmap)?,
|
|
113
|
+
hmtx,
|
|
114
|
+
metrics: FontMetrics {
|
|
115
|
+
units_per_em: head.units_per_em(),
|
|
116
|
+
ascender: hhea.ascender().into(),
|
|
117
|
+
descender: hhea.descender().into(),
|
|
118
|
+
line_gap: hhea.line_gap().into(),
|
|
119
|
+
},
|
|
120
|
+
glyph_count,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// The original font bytes.
|
|
125
|
+
#[must_use]
|
|
126
|
+
pub const fn as_bytes(&self) -> &'a [u8] {
|
|
127
|
+
self.data
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Unscaled horizontal line metrics.
|
|
131
|
+
#[must_use]
|
|
132
|
+
pub const fn metrics(&self) -> FontMetrics {
|
|
133
|
+
self.metrics
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// The number of glyphs, including glyph zero (`.notdef`).
|
|
137
|
+
#[must_use]
|
|
138
|
+
pub const fn glyph_count(&self) -> u16 {
|
|
139
|
+
self.glyph_count
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The nominal glyph for a Unicode character, without shaping or fallback.
|
|
143
|
+
///
|
|
144
|
+
/// Returns `None` for missing characters, mappings to `.notdef`, or mappings
|
|
145
|
+
/// outside the font's glyph range. Symbol and legacy cmaps are never used as
|
|
146
|
+
/// Unicode mappings; full-repertoire Unicode cmaps take priority over BMP ones.
|
|
147
|
+
#[must_use]
|
|
148
|
+
pub fn glyph_id(&self, character: char) -> Option<u16> {
|
|
149
|
+
let glyph = self.cmap.map_codepoint(character)?.to_u32();
|
|
150
|
+
u16::try_from(glyph).ok().filter(|&id| id != 0 && id < self.glyph_count)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The horizontal advance in font design units, without kerning or shaping.
|
|
154
|
+
///
|
|
155
|
+
/// Glyph zero is allowed; an out-of-range glyph returns `None`. A valid
|
|
156
|
+
/// zero-width glyph returns `Some(0)`.
|
|
157
|
+
#[must_use]
|
|
158
|
+
pub fn advance_width(&self, glyph_id: u16) -> Option<u16> {
|
|
159
|
+
if glyph_id >= self.glyph_count {
|
|
160
|
+
return None;
|
|
161
|
+
}
|
|
162
|
+
self.hmtx.advance(GlyphId::new(u32::from(glyph_id)))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn validate_outlines(font: &FontRef<'_>, loca_format: i16, glyph_count: u16) -> Result<()> {
|
|
167
|
+
if !matches!(loca_format, 0 | 1) {
|
|
168
|
+
return Err(invalid("invalid head indexToLocFormat"));
|
|
169
|
+
}
|
|
170
|
+
let loca = font.loca(loca_format == 1).map_err(invalid)?;
|
|
171
|
+
let glyf = font.glyf().map_err(invalid)?;
|
|
172
|
+
if loca.len() != usize::from(glyph_count)
|
|
173
|
+
|| !loca.all_offsets_are_ascending()
|
|
174
|
+
|| loca
|
|
175
|
+
.get_raw(usize::from(glyph_count))
|
|
176
|
+
.is_none_or(|end| u64::from(end) > glyf.offset_data().len() as u64)
|
|
177
|
+
{
|
|
178
|
+
return Err(invalid("invalid loca offsets or truncated glyf table"));
|
|
179
|
+
}
|
|
180
|
+
Ok(())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn unicode_cmap<'a>(cmap: &Cmap<'a>) -> Result<CmapSubtable<'a>> {
|
|
184
|
+
// Fontations' best_subtable also accepts symbol and MacRoman maps. Only
|
|
185
|
+
// Unicode encodings belong here; prefer full repertoire over BMP mappings.
|
|
186
|
+
let encodings = [
|
|
187
|
+
(PlatformId::Windows, 10),
|
|
188
|
+
(PlatformId::Unicode, 6),
|
|
189
|
+
(PlatformId::Unicode, 4),
|
|
190
|
+
(PlatformId::Windows, 1),
|
|
191
|
+
(PlatformId::Unicode, 3),
|
|
192
|
+
(PlatformId::Unicode, 2),
|
|
193
|
+
(PlatformId::Unicode, 1),
|
|
194
|
+
(PlatformId::Unicode, 0),
|
|
195
|
+
];
|
|
196
|
+
for (platform, encoding) in encodings {
|
|
197
|
+
for record in cmap.encoding_records() {
|
|
198
|
+
if record.platform_id() != platform || record.encoding_id() != encoding {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
let subtable = record.subtable(cmap.offset_data()).map_err(invalid)?;
|
|
202
|
+
let length = match &subtable {
|
|
203
|
+
CmapSubtable::Format0(table) => u32::from(table.length()),
|
|
204
|
+
CmapSubtable::Format4(table) => u32::from(table.length()),
|
|
205
|
+
CmapSubtable::Format6(table) => u32::from(table.length()),
|
|
206
|
+
CmapSubtable::Format10(table) => table.length(),
|
|
207
|
+
CmapSubtable::Format12(table) => table.length(),
|
|
208
|
+
CmapSubtable::Format13(table) => table.length(),
|
|
209
|
+
_ => continue,
|
|
210
|
+
};
|
|
211
|
+
let length = usize::try_from(length).map_err(|_| invalid("cmap subtable too large"))?;
|
|
212
|
+
let data = subtable
|
|
213
|
+
.offset_data()
|
|
214
|
+
.slice(0..length)
|
|
215
|
+
.ok_or_else(|| invalid("truncated cmap subtable"))?;
|
|
216
|
+
// Constrain lazy array reads to this subtable's declared extent.
|
|
217
|
+
let subtable = CmapSubtable::read(data).map_err(invalid)?;
|
|
218
|
+
if subtable.min_byte_range().end > length {
|
|
219
|
+
return Err(invalid("truncated cmap arrays"));
|
|
220
|
+
}
|
|
221
|
+
return Ok(subtable);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
Err(Error::UnsupportedFont("no supported Unicode cmap"))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fn invalid(reason: impl std::fmt::Display) -> Error {
|
|
228
|
+
Error::malformed("TrueType font", reason.to_string())
|
|
229
|
+
}
|
|
@@ -18,12 +18,17 @@
|
|
|
18
18
|
//! # Ok::<(), imprimatur::Error>(())
|
|
19
19
|
//! ```
|
|
20
20
|
|
|
21
|
+
#[cfg(feature = "archive")]
|
|
22
|
+
pub mod archive;
|
|
21
23
|
pub mod doc;
|
|
22
24
|
mod docx;
|
|
23
25
|
mod error;
|
|
26
|
+
#[cfg(feature = "fonts")]
|
|
27
|
+
pub mod font;
|
|
24
28
|
mod opc;
|
|
25
29
|
mod pdf;
|
|
26
30
|
pub mod read;
|
|
31
|
+
mod script;
|
|
27
32
|
pub mod style;
|
|
28
33
|
#[cfg(test)]
|
|
29
34
|
mod test_support;
|