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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +153 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +139 -21
  6. data/ext/imprimatur_native/cli/Cargo.toml +3 -1
  7. data/ext/imprimatur_native/core/Cargo.toml +20 -4
  8. data/ext/imprimatur_native/core/README.md +36 -4
  9. data/ext/imprimatur_native/core/src/archive.rs +3 -16
  10. data/ext/imprimatur_native/core/src/barcode/code128.rs +162 -0
  11. data/ext/imprimatur_native/core/src/barcode/draw.rs +124 -0
  12. data/ext/imprimatur_native/core/src/barcode/ean.rs +91 -0
  13. data/ext/imprimatur_native/core/src/barcode/gs1.rs +188 -0
  14. data/ext/imprimatur_native/core/src/barcode/matrix.rs +130 -0
  15. data/ext/imprimatur_native/core/src/barcode.rs +314 -0
  16. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  17. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  18. data/ext/imprimatur_native/core/src/doc/symbol.rs +136 -0
  19. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  20. data/ext/imprimatur_native/core/src/doc.rs +96 -2
  21. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +103 -28
  23. data/ext/imprimatur_native/core/src/error.rs +18 -0
  24. data/ext/imprimatur_native/core/src/font.rs +231 -0
  25. data/ext/imprimatur_native/core/src/lib.rs +6 -1
  26. data/ext/imprimatur_native/core/src/pdf/layout.rs +72 -15
  27. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  28. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  29. data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +129 -0
  30. data/ext/imprimatur_native/core/src/pdf/truetype/program.rs +70 -0
  31. data/ext/imprimatur_native/core/src/pdf/truetype.rs +149 -0
  32. data/ext/imprimatur_native/core/src/pdf.rs +40 -4
  33. data/ext/imprimatur_native/core/src/read/content.rs +75 -2
  34. data/ext/imprimatur_native/core/src/read/image.rs +17 -0
  35. data/ext/imprimatur_native/core/src/read/json.rs +94 -7
  36. data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
  37. data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
  38. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  39. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  40. data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
  41. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  42. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
  43. data/ext/imprimatur_native/core/src/read.rs +5 -1
  44. data/ext/imprimatur_native/core/src/script.rs +116 -0
  45. data/ext/imprimatur_native/core/src/style.rs +32 -0
  46. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  47. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  48. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  49. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  50. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  51. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  52. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  53. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  54. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  55. data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
  56. data/ext/imprimatur_native/ffi/src/barcode.rs +147 -0
  57. data/ext/imprimatur_native/ffi/src/document.rs +76 -1
  58. data/ext/imprimatur_native/ffi/src/lib.rs +40 -1
  59. data/ext/imprimatur_native/ffi/src/read.rs +8 -11
  60. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  61. data/lib/imprimatur/barcode.rb +78 -0
  62. data/lib/imprimatur/document.rb +46 -6
  63. data/lib/imprimatur/reader.rb +2 -2
  64. data/lib/imprimatur/spreadsheet.rb +151 -0
  65. data/lib/imprimatur/version.rb +1 -1
  66. data/lib/imprimatur/xlsx.rb +25 -4
  67. data/lib/imprimatur.rb +19 -3
  68. metadata +23 -2
  69. 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) const fn points(self) -> (f64, f64) {
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
- fn content_types(footer: bool) -> String {
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
- opc::content_types(&[], &overrides)
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(document: &Document) -> String {
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
- xml.open("w:document", &[("xmlns:w", W_NS), ("xmlns:r", opc::RELATIONSHIPS)]);
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
- fn paragraph(xml: &mut XmlWriter, content: &Paragraph) {
117
- let rtl = content.direction == Some(TextDirection::RightToLeft);
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
- /// A one-run paragraph straight from borrowed text: headings and table cells.
126
- fn simple_paragraph(xml: &mut XmlWriter, style: Option<&str>, text: &str, look: Look) {
127
- open_paragraph(xml, style, false, None);
128
- run(xml, text, look, false);
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 * MARGIN;
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
- fn section(xml: &mut XmlWriter, document: &Document) {
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) = (width.to_string(), height.to_string(), MARGIN.to_string());
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