imprimatur 0.2.0 → 0.4.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 +153 -3
- data/Cargo.toml +1 -1
- data/README.md +139 -21
- data/ext/imprimatur_native/cli/Cargo.toml +3 -1
- data/ext/imprimatur_native/core/Cargo.toml +20 -4
- data/ext/imprimatur_native/core/README.md +36 -4
- data/ext/imprimatur_native/core/src/archive.rs +3 -16
- data/ext/imprimatur_native/core/src/barcode/code128.rs +162 -0
- data/ext/imprimatur_native/core/src/barcode/draw.rs +124 -0
- data/ext/imprimatur_native/core/src/barcode/ean.rs +91 -0
- data/ext/imprimatur_native/core/src/barcode/gs1.rs +188 -0
- data/ext/imprimatur_native/core/src/barcode/matrix.rs +130 -0
- data/ext/imprimatur_native/core/src/barcode.rs +314 -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/symbol.rs +136 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
- data/ext/imprimatur_native/core/src/doc.rs +96 -2
- data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
- data/ext/imprimatur_native/core/src/docx.rs +103 -28
- data/ext/imprimatur_native/core/src/error.rs +18 -0
- data/ext/imprimatur_native/core/src/font.rs +231 -0
- data/ext/imprimatur_native/core/src/lib.rs +6 -1
- data/ext/imprimatur_native/core/src/pdf/layout.rs +72 -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/truetype/descriptor.rs +129 -0
- data/ext/imprimatur_native/core/src/pdf/truetype/program.rs +70 -0
- data/ext/imprimatur_native/core/src/pdf/truetype.rs +149 -0
- data/ext/imprimatur_native/core/src/pdf.rs +40 -4
- data/ext/imprimatur_native/core/src/read/content.rs +75 -2
- data/ext/imprimatur_native/core/src/read/image.rs +17 -0
- data/ext/imprimatur_native/core/src/read/json.rs +94 -7
- data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
- data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
- 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.rs +1 -1
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
- data/ext/imprimatur_native/core/src/read.rs +5 -1
- data/ext/imprimatur_native/core/src/script.rs +116 -0
- data/ext/imprimatur_native/core/src/style.rs +32 -0
- 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 +1 -3
- data/ext/imprimatur_native/ffi/src/barcode.rs +147 -0
- data/ext/imprimatur_native/ffi/src/document.rs +76 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +40 -1
- data/ext/imprimatur_native/ffi/src/read.rs +8 -11
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/barcode.rb +78 -0
- data/lib/imprimatur/document.rb +46 -6
- data/lib/imprimatur/reader.rb +2 -2
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur.rb +19 -3
- metadata +23 -2
- data/ext/imprimatur_native/core/src/test_support.rs +0 -250
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//! Barcodes in the flow: shapes in a PDF, a picture in DOCX.
|
|
2
|
+
|
|
3
|
+
use crate::Result;
|
|
4
|
+
use crate::barcode::{Barcode, Look, Shapes};
|
|
5
|
+
use crate::doc::Picture;
|
|
6
|
+
use crate::style::{Align, Color};
|
|
7
|
+
|
|
8
|
+
/// The largest side, in points, a symbol may be set to: PDF's page limit.
|
|
9
|
+
const MAX_POINTS: f64 = 14_400.0;
|
|
10
|
+
/// Pixels an inch a DOCX picture of a symbol has at least.
|
|
11
|
+
const DOCX_DPI: f64 = 300.0;
|
|
12
|
+
|
|
13
|
+
/// A [`Barcode`] drawn in a document's flow.
|
|
14
|
+
///
|
|
15
|
+
/// It is drawn at the width or height set (in points; a QR Code or Data
|
|
16
|
+
/// Matrix keeps square modules, a linear code's height is its bars'), or
|
|
17
|
+
/// else at its symbology's usual module size, never larger than the page
|
|
18
|
+
/// leaves room for. Its quiet zone is part of its size.
|
|
19
|
+
///
|
|
20
|
+
/// ```
|
|
21
|
+
/// use imprimatur::barcode::{Barcode, Level};
|
|
22
|
+
/// use imprimatur::doc::{Document, Symbol};
|
|
23
|
+
/// use imprimatur::style::Align;
|
|
24
|
+
///
|
|
25
|
+
/// let mut doc = Document::new();
|
|
26
|
+
/// let qr = Barcode::qr("https://example.com", Level::Medium)?;
|
|
27
|
+
/// doc.barcode(Symbol::new(qr).width(144.0)?.align(Align::Center));
|
|
28
|
+
/// doc.barcode(Barcode::ean13("950600013435")?);
|
|
29
|
+
/// assert!(doc.to_pdf()?.starts_with(b"%PDF-"));
|
|
30
|
+
/// # Ok::<(), imprimatur::Error>(())
|
|
31
|
+
/// ```
|
|
32
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
33
|
+
pub struct Symbol {
|
|
34
|
+
pub(crate) barcode: Barcode,
|
|
35
|
+
width: Option<f64>,
|
|
36
|
+
height: Option<f64>,
|
|
37
|
+
pub(crate) align: Option<Align>,
|
|
38
|
+
pub(crate) color: Color,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl Symbol {
|
|
42
|
+
#[must_use]
|
|
43
|
+
pub const fn new(barcode: Barcode) -> Self {
|
|
44
|
+
Self { barcode, width: None, height: None, align: None, color: Color::BLACK }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Its width in points, quiet zone included.
|
|
48
|
+
///
|
|
49
|
+
/// # Errors
|
|
50
|
+
/// [`Error::InvalidBarcode`](crate::Error::InvalidBarcode) outside 1 to
|
|
51
|
+
/// 14 400 points.
|
|
52
|
+
pub fn width(mut self, points: f64) -> Result<Self> {
|
|
53
|
+
self.width = Some(self.points_in_range(points)?);
|
|
54
|
+
Ok(self)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Its height in points: a linear code's bars, a QR Code or Data
|
|
58
|
+
/// Matrix's whole (quiet zone included).
|
|
59
|
+
///
|
|
60
|
+
/// # Errors
|
|
61
|
+
/// [`Error::InvalidBarcode`](crate::Error::InvalidBarcode) outside 1 to
|
|
62
|
+
/// 14 400 points.
|
|
63
|
+
pub fn height(mut self, points: f64) -> Result<Self> {
|
|
64
|
+
self.height = Some(self.points_in_range(points)?);
|
|
65
|
+
Ok(self)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[must_use]
|
|
69
|
+
pub const fn align(mut self, align: Align) -> Self {
|
|
70
|
+
self.align = Some(align);
|
|
71
|
+
self
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// The bars' color; black unless set.
|
|
75
|
+
#[must_use]
|
|
76
|
+
pub const fn color(mut self, color: Color) -> Self {
|
|
77
|
+
self.color = color;
|
|
78
|
+
self
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Its rectangles and the size they are drawn at, in points, fitted
|
|
82
|
+
/// into `room` (the width and height between the margins).
|
|
83
|
+
pub(crate) fn placed(&self, room: (f64, f64)) -> (Shapes, (f64, f64)) {
|
|
84
|
+
let shapes = self.barcode.shapes(true, None);
|
|
85
|
+
let (columns, rows) = shapes.extent();
|
|
86
|
+
let module = self.barcode.default_module();
|
|
87
|
+
let (width, height) = if self.barcode.symbology().is_linear() {
|
|
88
|
+
let width = self.width.unwrap_or(columns * module);
|
|
89
|
+
(width, self.height.unwrap_or(rows * width / columns))
|
|
90
|
+
} else {
|
|
91
|
+
let module = match (self.width, self.height) {
|
|
92
|
+
(Some(width), Some(height)) => (width / columns).min(height / rows),
|
|
93
|
+
(Some(width), None) => width / columns,
|
|
94
|
+
(None, Some(height)) => height / rows,
|
|
95
|
+
(None, None) => module,
|
|
96
|
+
};
|
|
97
|
+
(columns * module, rows * module)
|
|
98
|
+
};
|
|
99
|
+
let scale = (room.0 / width).min(room.1 / height).min(1.0);
|
|
100
|
+
(shapes, (width * scale, height * scale))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// A PNG of it at 300 pixels an inch or more, for formats that take
|
|
104
|
+
/// pictures only.
|
|
105
|
+
pub(crate) fn picture(&self, room: (f64, f64)) -> Result<Picture> {
|
|
106
|
+
let (shapes, (width, height)) = self.placed(room);
|
|
107
|
+
let module = width / shapes.extent().0;
|
|
108
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "1 to 64")]
|
|
109
|
+
let scale = (module * DOCX_DPI / 72.0).ceil().clamp(1.0, 64.0) as u32;
|
|
110
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "bars, small")]
|
|
111
|
+
let bar_height = (height / module).round().max(1.0) as u32;
|
|
112
|
+
let look = Look {
|
|
113
|
+
dark: self.color,
|
|
114
|
+
light: Some(Color::WHITE),
|
|
115
|
+
quiet_zone: true,
|
|
116
|
+
bar_height: Some(bar_height),
|
|
117
|
+
};
|
|
118
|
+
let png = self.barcode.png(scale, &look)?;
|
|
119
|
+
let picture = Picture::new(png)?.width(width)?.height(height)?;
|
|
120
|
+
Ok(match self.align {
|
|
121
|
+
Some(align) => picture.align(align),
|
|
122
|
+
None => picture,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn points_in_range(&self, points: f64) -> Result<f64> {
|
|
127
|
+
let invalid = || self.barcode.symbology().invalid("a size of 1 to 14 400 points");
|
|
128
|
+
(1.0..=MAX_POINTS).contains(&points).then_some(points).ok_or_else(invalid)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
impl From<Barcode> for Symbol {
|
|
133
|
+
fn from(barcode: Barcode) -> Self {
|
|
134
|
+
Self::new(barcode)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -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,8 @@
|
|
|
22
22
|
//! ```
|
|
23
23
|
|
|
24
24
|
mod paragraph;
|
|
25
|
+
pub(crate) mod picture;
|
|
26
|
+
mod symbol;
|
|
25
27
|
mod table;
|
|
26
28
|
#[cfg(test)]
|
|
27
29
|
mod tests;
|
|
@@ -31,24 +33,50 @@ use std::io::{BufWriter, Write};
|
|
|
31
33
|
use std::path::Path;
|
|
32
34
|
|
|
33
35
|
pub use paragraph::{Paragraph, Run};
|
|
36
|
+
pub use picture::Picture;
|
|
37
|
+
pub use symbol::Symbol;
|
|
34
38
|
pub use table::Table;
|
|
35
39
|
|
|
40
|
+
use crate::script::Counts;
|
|
36
41
|
use crate::{Error, Result};
|
|
37
42
|
|
|
38
|
-
/// Paper size; pages are portrait.
|
|
43
|
+
/// Paper size; A4 and Letter pages are portrait.
|
|
39
44
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
40
45
|
pub enum PageSize {
|
|
41
46
|
#[default]
|
|
42
47
|
A4,
|
|
43
48
|
Letter,
|
|
49
|
+
/// Width and height in hundredths of a point; see [`PageSize::custom`].
|
|
50
|
+
Custom(u32, u32),
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
impl PageSize {
|
|
54
|
+
/// A page of this width and height, in points (1/72 inch): 36 to 1584
|
|
55
|
+
/// (half an inch to 22 inches), as both PDF and Word take them.
|
|
56
|
+
///
|
|
57
|
+
/// # Errors
|
|
58
|
+
/// [`Error::InvalidPageSize`] outside that range.
|
|
59
|
+
pub fn custom(width: f64, height: f64) -> Result<Self> {
|
|
60
|
+
let side = |points: f64| {
|
|
61
|
+
#[expect(
|
|
62
|
+
clippy::cast_possible_truncation,
|
|
63
|
+
clippy::cast_sign_loss,
|
|
64
|
+
reason = "36 to 1584"
|
|
65
|
+
)]
|
|
66
|
+
(36.0..=1584.0).contains(&points).then(|| (points * 100.0).round() as u32)
|
|
67
|
+
};
|
|
68
|
+
side(width)
|
|
69
|
+
.zip(side(height))
|
|
70
|
+
.map(|(width, height)| Self::Custom(width, height))
|
|
71
|
+
.ok_or(Error::InvalidPageSize(width, height))
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
/// Width and height in PDF points (1/72 inch).
|
|
48
|
-
pub(crate)
|
|
75
|
+
pub(crate) fn points(self) -> (f64, f64) {
|
|
49
76
|
match self {
|
|
50
77
|
Self::A4 => (595.276, 841.89),
|
|
51
78
|
Self::Letter => (612.0, 792.0),
|
|
79
|
+
Self::Custom(width, height) => (f64::from(width) / 100.0, f64::from(height) / 100.0),
|
|
52
80
|
}
|
|
53
81
|
}
|
|
54
82
|
|
|
@@ -57,6 +85,7 @@ impl PageSize {
|
|
|
57
85
|
match self {
|
|
58
86
|
Self::A4 => (11_906, 16_838),
|
|
59
87
|
Self::Letter => (12_240, 15_840),
|
|
88
|
+
Self::Custom(width, height) => (width / 5, height / 5),
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
}
|
|
@@ -68,6 +97,8 @@ pub enum Block {
|
|
|
68
97
|
Heading { level: u8, text: String },
|
|
69
98
|
Paragraph(Paragraph),
|
|
70
99
|
Table(Table),
|
|
100
|
+
Picture(Picture),
|
|
101
|
+
Symbol(Symbol),
|
|
71
102
|
PageBreak,
|
|
72
103
|
}
|
|
73
104
|
|
|
@@ -78,6 +109,8 @@ pub struct Document {
|
|
|
78
109
|
blocks: Vec<Block>,
|
|
79
110
|
page_size: PageSize,
|
|
80
111
|
page_numbers: bool,
|
|
112
|
+
/// Hundredths of a point; one inch when unset.
|
|
113
|
+
margin: Option<u32>,
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
impl Document {
|
|
@@ -91,6 +124,50 @@ impl Document {
|
|
|
91
124
|
self
|
|
92
125
|
}
|
|
93
126
|
|
|
127
|
+
/// The margin on every side, in points: one inch (72) unless set.
|
|
128
|
+
///
|
|
129
|
+
/// # Errors
|
|
130
|
+
/// [`Error::InvalidMargin`] outside 0 to 144 points.
|
|
131
|
+
pub fn set_margin(&mut self, points: f64) -> Result<&mut Self> {
|
|
132
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "0 to 144")]
|
|
133
|
+
let margin = (0.0..=144.0)
|
|
134
|
+
.contains(&points)
|
|
135
|
+
.then(|| (points * 100.0).round() as u32)
|
|
136
|
+
.ok_or(Error::InvalidMargin(points))?;
|
|
137
|
+
self.margin = Some(margin);
|
|
138
|
+
Ok(self)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// The margin on every side, in points.
|
|
142
|
+
#[must_use]
|
|
143
|
+
pub fn margin(&self) -> f64 {
|
|
144
|
+
self.margin.map_or(72.0, |margin| f64::from(margin) / 100.0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// The width and height between the margins, in points.
|
|
148
|
+
///
|
|
149
|
+
/// # Errors
|
|
150
|
+
/// [`Error::InvalidMargin`] when the margins leave no room.
|
|
151
|
+
pub(crate) fn room(&self) -> Result<(f64, f64)> {
|
|
152
|
+
let (width, height) = self.page_size.points();
|
|
153
|
+
let margin = self.margin();
|
|
154
|
+
let room = (2.0f64.mul_add(-margin, width), 2.0f64.mul_add(-margin, height));
|
|
155
|
+
(room.0 >= 1.0 && room.1 >= 1.0).then_some(room).ok_or(Error::InvalidMargin(margin))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// A picture, in the flow of the text.
|
|
159
|
+
pub fn picture(&mut self, picture: Picture) -> &mut Self {
|
|
160
|
+
self.blocks.push(Block::Picture(picture));
|
|
161
|
+
self
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// A barcode, in the flow of the text: a [`Barcode`](crate::barcode::Barcode)
|
|
165
|
+
/// at its usual size, or a [`Symbol`] sized and aligned.
|
|
166
|
+
pub fn barcode(&mut self, symbol: impl Into<Symbol>) -> &mut Self {
|
|
167
|
+
self.blocks.push(Block::Symbol(symbol.into()));
|
|
168
|
+
self
|
|
169
|
+
}
|
|
170
|
+
|
|
94
171
|
/// Numbers pages `1 / 3` at the bottom of each one.
|
|
95
172
|
pub const fn set_page_numbers(&mut self, enabled: bool) -> &mut Self {
|
|
96
173
|
self.page_numbers = enabled;
|
|
@@ -144,6 +221,23 @@ impl Document {
|
|
|
144
221
|
self
|
|
145
222
|
}
|
|
146
223
|
|
|
224
|
+
/// Whether most of the document's letters are right to left: its pages
|
|
225
|
+
/// are then laid out right to left.
|
|
226
|
+
pub(crate) fn reads_right_to_left(&self) -> bool {
|
|
227
|
+
let mut counts = Counts::default();
|
|
228
|
+
for block in &self.blocks {
|
|
229
|
+
match block {
|
|
230
|
+
Block::Heading { text, .. } => counts.add(text),
|
|
231
|
+
Block::Paragraph(paragraph) => {
|
|
232
|
+
paragraph.runs.iter().for_each(|run| counts.add(&run.text));
|
|
233
|
+
}
|
|
234
|
+
Block::Table(table) => table.count(&mut counts),
|
|
235
|
+
Block::Picture(_) | Block::Symbol(_) | Block::PageBreak => {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
counts.right_to_left()
|
|
239
|
+
}
|
|
240
|
+
|
|
147
241
|
#[must_use]
|
|
148
242
|
pub fn blocks(&self) -> &[Block] {
|
|
149
243
|
&self.blocks
|
|
@@ -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,15 @@
|
|
|
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
|
|
|
9
|
+
use std::borrow::Cow;
|
|
8
10
|
use std::io::Write;
|
|
9
11
|
|
|
10
12
|
use crate::Result;
|
|
11
|
-
use crate::doc::{Block, Document, Paragraph, Run, Table};
|
|
13
|
+
use crate::doc::{Block, Document, Paragraph, Picture, Run, Table};
|
|
12
14
|
use crate::opc;
|
|
13
15
|
use crate::style::{Align, Color, TextDirection};
|
|
14
16
|
use crate::xml::XmlWriter;
|
|
@@ -16,39 +18,64 @@ use crate::zip::ZipWriter;
|
|
|
16
18
|
|
|
17
19
|
const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
18
20
|
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
21
|
/// A header row's shading.
|
|
22
22
|
const HEADER_FILL: &str = "D9E2F3";
|
|
23
23
|
|
|
24
24
|
pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
|
|
25
|
+
let room = document.room()?;
|
|
25
26
|
let footer = document.has_page_numbers();
|
|
26
|
-
let mut parts = vec![("styles", "styles.xml")];
|
|
27
|
+
let mut parts = vec![("styles", "styles.xml".to_owned())];
|
|
27
28
|
if footer {
|
|
28
|
-
parts.push(("footer", "footer1.xml"));
|
|
29
|
+
parts.push(("footer", "footer1.xml".to_owned()));
|
|
30
|
+
}
|
|
31
|
+
// Pictures' files follow, their relationships numbered from here.
|
|
32
|
+
let first_media = parts.len() + 1;
|
|
33
|
+
// Barcodes go in as pictures.
|
|
34
|
+
let rendered = document
|
|
35
|
+
.blocks()
|
|
36
|
+
.iter()
|
|
37
|
+
.filter_map(|block| match block {
|
|
38
|
+
Block::Picture(picture) => Some(Ok(Cow::Borrowed(picture))),
|
|
39
|
+
Block::Symbol(symbol) => Some(symbol.picture(room).map(Cow::Owned)),
|
|
40
|
+
_ => None,
|
|
41
|
+
})
|
|
42
|
+
.collect::<Result<Vec<Cow<'_, Picture>>>>()?;
|
|
43
|
+
let pictures: Vec<&Picture> = rendered.iter().map(|picture| &**picture).collect();
|
|
44
|
+
for (number, picture) in (1..).zip(&pictures) {
|
|
45
|
+
parts.push(("image", picture::file_name(picture, number)));
|
|
29
46
|
}
|
|
30
47
|
let mut zip = ZipWriter::new(out);
|
|
31
|
-
zip.add("[Content_Types].xml", content_types(footer).as_bytes())?;
|
|
48
|
+
zip.add("[Content_Types].xml", content_types(footer, &pictures).as_bytes())?;
|
|
32
49
|
let package = opc::relationships(&[("officeDocument", "word/document.xml")]);
|
|
33
50
|
zip.add("_rels/.rels", package.as_bytes())?;
|
|
34
|
-
zip.add("word/document.xml", body(document).as_bytes())?;
|
|
51
|
+
zip.add("word/document.xml", body(document, room, first_media, &pictures).as_bytes())?;
|
|
35
52
|
zip.add("word/_rels/document.xml.rels", opc::relationships(&parts).as_bytes())?;
|
|
36
53
|
zip.add("word/styles.xml", styles().as_bytes())?;
|
|
37
54
|
if footer {
|
|
38
55
|
zip.add("word/footer1.xml", page_number_footer().as_bytes())?;
|
|
39
56
|
}
|
|
57
|
+
for (number, picture) in (1..).zip(&pictures) {
|
|
58
|
+
zip.add(&format!("word/{}", picture::file_name(picture, number)), &picture.data)?;
|
|
59
|
+
}
|
|
40
60
|
zip.finish()
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
/// One override per part: the document, its styles and the footer
|
|
44
|
-
|
|
63
|
+
/// One override per part: the document, its styles and the footer; the
|
|
64
|
+
/// pictures' types by extension.
|
|
65
|
+
fn content_types(footer: bool, pictures: &[&Picture]) -> String {
|
|
45
66
|
let mut parts = vec![("/word/document.xml", "document.main"), ("/word/styles.xml", "styles")];
|
|
46
67
|
if footer {
|
|
47
68
|
parts.push(("/word/footer1.xml", "footer"));
|
|
48
69
|
}
|
|
49
70
|
let overrides: Vec<(&str, String)> =
|
|
50
71
|
parts.into_iter().map(|(part, kind)| (part, format!("{WORD_TYPE}.{kind}+xml"))).collect();
|
|
51
|
-
|
|
72
|
+
let mut defaults = Vec::new();
|
|
73
|
+
for (extension, content_type) in [("png", "image/png"), ("jpeg", "image/jpeg")] {
|
|
74
|
+
if pictures.iter().any(|picture| picture::extension(picture) == extension) {
|
|
75
|
+
defaults.push((extension, content_type));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
opc::content_types(&defaults, &overrides)
|
|
52
79
|
}
|
|
53
80
|
|
|
54
81
|
/// `word/footer1.xml`: a centered `PAGE / NUMPAGES`, which Word computes.
|
|
@@ -82,18 +109,39 @@ fn page_number_footer() -> String {
|
|
|
82
109
|
xml.finish()
|
|
83
110
|
}
|
|
84
111
|
|
|
85
|
-
fn body(
|
|
112
|
+
fn body(
|
|
113
|
+
document: &Document,
|
|
114
|
+
room: (f64, f64),
|
|
115
|
+
first_media: usize,
|
|
116
|
+
pictures: &[&Picture],
|
|
117
|
+
) -> String {
|
|
118
|
+
// What reads in no direction of its own (figures, punctuation) takes the
|
|
119
|
+
// document's.
|
|
120
|
+
let rtl = document.reads_right_to_left();
|
|
86
121
|
let mut xml = XmlWriter::new();
|
|
87
|
-
|
|
122
|
+
let namespaces = [
|
|
123
|
+
("xmlns:w", W_NS),
|
|
124
|
+
("xmlns:r", opc::RELATIONSHIPS),
|
|
125
|
+
("xmlns:wp", picture::WP_NS),
|
|
126
|
+
("xmlns:a", picture::A_NS),
|
|
127
|
+
("xmlns:pic", picture::PIC_NS),
|
|
128
|
+
];
|
|
129
|
+
xml.open("w:document", &namespaces);
|
|
88
130
|
xml.open("w:body", &[]);
|
|
131
|
+
let mut drawn = 0;
|
|
89
132
|
for block in document.blocks() {
|
|
90
133
|
match block {
|
|
91
134
|
Block::Heading { level, text } => {
|
|
92
135
|
let style = format!("Heading{level}");
|
|
93
|
-
simple_paragraph(&mut xml, Some(&style), text, Look::default());
|
|
136
|
+
simple_paragraph(&mut xml, Some(&style), text, Look::default(), rtl);
|
|
137
|
+
}
|
|
138
|
+
Block::Paragraph(content) => paragraph(&mut xml, content, rtl),
|
|
139
|
+
Block::Table(content) => table(&mut xml, content, document, rtl),
|
|
140
|
+
Block::Picture(_) | Block::Symbol(_) => {
|
|
141
|
+
drawn += 1;
|
|
142
|
+
let relationship = format!("rId{}", first_media + drawn - 1);
|
|
143
|
+
picture::paragraph(&mut xml, pictures[drawn - 1], room, drawn, &relationship);
|
|
94
144
|
}
|
|
95
|
-
Block::Paragraph(content) => paragraph(&mut xml, content),
|
|
96
|
-
Block::Table(content) => table(&mut xml, content, document),
|
|
97
145
|
Block::PageBreak => {
|
|
98
146
|
xml.open("w:p", &[]);
|
|
99
147
|
xml.open("w:r", &[]);
|
|
@@ -107,25 +155,37 @@ fn body(document: &Document) -> String {
|
|
|
107
155
|
if matches!(document.blocks().last(), Some(Block::Table(_))) {
|
|
108
156
|
xml.empty("w:p", &[]);
|
|
109
157
|
}
|
|
110
|
-
section(&mut xml, document);
|
|
158
|
+
section(&mut xml, document, rtl);
|
|
111
159
|
xml.close("w:body");
|
|
112
160
|
xml.close("w:document");
|
|
113
161
|
xml.finish()
|
|
114
162
|
}
|
|
115
163
|
|
|
116
|
-
|
|
117
|
-
|
|
164
|
+
/// Each run reads in the direction of its own first letter, as Word sets
|
|
165
|
+
/// them: the Latin runs of a Hebrew paragraph left to right, the Arabic
|
|
166
|
+
/// ones of an English paragraph right to left.
|
|
167
|
+
fn paragraph(xml: &mut XmlWriter, content: &Paragraph, document_rtl: bool) {
|
|
168
|
+
let rtl = right_to_left(content.effective_direction(), document_rtl);
|
|
118
169
|
open_paragraph(xml, None, rtl, content.align);
|
|
119
170
|
for item in &content.runs {
|
|
120
|
-
run(xml, &item.text, Look::of(item), rtl);
|
|
171
|
+
run(xml, &item.text, Look::of(item), right_to_left(TextDirection::of(&item.text), rtl));
|
|
121
172
|
}
|
|
122
173
|
xml.close("w:p");
|
|
123
174
|
}
|
|
124
175
|
|
|
125
|
-
///
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
176
|
+
/// Whether `direction` is right to left; `otherwise` when there is none:
|
|
177
|
+
/// text with no letter (figures, punctuation) takes the direction of what
|
|
178
|
+
/// holds it.
|
|
179
|
+
fn right_to_left(direction: Option<TextDirection>, otherwise: bool) -> bool {
|
|
180
|
+
direction.map_or(otherwise, |direction| direction == TextDirection::RightToLeft)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// A one-run paragraph straight from borrowed text: headings and table
|
|
184
|
+
/// cells, in the direction of their first letter, or of what holds them.
|
|
185
|
+
fn simple_paragraph(xml: &mut XmlWriter, style: Option<&str>, text: &str, look: Look, rtl: bool) {
|
|
186
|
+
let rtl = right_to_left(TextDirection::of(text), rtl);
|
|
187
|
+
open_paragraph(xml, style, rtl, None);
|
|
188
|
+
run(xml, text, look, rtl);
|
|
129
189
|
xml.close("w:p");
|
|
130
190
|
}
|
|
131
191
|
|
|
@@ -207,16 +267,20 @@ fn run(xml: &mut XmlWriter, text: &str, look: Look, rtl: bool) {
|
|
|
207
267
|
xml.close("w:r");
|
|
208
268
|
}
|
|
209
269
|
|
|
210
|
-
fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
270
|
+
fn table(xml: &mut XmlWriter, content: &Table, document: &Document, document_rtl: bool) {
|
|
271
|
+
let rtl = right_to_left(content.direction(), document_rtl);
|
|
211
272
|
let columns = content.columns();
|
|
212
273
|
let (page_width, _) = document.page_size().twips();
|
|
213
|
-
let usable = page_width - 2 *
|
|
274
|
+
let usable = page_width - 2 * margin(document);
|
|
214
275
|
// Never 0: a table has at least one column.
|
|
215
276
|
let column_width = (usable / u32::try_from(columns).unwrap_or(1).max(1)).to_string();
|
|
216
277
|
|
|
217
278
|
xml.open("w:tbl", &[]);
|
|
218
279
|
xml.open("w:tblPr", &[]);
|
|
219
280
|
xml.empty("w:tblStyle", &[("w:val", "TableGrid")]);
|
|
281
|
+
if rtl {
|
|
282
|
+
xml.empty("w:bidiVisual", &[]);
|
|
283
|
+
}
|
|
220
284
|
xml.empty("w:tblW", &[("w:w", "5000"), ("w:type", "pct")]);
|
|
221
285
|
xml.close("w:tblPr");
|
|
222
286
|
xml.open("w:tblGrid", &[]);
|
|
@@ -244,7 +308,7 @@ fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
|
244
308
|
}
|
|
245
309
|
xml.close("w:tcPr");
|
|
246
310
|
let text = row.get(column).map_or("", String::as_str);
|
|
247
|
-
simple_paragraph(xml, None, text, Look { bold: header, ..Look::default() });
|
|
311
|
+
simple_paragraph(xml, None, text, Look { bold: header, ..Look::default() }, rtl);
|
|
248
312
|
xml.close("w:tc");
|
|
249
313
|
}
|
|
250
314
|
xml.close("w:tr");
|
|
@@ -252,9 +316,17 @@ fn table(xml: &mut XmlWriter, content: &Table, document: &Document) {
|
|
|
252
316
|
xml.close("w:tbl");
|
|
253
317
|
}
|
|
254
318
|
|
|
255
|
-
|
|
319
|
+
/// The document's margin in twips.
|
|
320
|
+
fn margin(document: &Document) -> u32 {
|
|
321
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "0 to 2880")]
|
|
322
|
+
let twips = (document.margin() * 20.0).round() as u32;
|
|
323
|
+
twips
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn section(xml: &mut XmlWriter, document: &Document, rtl: bool) {
|
|
256
327
|
let (width, height) = document.page_size().twips();
|
|
257
|
-
let (width, height, margin) =
|
|
328
|
+
let (width, height, margin) =
|
|
329
|
+
(width.to_string(), height.to_string(), margin(document).to_string());
|
|
258
330
|
xml.open("w:sectPr", &[]);
|
|
259
331
|
if document.has_page_numbers() {
|
|
260
332
|
// rId2: the footer, after the styles (see `write`).
|
|
@@ -264,6 +336,9 @@ fn section(xml: &mut XmlWriter, document: &Document) {
|
|
|
264
336
|
let margins =
|
|
265
337
|
[("w:top", &margin), ("w:right", &margin), ("w:bottom", &margin), ("w:left", &margin)];
|
|
266
338
|
xml.empty("w:pgMar", &margins.map(|(key, value)| (key, value.as_str())));
|
|
339
|
+
if rtl {
|
|
340
|
+
xml.empty("w:bidi", &[]);
|
|
341
|
+
}
|
|
267
342
|
xml.close("w:sectPr");
|
|
268
343
|
}
|
|
269
344
|
|
|
@@ -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",
|
|
@@ -76,6 +88,12 @@ pub enum Error {
|
|
|
76
88
|
#[error("a table row needs {0:.0}pt, more than a whole page holds")]
|
|
77
89
|
RowTooTall(f64),
|
|
78
90
|
|
|
91
|
+
#[error("invalid {symbology} barcode: {reason}")]
|
|
92
|
+
InvalidBarcode { symbology: &'static str, reason: String },
|
|
93
|
+
|
|
94
|
+
#[error("invalid GS1 element string: {0}")]
|
|
95
|
+
InvalidElementString(String),
|
|
96
|
+
|
|
79
97
|
#[error("a workbook needs at least one sheet")]
|
|
80
98
|
EmptyWorkbook,
|
|
81
99
|
|