imprimatur 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +124 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +123 -23
  6. data/ext/imprimatur_native/cli/Cargo.toml +2 -2
  7. data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
  8. data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
  9. data/ext/imprimatur_native/cli/src/cli.rs +5 -5
  10. data/ext/imprimatur_native/cli/src/main.rs +1 -1
  11. data/ext/imprimatur_native/core/Cargo.toml +12 -2
  12. data/ext/imprimatur_native/core/README.md +15 -1
  13. data/ext/imprimatur_native/core/src/archive.rs +279 -0
  14. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  15. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  16. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  17. data/ext/imprimatur_native/core/src/doc.rs +86 -2
  18. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  19. data/ext/imprimatur_native/core/src/docx.rs +91 -28
  20. data/ext/imprimatur_native/core/src/error.rs +13 -1
  21. data/ext/imprimatur_native/core/src/font.rs +229 -0
  22. data/ext/imprimatur_native/core/src/lib.rs +5 -0
  23. data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
  24. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  25. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  26. data/ext/imprimatur_native/core/src/pdf.rs +31 -3
  27. data/ext/imprimatur_native/core/src/read/content.rs +91 -3
  28. data/ext/imprimatur_native/core/src/read/json.rs +96 -7
  29. data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
  30. data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
  31. data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
  32. data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
  33. data/ext/imprimatur_native/core/src/read/package.rs +0 -5
  34. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  35. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  36. data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
  37. data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
  38. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  39. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
  40. data/ext/imprimatur_native/core/src/read.rs +68 -21
  41. data/ext/imprimatur_native/core/src/script.rs +116 -0
  42. data/ext/imprimatur_native/core/src/style.rs +21 -0
  43. data/ext/imprimatur_native/core/src/test_support.rs +90 -2
  44. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  45. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  46. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  47. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  48. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  49. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  50. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  51. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  52. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  53. data/ext/imprimatur_native/ffi/src/archive.rs +119 -0
  54. data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
  55. data/ext/imprimatur_native/ffi/src/document.rs +48 -1
  56. data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
  57. data/ext/imprimatur_native/ffi/src/read.rs +9 -14
  58. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  59. data/lib/imprimatur/document.rb +30 -6
  60. data/lib/imprimatur/error.rb +4 -3
  61. data/lib/imprimatur/reader.rb +3 -3
  62. data/lib/imprimatur/spreadsheet.rb +151 -0
  63. data/lib/imprimatur/version.rb +1 -1
  64. data/lib/imprimatur/xlsx.rb +25 -4
  65. data/lib/imprimatur/zip.rb +127 -0
  66. data/lib/imprimatur.rb +18 -5
  67. metadata +23 -4
@@ -8,13 +8,11 @@ use std::fmt::{self, Arguments, Write as _};
8
8
 
9
9
  use super::font::Face;
10
10
  use super::objects::{Num, push_string};
11
- use crate::doc::{PageSize, Paragraph, Table};
11
+ use crate::doc::{Document, Paragraph, Picture, Table};
12
12
  use crate::style::{Align, Color};
13
13
  use crate::{Error, Result};
14
14
  use wrap::{Line, Look, wrap};
15
15
 
16
- /// One inch on every side.
17
- const MARGIN: f64 = 72.0;
18
16
  const BODY_SIZE: f64 = 11.0;
19
17
  const TABLE_SIZE: f64 = 10.0;
20
18
  /// Line height as a multiple of the font size.
