imprimatur 0.2.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 +22 -0
- data/Cargo.lock +123 -3
- data/Cargo.toml +1 -1
- data/README.md +98 -20
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +6 -1
- data/ext/imprimatur_native/core/README.md +12 -3
- data/ext/imprimatur_native/core/src/archive.rs +3 -16
- 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 +12 -0
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +3 -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 +75 -2
- 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 +4 -1
- 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 +3 -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 +1 -3
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +38 -1
- data/ext/imprimatur_native/ffi/src/read.rs +8 -11
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -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 +15 -3
- metadata +11 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
//! Pictures: PNG or JPEG files, read once for what PDF and DOCX need.
|
|
2
|
+
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
|
|
5
|
+
use crate::style::Align;
|
|
6
|
+
use crate::{Error, Result};
|
|
7
|
+
|
|
8
|
+
/// Pixels per point at 96 pixels an inch: a picture's size when none is set.
|
|
9
|
+
const POINTS_PER_PIXEL: f64 = 0.75;
|
|
10
|
+
/// The largest side, in points, a picture may be set to: PDF's page limit.
|
|
11
|
+
const MAX_POINTS: f64 = 14_400.0;
|
|
12
|
+
/// The largest side, in pixels, a picture may have.
|
|
13
|
+
const MAX_PIXELS: u32 = 1 << 16;
|
|
14
|
+
|
|
15
|
+
/// A PNG or JPEG drawn in the document's flow.
|
|
16
|
+
///
|
|
17
|
+
/// It is drawn at the width or height set (in points, the other following
|
|
18
|
+
/// its proportions), or else at 96 pixels an inch, never larger than the
|
|
19
|
+
/// page leaves room for.
|
|
20
|
+
///
|
|
21
|
+
/// ```no_run
|
|
22
|
+
/// use imprimatur::doc::{Document, Picture};
|
|
23
|
+
/// use imprimatur::style::Align;
|
|
24
|
+
///
|
|
25
|
+
/// let mut doc = Document::new();
|
|
26
|
+
/// doc.picture(Picture::new(std::fs::read("logo.png")?)?.width(144.0)?.align(Align::Center));
|
|
27
|
+
/// # Ok::<(), imprimatur::Error>(())
|
|
28
|
+
/// ```
|
|
29
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
30
|
+
pub struct Picture {
|
|
31
|
+
pub(crate) data: Arc<[u8]>,
|
|
32
|
+
pub(crate) kind: Kind,
|
|
33
|
+
pub(crate) pixels: (u32, u32),
|
|
34
|
+
width: Option<f64>,
|
|
35
|
+
height: Option<f64>,
|
|
36
|
+
pub(crate) align: Option<Align>,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The format, and what writing it takes.
|
|
40
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
41
|
+
pub enum Kind {
|
|
42
|
+
Png(Png),
|
|
43
|
+
/// Gray (1), RGB (3) or CMYK (4) components; Adobe's CMYK is inverted.
|
|
44
|
+
Jpeg {
|
|
45
|
+
components: u8,
|
|
46
|
+
inverted: bool,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// A PNG's pixel layout and its compressed pixels (its `IDAT` chunks).
|
|
51
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
52
|
+
pub struct Png {
|
|
53
|
+
pub depth: u8,
|
|
54
|
+
/// 0 gray, 2 RGB, 3 palette, 4 gray and alpha, 6 RGB and alpha.
|
|
55
|
+
pub color: u8,
|
|
56
|
+
pub palette: Vec<u8>,
|
|
57
|
+
/// The `tRNS` chunk: a palette's alphas, or the transparent color.
|
|
58
|
+
pub transparency: Vec<u8>,
|
|
59
|
+
pub compressed: Vec<u8>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Picture {
|
|
63
|
+
/// # Errors
|
|
64
|
+
/// [`Error::InvalidImage`] for data that is no PNG or JPEG, a damaged
|
|
65
|
+
/// one, an interlaced PNG, a JPEG other than 8 bits a sample, or one
|
|
66
|
+
/// larger than 65 536 pixels a side.
|
|
67
|
+
pub fn new(data: impl Into<Vec<u8>>) -> Result<Self> {
|
|
68
|
+
let data: Arc<[u8]> = data.into().into();
|
|
69
|
+
let (kind, pixels) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
|
70
|
+
png(&data)?
|
|
71
|
+
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
|
72
|
+
jpeg(&data)?
|
|
73
|
+
} else {
|
|
74
|
+
return Err(Error::InvalidImage("not a PNG or JPEG image"));
|
|
75
|
+
};
|
|
76
|
+
let valid = (1..=MAX_PIXELS).contains(&pixels.0) && (1..=MAX_PIXELS).contains(&pixels.1);
|
|
77
|
+
valid.ok_or(Error::InvalidImage("a picture of 1 to 65 536 pixels a side"))?;
|
|
78
|
+
Ok(Self { data, kind, pixels, width: None, height: None, align: None })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Its width in points; the height follows unless set too.
|
|
82
|
+
///
|
|
83
|
+
/// # Errors
|
|
84
|
+
/// [`Error::InvalidImage`] outside 1 to 14 400 points.
|
|
85
|
+
pub fn width(mut self, points: f64) -> Result<Self> {
|
|
86
|
+
self.width = Some(points_in_range(points)?);
|
|
87
|
+
Ok(self)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Its height in points; the width follows unless set too.
|
|
91
|
+
///
|
|
92
|
+
/// # Errors
|
|
93
|
+
/// [`Error::InvalidImage`] outside 1 to 14 400 points.
|
|
94
|
+
pub fn height(mut self, points: f64) -> Result<Self> {
|
|
95
|
+
self.height = Some(points_in_range(points)?);
|
|
96
|
+
Ok(self)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[must_use]
|
|
100
|
+
pub const fn align(mut self, align: Align) -> Self {
|
|
101
|
+
self.align = Some(align);
|
|
102
|
+
self
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// The size it is drawn at, in points, fitted into `room` (the width
|
|
106
|
+
/// and height between the margins).
|
|
107
|
+
pub(crate) fn size(&self, room: (f64, f64)) -> (f64, f64) {
|
|
108
|
+
let (pixels_wide, pixels_high) = (f64::from(self.pixels.0), f64::from(self.pixels.1));
|
|
109
|
+
let (width, height) = match (self.width, self.height) {
|
|
110
|
+
(Some(width), Some(height)) => (width, height),
|
|
111
|
+
(Some(width), None) => (width, width * pixels_high / pixels_wide),
|
|
112
|
+
(None, Some(height)) => (height * pixels_wide / pixels_high, height),
|
|
113
|
+
(None, None) => (pixels_wide * POINTS_PER_PIXEL, pixels_high * POINTS_PER_PIXEL),
|
|
114
|
+
};
|
|
115
|
+
let scale = (room.0 / width).min(room.1 / height).min(1.0);
|
|
116
|
+
(width * scale, height * scale)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn points_in_range(points: f64) -> Result<f64> {
|
|
121
|
+
(1.0..=MAX_POINTS)
|
|
122
|
+
.contains(&points)
|
|
123
|
+
.then_some(points)
|
|
124
|
+
.ok_or(Error::InvalidImage("a picture of 1 to 14 400 points a side"))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// A PNG's chunks; interlaced ones are refused.
|
|
128
|
+
fn png(data: &[u8]) -> Result<(Kind, (u32, u32))> {
|
|
129
|
+
let damaged = || Error::InvalidImage("a damaged PNG");
|
|
130
|
+
let mut png = Png {
|
|
131
|
+
depth: 0,
|
|
132
|
+
color: 0,
|
|
133
|
+
palette: Vec::new(),
|
|
134
|
+
transparency: Vec::new(),
|
|
135
|
+
compressed: Vec::new(),
|
|
136
|
+
};
|
|
137
|
+
let (mut pixels, mut at) = (None, 8);
|
|
138
|
+
while let Some(head) = data.get(at..at + 8) {
|
|
139
|
+
let length = u32::from_be_bytes([head[0], head[1], head[2], head[3]]) as usize;
|
|
140
|
+
let body = data.get(at + 8..at + 8 + length).ok_or_else(damaged)?;
|
|
141
|
+
match &head[4..8] {
|
|
142
|
+
b"IHDR" if body.len() == 13 => {
|
|
143
|
+
let side = |at: usize| {
|
|
144
|
+
u32::from_be_bytes([body[at], body[at + 1], body[at + 2], body[at + 3]])
|
|
145
|
+
};
|
|
146
|
+
pixels = Some((side(0), side(4)));
|
|
147
|
+
(png.depth, png.color) = (body[8], body[9]);
|
|
148
|
+
(body[12] == 0).ok_or(Error::InvalidImage("an interlaced PNG"))?;
|
|
149
|
+
}
|
|
150
|
+
b"PLTE" => png.palette = body.to_vec(),
|
|
151
|
+
b"tRNS" => png.transparency = body.to_vec(),
|
|
152
|
+
b"IDAT" => png.compressed.extend_from_slice(body),
|
|
153
|
+
b"IEND" => break,
|
|
154
|
+
_ => {}
|
|
155
|
+
}
|
|
156
|
+
at += 12 + length;
|
|
157
|
+
}
|
|
158
|
+
let layout = matches!(
|
|
159
|
+
(png.color, png.depth),
|
|
160
|
+
(0, 1 | 2 | 4 | 8 | 16) | (3, 1 | 2 | 4 | 8) | (2 | 4 | 6, 8 | 16)
|
|
161
|
+
);
|
|
162
|
+
let palette = png.color != 3 || !png.palette.is_empty();
|
|
163
|
+
let pixels = pixels.filter(|_| layout && palette && !png.compressed.is_empty());
|
|
164
|
+
Ok((Kind::Png(png), pixels.ok_or_else(damaged)?))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// A JPEG's frame header (`SOF`), and Adobe's mark, which inverts CMYK.
|
|
168
|
+
fn jpeg(data: &[u8]) -> Result<(Kind, (u32, u32))> {
|
|
169
|
+
let damaged = || Error::InvalidImage("a damaged JPEG");
|
|
170
|
+
let (mut at, mut inverted) = (2, false);
|
|
171
|
+
loop {
|
|
172
|
+
let &[0xFF, marker, high, low] = data.get(at..at + 4).ok_or_else(damaged)? else {
|
|
173
|
+
return Err(damaged());
|
|
174
|
+
};
|
|
175
|
+
let length = usize::from(u16::from_be_bytes([high, low]));
|
|
176
|
+
if marker == 0xEE && data.get(at + 4..at + 9) == Some(b"Adobe") {
|
|
177
|
+
inverted = true;
|
|
178
|
+
}
|
|
179
|
+
// SOF0 to SOF15, but DHT, JPG and DAC share the range.
|
|
180
|
+
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
|
|
181
|
+
let frame = data.get(at + 4..at + 10).ok_or_else(damaged)?;
|
|
182
|
+
(frame[0] == 8).ok_or(Error::InvalidImage("a JPEG of 8 bits a sample"))?;
|
|
183
|
+
let height = u32::from(u16::from_be_bytes([frame[1], frame[2]]));
|
|
184
|
+
let width = u32::from(u16::from_be_bytes([frame[3], frame[4]]));
|
|
185
|
+
let components = frame[5];
|
|
186
|
+
matches!(components, 1 | 3 | 4).ok_or_else(damaged)?;
|
|
187
|
+
let kind = Kind::Jpeg { components, inverted: inverted && components == 4 };
|
|
188
|
+
return Ok((kind, (width, height)));
|
|
189
|
+
}
|
|
190
|
+
at += 2 + length;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
use crate::script::Counts;
|
|
2
|
+
use crate::style::TextDirection;
|
|
3
|
+
|
|
1
4
|
/// A grid of text cells; columns share the page width equally. Short rows
|
|
2
5
|
/// are padded with empty cells.
|
|
6
|
+
///
|
|
7
|
+
/// Each cell reads in the direction of its first letter, a cell of figures
|
|
8
|
+
/// in the table's; a table mostly right to left has its first column on the
|
|
9
|
+
/// right.
|
|
3
10
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
4
11
|
pub struct Table {
|
|
5
12
|
pub(crate) rows: Vec<Vec<String>>,
|
|
@@ -38,4 +45,18 @@ impl Table {
|
|
|
38
45
|
pub(crate) const fn has_header(&self) -> bool {
|
|
39
46
|
self.header
|
|
40
47
|
}
|
|
48
|
+
|
|
49
|
+
/// The direction most of the table's letters read in; `None` without a
|
|
50
|
+
/// letter.
|
|
51
|
+
pub(crate) fn direction(&self) -> Option<TextDirection> {
|
|
52
|
+
let mut counts = Counts::default();
|
|
53
|
+
self.count(&mut counts);
|
|
54
|
+
counts.direction()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub(crate) fn count(&self, counts: &mut Counts) {
|
|
58
|
+
for cell in self.rows.iter().flatten() {
|
|
59
|
+
counts.add(cell);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
41
62
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
//! ```
|
|
23
23
|
|
|
24
24
|
mod paragraph;
|
|
25
|
+
pub(crate) mod picture;
|
|
25
26
|
mod table;
|
|
26
27
|
#[cfg(test)]
|
|
27
28
|
mod tests;
|
|
@@ -31,24 +32,49 @@ use std::io::{BufWriter, Write};
|
|
|
31
32
|
use std::path::Path;
|
|
32
33
|
|
|
33
34
|
pub use paragraph::{Paragraph, Run};
|
|
35
|
+
pub use picture::Picture;
|
|
34
36
|
pub use table::Table;
|
|
35
37
|
|
|
38
|
+
use crate::script::Counts;
|
|
36
39
|
use crate::{Error, Result};
|
|
37
40
|
|
|
38
|
-
/// Paper size; pages are portrait.
|
|
41
|
+
/// Paper size; A4 and Letter pages are portrait.
|
|
39
42
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
40
43
|
pub enum PageSize {
|
|
41
44
|
#[default]
|
|
42
45
|
A4,
|
|
43
46
|
Letter,
|
|
47
|
+
/// Width and height in hundredths of a point; see [`PageSize::custom`].
|
|
48
|
+
Custom(u32, u32),
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
impl PageSize {
|
|
52
|
+
/// A page of this width and height, in points (1/72 inch): 36 to 1584
|
|
53
|
+
/// (half an inch to 22 inches), as both PDF and Word take them.
|
|
54
|
+
///
|
|
55
|
+
/// # Errors
|
|
56
|
+
/// [`Error::InvalidPageSize`] outside that range.
|
|
57
|
+
pub fn custom(width: f64, height: f64) -> Result<Self> {
|
|
58
|
+
let side = |points: f64| {
|
|
59
|
+
#[expect(
|
|
60
|
+
clippy::cast_possible_truncation,
|
|
61
|
+
clippy::cast_sign_loss,
|
|
62
|
+
reason = "36 to 1584"
|
|
63
|
+
)]
|
|
64
|
+
(36.0..=1584.0).contains(&points).then(|| (points * 100.0).round() as u32)
|
|
65
|
+
};
|
|
66
|
+
side(width)
|
|
67
|
+
.zip(side(height))
|
|
68
|
+
.map(|(width, height)| Self::Custom(width, height))
|
|
69
|
+
.ok_or(Error::InvalidPageSize(width, height))
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/// Width and height in PDF points (1/72 inch).
|
|
48
|
-
pub(crate)
|
|
73
|
+
pub(crate) fn points(self) -> (f64, f64) {
|
|
49
74
|
match self {
|
|
50
75
|
Self::A4 => (595.276, 841.89),
|
|
51
76
|
Self::Letter => (612.0, 792.0),
|
|
77
|
+
Self::Custom(width, height) => (f64::from(width) / 100.0, f64::from(height) / 100.0),
|
|
52
78
|
}
|
|
53
79
|
}
|
|
54
80
|
|
|
@@ -57,6 +83,7 @@ impl PageSize {
|
|
|
57
83
|
match self {
|
|
58
84
|
Self::A4 => (11_906, 16_838),
|
|
59
85
|
Self::Letter => (12_240, 15_840),
|
|
86
|
+
Self::Custom(width, height) => (width / 5, height / 5),
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
}
|
|
@@ -68,6 +95,7 @@ pub enum Block {
|
|
|
68
95
|
Heading { level: u8, text: String },
|
|
69
96
|
Paragraph(Paragraph),
|
|
70
97
|
Table(Table),
|
|
98
|
+
Picture(Picture),
|
|
71
99
|
PageBreak,
|
|
72
100
|
}
|
|
73
101
|
|
|
@@ -78,6 +106,8 @@ pub struct Document {
|
|
|
78
106
|
blocks: Vec<Block>,
|
|
79
107
|
page_size: PageSize,
|
|
80
108
|
page_numbers: bool,
|
|
109
|
+
/// Hundredths of a point; one inch when unset.
|
|
110
|
+
margin: Option<u32>,
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
impl Document {
|
|
@@ -91,6 +121,43 @@ impl Document {
|
|
|
91
121
|
self
|
|
92
122
|
}
|
|
93
123
|
|
|
124
|
+
/// The margin on every side, in points: one inch (72) unless set.
|
|
125
|
+
///
|
|
126
|
+
/// # Errors
|
|
127
|
+
/// [`Error::InvalidMargin`] outside 0 to 144 points.
|
|
128
|
+
pub fn set_margin(&mut self, points: f64) -> Result<&mut Self> {
|
|
129
|
+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "0 to 144")]
|
|
130
|
+
let margin = (0.0..=144.0)
|
|
131
|
+
.contains(&points)
|
|
132
|
+
.then(|| (points * 100.0).round() as u32)
|
|
133
|
+
.ok_or(Error::InvalidMargin(points))?;
|
|
134
|
+
self.margin = Some(margin);
|
|
135
|
+
Ok(self)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The margin on every side, in points.
|
|
139
|
+
#[must_use]
|
|
140
|
+
pub fn margin(&self) -> f64 {
|
|
141
|
+
self.margin.map_or(72.0, |margin| f64::from(margin) / 100.0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// The width and height between the margins, in points.
|
|
145
|
+
///
|
|
146
|
+
/// # Errors
|
|
147
|
+
/// [`Error::InvalidMargin`] when the margins leave no room.
|
|
148
|
+
pub(crate) fn room(&self) -> Result<(f64, f64)> {
|
|
149
|
+
let (width, height) = self.page_size.points();
|
|
150
|
+
let margin = self.margin();
|
|
151
|
+
let room = (2.0f64.mul_add(-margin, width), 2.0f64.mul_add(-margin, height));
|
|
152
|
+
(room.0 >= 1.0 && room.1 >= 1.0).then_some(room).ok_or(Error::InvalidMargin(margin))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// A picture, in the flow of the text.
|
|
156
|
+
pub fn picture(&mut self, picture: Picture) -> &mut Self {
|
|
157
|
+
self.blocks.push(Block::Picture(picture));
|
|
158
|
+
self
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/// Numbers pages `1 / 3` at the bottom of each one.
|
|
95
162
|
pub const fn set_page_numbers(&mut self, enabled: bool) -> &mut Self {
|
|
96
163
|
self.page_numbers = enabled;
|
|
@@ -144,6 +211,23 @@ impl Document {
|
|
|
144
211
|
self
|
|
145
212
|
}
|
|
146
213
|
|
|
214
|
+
/// Whether most of the document's letters are right to left: its pages
|
|
215
|
+
/// are then laid out right to left.
|
|
216
|
+
pub(crate) fn reads_right_to_left(&self) -> bool {
|
|
217
|
+
let mut counts = Counts::default();
|
|
218
|
+
for block in &self.blocks {
|
|
219
|
+
match block {
|
|
220
|
+
Block::Heading { text, .. } => counts.add(text),
|
|
221
|
+
Block::Paragraph(paragraph) => {
|
|
222
|
+
paragraph.runs.iter().for_each(|run| counts.add(&run.text));
|
|
223
|
+
}
|
|
224
|
+
Block::Table(table) => table.count(&mut counts),
|
|
225
|
+
Block::Picture(_) | Block::PageBreak => {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
counts.right_to_left()
|
|
229
|
+
}
|
|
230
|
+
|
|
147
231
|
#[must_use]
|
|
148
232
|
pub fn blocks(&self) -> &[Block] {
|
|
149
233
|
&self.blocks
|
|
@@ -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
|
|