@@ -32,6 +30,7 @@ const HEADINGS: [(f64, f64, Option<Color>); 3] =
32
30
  pub struct Layout {
33
31
  width: f64,
34
32
  height: f64,
33
+ margin: f64,
35
34
  pages: Vec<Vec<u8>>,
36
35
  /// The top of the free space on the current page, from the bottom edge.
37
36
  y: f64,
@@ -39,9 +38,15 @@ pub struct Layout {
39
38
  }
40
39
 
41
40
  impl Layout {
42
- pub fn new(size: PageSize) -> Self {
43
- let (width, height) = size.points();
44
- Self { width, height, pages: vec![Vec::new()], y: height - MARGIN, blank: true }
41
+ /// The document's pages, between its margins.
42
+ ///
43
+ /// # Errors
44
+ /// [`Error::InvalidMargin`] when the margins leave no room.
45
+ pub fn new(document: &Document) -> Result<Self> {
46
+ document.room()?;
47
+ let (width, height) = document.page_size().points();
48
+ let margin = document.margin();
49
+ Ok(Self { width, height, margin, pages: vec![Vec::new()], y: height - margin, blank: true })
45
50
  }
46
51
 
47
52
  pub const fn page_size(&self) -> (f64, f64) {
@@ -49,7 +54,7 @@ impl Layout {
49
54
  }
50
55
 
51
56
  const fn content_width(&self) -> f64 {
52
- (-2.0f64).mul_add(MARGIN, self.width)
57
+ (-2.0f64).mul_add(self.margin, self.width)
53
58
  }
54
59
 
55
60
  pub fn heading(&mut self, level: u8, text: &str) -> Result<()> {
@@ -105,7 +110,7 @@ impl Layout {
105
110
  }
106
111
 
107
112
  let header_height = rows.first().filter(|row| row.2).map_or(0.0, |row| row.1);
108
- let room = (-2.0f64).mul_add(MARGIN, self.height) - header_height;
113
+ let room = (-2.0f64).mul_add(self.margin, self.height) - header_height;
109
114
  for (index, (cells, height, header)) in rows.iter().enumerate() {
110
115
  (*height <= room || *header).ok_or(Error::RowTooTall(*height))?;
111
116
  // A header needs the first body row beside it.
@@ -121,6 +126,25 @@ impl Layout {
121
126
  Ok(())
122
127
  }
123
128
 
129
+ /// Draws picture `index` (`/Im{index}`), on a new page when it does not
130
+ /// fit, as its alignment says.
131
+ pub fn picture(&mut self, picture: &Picture, index: usize) {
132
+ let room = (self.content_width(), (-2.0f64).mul_add(self.margin, self.height));
133
+ let (width, height) = picture.size(room);
134
+ self.ensure_room(height);
135
+ let x = self.margin
136
+ + match picture.align.unwrap_or(Align::Left) {
137
+ Align::Left => 0.0,
138
+ Align::Center => (room.0 - width) / 2.0,
139
+ Align::Right => room.0 - width,
140
+ };
141
+ self.y -= height;
142
+ let (width, height, x, y) = (Num(width), Num(height), Num(x), Num(self.y));
143
+ emit(self.current(), format_args!("q {width} 0 0 {height} {x} {y} cm /Im{index} Do Q\n"));
144
+ self.y -= PARAGRAPH_GAP;
145
+ self.blank = false;
146
+ }
147
+
124
148
  pub fn page_break(&mut self) {
125
149
  self.new_page();
126
150
  }
@@ -129,14 +153,14 @@ impl Layout {
129
153
  pub fn finish(mut self, page_numbers: bool) -> Vec<Vec<u8>> {
130
154
  if page_numbers {
131
155
  let total = self.pages.len();
132
- let center = self.width / 2.0;
156
+ let (center, margin) = (self.width / 2.0, self.margin);
133
157
  for (index, page) in self.pages.iter_mut().enumerate() {
134
158
  let bytes = format!("{} / {total}", index + 1).into_bytes();
135
159
  let width = Face::Regular.width(&bytes, FOOTER_SIZE);
136
160
  let look =
137
161
  Look { face: Face::Regular, color: Some(FOOTER_COLOR), underline: false };
138
162
  let label = wrap::Fragment { bytes, look, width };
139
- draw_fragment(page, &label, FOOTER_SIZE, center - width / 2.0, MARGIN / 2.0);
163
+ draw_fragment(page, &label, FOOTER_SIZE, center - width / 2.0, margin / 2.0);
140
164
  }
141
165
  }
142
166
  self.pages
@@ -146,7 +170,7 @@ impl Layout {
146
170
  /// (or the page is still blank, where nothing would fit better).
147
171
  /// Returns whether it did.
148
172
  fn ensure_room(&mut self, needed: f64) -> bool {
149
- let full = !self.blank && self.y - needed < MARGIN;
173
+ let full = !self.blank && self.y - needed < self.margin;
150
174
  if full {
151
175
  self.new_page();
152
176
  }
@@ -155,7 +179,7 @@ impl Layout {
155
179
 
156
180
  fn new_page(&mut self) {
157
181
  self.pages.push(Vec::new());
158
- self.y = self.height - MARGIN;
182
+ self.y = self.height - self.margin;
159
183
  self.blank = true;
160
184
  }
161
185
 
@@ -174,7 +198,7 @@ impl Layout {
174
198
  self.ensure_room(leading);
175
199
  let baseline = self.y - baseline_offset(size);
176
200
  let slack = self.content_width() - line.width;
177
- let x = MARGIN
201
+ let x = self.margin
178
202
  + match align {
179
203
  Align::Left => 0.0,
180
204
  Align::Center => slack / 2.0,
@@ -188,9 +212,8 @@ impl Layout {
188
212
 
189
213
  /// One table row from the cursor down: fill, borders, then text.
190
214
  fn row(&mut self, cells: &[Vec<Line>], height: f64, column_width: f64, header: bool) {
191
- let top = self.y;
215
+ let (top, mut x) = (self.y, self.margin);
192
216
  let page = self.current();
193
- let mut x = MARGIN;
194
217
  for lines in cells {
195
218
  let rect = format_args!(
196
219
  "{} {} {} {} re",
@@ -1,6 +1,9 @@
1
1
  //! PDF file syntax: numbered objects, compressed streams, the
2
2
  //! cross-reference table and the trailer (ISO 32000-1, section 7).
3
3
 
4
+ #[cfg(test)]
5
+ mod tests;
6
+
4
7
  use std::fmt;
5
8
  use std::io::Write;
6
9
 
@@ -34,13 +37,34 @@ impl<W: Write> PdfWriter<W> {
34
37
  self.write_fmt(format_args!("{dictionary}\nendobj\n"))
35
38
  }
36
39
 
37
- /// Writes a Flate-compressed stream object.
38
- pub fn stream(&mut self, id: usize, data: &[u8]) -> Result<()> {
40
+ /// Writes a Flate-compressed stream object with optional dictionary entries.
41
+ /// Names omit the leading `/`; values are already PDF syntax. For example,
42
+ /// embedded TrueType fonts need `Length1` to hold the decoded byte count.
43
+ /// The writer supplies `Length` and `Filter`; entries must not repeat them.
44
+ pub fn stream(
45
+ &mut self,
46
+ id: usize,
47
+ data: &[u8],
48
+ entries: &[(&str, &dyn fmt::Display)],
49
+ ) -> Result<()> {
39
50
  let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, LEVEL);
51
+ let dictionary = fmt::from_fn(|f| {
52
+ f.write_str("/Filter /FlateDecode")?;
53
+ for (name, value) in entries {
54
+ write!(f, " /{name} {value}")?;
55
+ }
56
+ Ok(())
57
+ });
58
+ self.stored(id, format_args!("{dictionary}"), &compressed)
59
+ }
60
+
61
+ /// Writes a stream object as it is stored, `dictionary` following its
62
+ /// `/Length`.
63
+ pub fn stored(&mut self, id: usize, dictionary: fmt::Arguments<'_>, data: &[u8]) -> Result<()> {
40
64
  self.begin(id)?;
41
- let length = compressed.len();
42
- self.write_fmt(format_args!("<< /Length {length} /Filter /FlateDecode >>\nstream\n"))?;
43
- self.write(&compressed)?;
65
+ let length = data.len();
66
+ self.write_fmt(format_args!("<< /Length {length} {dictionary} >>\nstream\n"))?;
67
+ self.write(data)?;
44
68
  self.write(b"\nendstream\nendobj\n")
45
69
  }
46
70
 
@@ -0,0 +1,197 @@
1
+ //! Pictures as image `XObject`s: a JPEG as it is; a PNG's compressed pixels
2
+ //! as they are, PDF reading them with the PNG predictor, unless an alpha
3
+ //! channel needs them decoded into a soft mask.
4
+
5
+ use std::borrow::Cow;
6
+ use std::fmt::Write as _;
7
+
8
+ use crate::doc::Picture;
9
+ use crate::doc::picture::{Kind, Png};
10
+ use crate::{Error, Result};
11
+
12
+ /// The most pixels a PNG with alpha may have: it is decoded in memory.
13
+ const MAX_DECODED: u64 = 1 << 26;
14
+
15
+ /// An image stream: its dictionary after `/Length`, and its data as stored.
16
+ pub struct Stream<'a> {
17
+ pub dictionary: String,
18
+ pub data: Cow<'a, [u8]>,
19
+ }
20
+
21
+ /// Whether the picture has a soft mask, written right after it.
22
+ pub const fn has_mask(picture: &Picture) -> bool {
23
+ match &picture.kind {
24
+ Kind::Png(png) => alpha(png),
25
+ Kind::Jpeg { .. } => false,
26
+ }
27
+ }
28
+
29
+ /// The image, then its soft mask when it has one; `mask` is the mask's
30
+ /// object number.
31
+ pub fn streams(picture: &Picture, mask: usize) -> Result<Vec<Stream<'_>>> {
32
+ let (width, height) = picture.pixels;
33
+ let head = format!("/Type /XObject /Subtype /Image /Width {width} /Height {height}");
34
+ match &picture.kind {
35
+ Kind::Jpeg { components, inverted } => {
36
+ let space = ["DeviceGray", "", "DeviceRGB", "DeviceCMYK"][usize::from(*components) - 1];
37
+ let decode = if *inverted { " /Decode [1 0 1 0 1 0 1 0]" } else { "" };
38
+ let dictionary = format!(
39
+ "{head} /ColorSpace /{space} /BitsPerComponent 8 /Filter /DCTDecode{decode}"
40
+ );
41
+ Ok(vec![Stream { dictionary, data: Cow::Borrowed(&picture.data) }])
42
+ }
43
+ Kind::Png(png) if alpha(png) => decoded(png, (width, height), &head, mask),
44
+ Kind::Png(png) => Ok(vec![predicted(png, width, &head)]),
45
+ }
46
+ }
47
+
48
+ /// Alpha that only a soft mask carries: an alpha channel, or a palette's.
49
+ const fn alpha(png: &Png) -> bool {
50
+ matches!(png.color, 4 | 6) || (png.color == 3 && !png.transparency.is_empty())
51
+ }
52
+
53
+ /// The compressed pixels as stored, read through the PNG predictor; a
54
+ /// transparent color becomes a color key mask.
55
+ fn predicted<'a>(png: &'a Png, width: u32, head: &str) -> Stream<'a> {
56
+ let depth = png.depth;
57
+ let (colors, space) = match png.color {
58
+ 0 => (1, "/DeviceGray".to_owned()),
59
+ 2 => (3, "/DeviceRGB".to_owned()),
60
+ _ => (1, indexed(&png.palette)),
61
+ };
62
+ let mut dictionary = format!(
63
+ "{head} /ColorSpace {space} /BitsPerComponent {depth} /Filter /FlateDecode \
64
+ /DecodeParms << /Predictor 15 /Colors {colors} /BitsPerComponent {depth} \
65
+ /Columns {width} >>"
66
+ );
67
+ if !png.transparency.is_empty() {
68
+ dictionary.push_str(" /Mask [");
69
+ for key in png.transparency.as_chunks::<2>().0 {
70
+ let value = u16::from_be_bytes(*key);
71
+ // Writing to a String cannot fail.
72
+ let _ = write!(dictionary, "{value} {value} ");
73
+ }
74
+ dictionary.push(']');
75
+ }
76
+ Stream { dictionary, data: Cow::Borrowed(&png.compressed) }
77
+ }
78
+
79
+ /// `[/Indexed /DeviceRGB n <palette>]`.
80
+ fn indexed(palette: &[u8]) -> String {
81
+ let mut space = format!("[/Indexed /DeviceRGB {} <", palette.len() / 3 - 1);
82
+ for byte in palette {
83
+ // Writing to a String cannot fail.
84
+ let _ = write!(space, "{byte:02X}");
85
+ }
86
+ space.push_str(">]");
87
+ space
88
+ }
89
+
90
+ /// The pixels decoded into 8-bit color and alpha, both compressed again.
91
+ fn decoded<'a>(
92
+ png: &Png,
93
+ (width, height): (u32, u32),
94
+ head: &str,
95
+ mask: usize,
96
+ ) -> Result<Vec<Stream<'a>>> {
97
+ let damaged = || Error::InvalidImage("a damaged PNG");
98
+ (u64::from(width) * u64::from(height) <= MAX_DECODED)
99
+ .ok_or(Error::InvalidImage("a PNG with alpha of at most 67 million pixels"))?;
100
+ let channels = match png.color {
101
+ 4 => 2,
102
+ 6 => 4,
103
+ _ => 1,
104
+ };
105
+ let bits = channels * usize::from(png.depth);
106
+ let (width, height) = (width as usize, height as usize);
107
+ let stride = (width * bits).div_ceil(8);
108
+ let filtered = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(
109
+ &png.compressed,
110
+ height * (stride + 1),
111
+ )
112
+ .map_err(|_| damaged())?;
113
+ let pixels = unfilter(&filtered, stride, bits.div_ceil(8), height).ok_or_else(damaged)?;
114
+ let gray = png.color == 4;
115
+ let (mut color, mut alpha) =
116
+ (Vec::with_capacity(width * height * 3), Vec::with_capacity(width * height));
117
+ for row in pixels.chunks_exact(stride) {
118
+ for x in 0..width {
119
+ if png.color == 3 {
120
+ let index = usize::from(sample(row, x, png.depth));
121
+ let rgb = png.palette.get(index * 3..index * 3 + 3).ok_or_else(damaged)?;
122
+ color.extend_from_slice(rgb);
123
+ alpha.push(png.transparency.get(index).copied().unwrap_or(u8::MAX));
124
+ } else {
125
+ // 16-bit samples keep their high byte.
126
+ let step = usize::from(png.depth / 8);
127
+ let at = |channel: usize| row[(x * channels + channel) * step];
128
+ color.extend((0..channels - 1).map(at));
129
+ alpha.push(at(channels - 1));
130
+ }
131
+ }
132
+ }
133
+ let space = if gray { "DeviceGray" } else { "DeviceRGB" };
134
+ let compress = |data: &[u8]| Cow::Owned(miniz_oxide::deflate::compress_to_vec_zlib(data, 6));
135
+ Ok(vec![
136
+ Stream {
137
+ dictionary: format!(
138
+ "{head} /ColorSpace /{space} /BitsPerComponent 8 /Filter /FlateDecode \
139
+ /SMask {mask} 0 R"
140
+ ),
141
+ data: compress(&color),
142
+ },
143
+ Stream {
144
+ dictionary: format!(
145
+ "{head} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode"
146
+ ),
147
+ data: compress(&alpha),
148
+ },
149
+ ])
150
+ }
151
+
152
+ /// The `x`th sample of a row of `depth`-bit samples.
153
+ fn sample(row: &[u8], x: usize, depth: u8) -> u8 {
154
+ let depth = usize::from(depth);
155
+ let (byte, shift) = (x * depth / 8, 8 - depth - x * depth % 8);
156
+ (row[byte] >> shift) & (u8::MAX >> (8 - depth))
157
+ }
158
+
159
+ /// PNG's filters undone (ISO/IEC 15948, 9): rows of `stride` bytes, `bpp`
160
+ /// bytes a pixel.
161
+ fn unfilter(data: &[u8], stride: usize, bpp: usize, rows: usize) -> Option<Vec<u8>> {
162
+ let mut out = vec![0_u8; stride * rows];
163
+ for row in 0..rows {
164
+ let line = data.get(row * (stride + 1)..(row + 1) * (stride + 1))?;
165
+ let (done, rest) = out.split_at_mut(row * stride);
166
+ let previous = row.checked_sub(1).map(|_| &done[done.len() - stride..]);
167
+ let current = &mut rest[..stride];
168
+ for at in 0..stride {
169
+ let left = at.checked_sub(bpp).map_or(0, |at| current[at]);
170
+ let up = previous.map_or(0, |previous| previous[at]);
171
+ let up_left = at.checked_sub(bpp).and_then(|at| previous.map(|previous| previous[at]));
172
+ let predicted = match line[0] {
173
+ 0 => 0,
174
+ 1 => left,
175
+ 2 => up,
176
+ 3 => left.midpoint(up),
177
+ 4 => paeth(left, up, up_left.unwrap_or(0)),
178
+ _ => return None,
179
+ };
180
+ current[at] = line[at + 1].wrapping_add(predicted);
181
+ }
182
+ }
183
+ Some(out)
184
+ }
185
+
186
+ /// The neighbor nearest `left + up - up_left`.
187
+ fn paeth(left: u8, up: u8, up_left: u8) -> u8 {
188
+ let estimate = i16::from(left) + i16::from(up) - i16::from(up_left);
189
+ let distance = |byte: u8| (estimate - i16::from(byte)).abs();
190
+ if distance(left) <= distance(up) && distance(left) <= distance(up_left) {
191
+ left
192
+ } else if distance(up) <= distance(up_left) {
193
+ up
194
+ } else {
195
+ up_left
196
+ }
197
+ }
@@ -7,6 +7,7 @@ mod layout;
7
7
  #[rustfmt::skip]
8
8
  pub mod metrics;
9
9
  mod objects;
10
+ mod picture;
10
11
  #[cfg(test)]
11
12
  mod tests;
12
13
 
@@ -26,12 +27,17 @@ const FIRST_FONT: usize = 3;
26
27
  const INFO: usize = FIRST_FONT + Face::ALL.len();
27
28
 
28
29
  pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
29
- let mut layout = Layout::new(document.page_size());
30
+ let mut layout = Layout::new(document)?;
31
+ let mut pictures = Vec::new();
30
32
  for block in document.blocks() {
31
33
  match block {
32
34
  Block::Heading { level, text } => layout.heading(*level, text)?,
33
35
  Block::Paragraph(paragraph) => layout.paragraph(paragraph)?,
34
36
  Block::Table(table) => layout.table(table)?,
37
+ Block::Picture(picture) => {
38
+ layout.picture(picture, pictures.len());
39
+ pictures.push(picture);
40
+ }
35
41
  Block::PageBreak => layout.page_break(),
36
42
  }
37
43
  }
@@ -67,6 +73,23 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
67
73
  }
68
74
  Ok(())
69
75
  });
76
+ // Pictures follow the pages: an image, then its soft mask if it has one.
77
+ let mut ids = Vec::with_capacity(pictures.len());
78
+ let mut next = page_id(pages.len());
79
+ for picture in &pictures {
80
+ ids.push(next);
81
+ next += 1 + usize::from(picture::has_mask(picture));
82
+ }
83
+ let images = fmt::from_fn(|f| {
84
+ if !ids.is_empty() {
85
+ f.write_str("/XObject << ")?;
86
+ for (index, id) in ids.iter().enumerate() {
87
+ write!(f, "/Im{index} {id} 0 R ")?;
88
+ }
89
+ f.write_str(">> ")?;
90
+ }
91
+ Ok(())
92
+ });
70
93
  let (width, height) = (Num(width), Num(height));
71
94
  for (index, content) in pages.iter().enumerate() {
72
95
  let id = page_id(index);
@@ -74,11 +97,16 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
74
97
  id,
75
98
  format_args!(
76
99
  "<< /Type /Page /Parent {PAGES} 0 R /MediaBox [0 0 {width} {height}] \
77
- /Resources << /Font << {fonts}>> >> /Contents {} 0 R >>",
100
+ /Resources << /Font << {fonts}>> {images}>> /Contents {} 0 R >>",
78
101
  id + 1
79
102
  ),
80
103
  )?;
81
- pdf.stream(id + 1, content)?;
104
+ pdf.stream(id + 1, content, &[])?;
105
+ }
106
+ for (picture, id) in pictures.into_iter().zip(ids) {
107
+ for (offset, stream) in (0..).zip(picture::streams(picture, id + 1)?) {
108
+ pdf.stored(id + offset, format_args!("{}", stream.dictionary), &stream.data)?;
109
+ }
82
110
  }
83
111
  pdf.finish(CATALOG, INFO)
84
112
  }
@@ -13,13 +13,17 @@ use super::span;
13
13
  use crate::xlsx::push_cell_reference;
14
14
  use crate::xlsx::{DateTime, cell_reference};
15
15
 
16
- /// A document's content, by format.
16
+ /// A document's content, by format. Every workbook format holds sheets.
17
17
  #[derive(Debug, Clone, PartialEq)]
18
18
  #[non_exhaustive]
19
19
  pub enum Content {
20
20
  Pdf(Vec<Page>),
21
21
  Xlsx(Vec<Sheet>),
22
22
  Docx(Vec<Block>),
23
+ /// An `.ods` spreadsheet.
24
+ Ods(Vec<Sheet>),
25
+ /// A legacy Excel workbook (Excel 97 to 2003).
26
+ Xls(Vec<Sheet>),
23
27
  }
24
28
 
25
29
  impl Content {
@@ -30,6 +34,17 @@ impl Content {
30
34
  Self::Pdf(_) => super::Format::Pdf,
31
35
  Self::Xlsx(_) => super::Format::Xlsx,
32
36
  Self::Docx(_) => super::Format::Docx,
37
+ Self::Ods(_) => super::Format::Ods,
38
+ Self::Xls(_) => super::Format::Xls,
39
+ }
40
+ }
41
+
42
+ /// A workbook's sheets, whatever its format; `None` for documents.
43
+ #[must_use]
44
+ pub fn sheets(&self) -> Option<&[Sheet]> {
45
+ match self {
46
+ Self::Xlsx(sheets) | Self::Ods(sheets) | Self::Xls(sheets) => Some(sheets),
47
+ Self::Pdf(_) | Self::Docx(_) => None,
33
48
  }
34
49
  }
35
50
  }
@@ -129,13 +144,86 @@ pub struct Mark {
129
144
  pub link: Option<String>,
130
145
  }
131
146
 
132
- /// A workbook sheet: its cells, then its comments.
133
- #[derive(Debug, Clone, PartialEq)]
147
+ /// A workbook sheet: its cells and comments and, from an XLSX workbook,
148
+ /// its column widths, data validations and cell formats.
149
+ #[derive(Debug, Clone, Default, PartialEq)]
134
150
  pub struct Sheet {
135
151
  pub name: String,
136
152
  pub hidden: bool,
137
153
  pub cells: Grid,
138
154
  pub comments: Vec<Note>,
155
+ pub columns: Vec<Columns>,
156
+ pub validations: Vec<Validation>,
157
+ /// The cells with a format of their own, empty ones included.
158
+ pub styles: Vec<Styled>,
159
+ }
160
+
161
+ /// A run of columns, from 0, and their width in characters.
162
+ #[derive(Debug, Clone, PartialEq)]
163
+ pub struct Columns {
164
+ pub first: u32,
165
+ pub last: u32,
166
+ pub width: Option<f64>,
167
+ pub hidden: bool,
168
+ }
169
+
170
+ /// A data validation rule, as the workbook stores it.
171
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
172
+ pub struct Validation {
173
+ /// The cells it applies to: `B2:B100 D2`.
174
+ pub range: String,
175
+ /// `list`, `whole`, `decimal`, `date`, `time`, `textLength`, `custom`,
176
+ /// or `none` for an input message alone.
177
+ pub kind: String,
178
+ pub operator: Option<String>,
179
+ pub formula1: Option<String>,
180
+ pub formula2: Option<String>,
181
+ pub allow_blank: bool,
182
+ /// Whether a list shows its in-cell drop-down arrow.
183
+ pub dropdown: bool,
184
+ /// `stop`, `warning` or `information`, when a wrong value is refused.
185
+ pub alert: Option<String>,
186
+ pub error_title: Option<String>,
187
+ pub error: Option<String>,
188
+ pub prompt_title: Option<String>,
189
+ pub prompt: Option<String>,
190
+ }
191
+
192
+ /// A cell with a format of its own, from 0.
193
+ #[derive(Debug, Clone, PartialEq)]
194
+ pub struct Styled {
195
+ pub row: u32,
196
+ pub column: u32,
197
+ pub look: Arc<Look>,
198
+ }
199
+
200
+ impl Styled {
201
+ /// The cell's A1 reference: `B2`.
202
+ #[must_use]
203
+ pub fn reference(&self) -> String {
204
+ reference(self.row, self.column)
205
+ }
206
+ }
207
+
208
+ /// How a cell format shows a cell. Colors are ARGB (`FFFF0000`), and only
209
+ /// those given as such: theme and indexed colors are left out.
210
+ #[derive(Debug, Clone, Default, PartialEq)]
211
+ #[expect(clippy::struct_excessive_bools, reason = "a format's switches, as Excel keeps them")]
212
+ pub struct Look {
213
+ pub font: String,
214
+ /// In points.
215
+ pub size: f64,
216
+ pub bold: bool,
217
+ pub italic: bool,
218
+ pub underline: bool,
219
+ pub color: Option<String>,
220
+ pub fill: Option<String>,
221
+ pub align: Option<String>,
222
+ pub valign: Option<String>,
223
+ pub wrap: bool,
224
+ /// The left border's style: `thin`, `medium`, `thick`, `dashed`...
225
+ pub border: Option<String>,
226
+ pub number_format: String,
139
227
  }
140
228
 
141
229
  /// A comment on a cell.