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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Cargo.lock +123 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +98 -20
  6. data/ext/imprimatur_native/cli/Cargo.toml +1 -1
  7. data/ext/imprimatur_native/core/Cargo.toml +6 -1
  8. data/ext/imprimatur_native/core/README.md +12 -3
  9. data/ext/imprimatur_native/core/src/archive.rs +3 -16
  10. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  11. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  12. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  13. data/ext/imprimatur_native/core/src/doc.rs +86 -2
  14. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  15. data/ext/imprimatur_native/core/src/docx.rs +91 -28
  16. data/ext/imprimatur_native/core/src/error.rs +12 -0
  17. data/ext/imprimatur_native/core/src/font.rs +229 -0
  18. data/ext/imprimatur_native/core/src/lib.rs +3 -0
  19. data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
  20. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  21. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  22. data/ext/imprimatur_native/core/src/pdf.rs +31 -3
  23. data/ext/imprimatur_native/core/src/read/content.rs +75 -2
  24. data/ext/imprimatur_native/core/src/read/json.rs +94 -7
  25. data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
  26. data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
  27. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  28. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  29. data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
  30. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  31. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
  32. data/ext/imprimatur_native/core/src/read.rs +4 -1
  33. data/ext/imprimatur_native/core/src/script.rs +116 -0
  34. data/ext/imprimatur_native/core/src/style.rs +21 -0
  35. data/ext/imprimatur_native/core/src/test_support.rs +3 -2
  36. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  37. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  38. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  39. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  40. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  41. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  42. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  43. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  44. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  45. data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
  46. data/ext/imprimatur_native/ffi/src/document.rs +48 -1
  47. data/ext/imprimatur_native/ffi/src/lib.rs +38 -1
  48. data/ext/imprimatur_native/ffi/src/read.rs +8 -11
  49. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  50. data/lib/imprimatur/document.rb +30 -6
  51. data/lib/imprimatur/reader.rb +2 -2
  52. data/lib/imprimatur/spreadsheet.rb +151 -0
  53. data/lib/imprimatur/version.rb +1 -1
  54. data/lib/imprimatur/xlsx.rb +25 -4
  55. data/lib/imprimatur.rb +15 -3
  56. metadata +11 -1
@@ -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",
@@ -0,0 +1,229 @@
1
+ //! Static TrueType font data, Unicode glyph lookup and unscaled metrics.
2
+ //!
3
+ //! This does not select or embed fonts in documents, shape text, or perform
4
+ //! bidirectional layout. A character's nominal glyph is not necessarily the
5
+ //! glyph used when shaping a script such as Arabic.
6
+
7
+ #[cfg(test)]
8
+ mod tests;
9
+
10
+ use read_fonts::{
11
+ FontRead, FontRef, MinByteRange, TableProvider,
12
+ tables::{
13
+ cmap::{Cmap, CmapSubtable, PlatformId},
14
+ hmtx::Hmtx,
15
+ },
16
+ types::{GlyphId, Tag, Version16Dot16},
17
+ };
18
+
19
+ use crate::{Error, Result};
20
+
21
+ /// Horizontal line metrics in font design units, taken from `head` and `hhea`.
22
+ ///
23
+ /// Multiply a metric by the point size and divide by `units_per_em` to convert
24
+ /// it to points. These are the raw `hhea` metrics, not an OS/2 line-height policy.
25
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26
+ pub struct FontMetrics {
27
+ pub units_per_em: u16,
28
+ pub ascender: i16,
29
+ /// Usually negative: the position below the baseline.
30
+ pub descender: i16,
31
+ pub line_gap: i16,
32
+ }
33
+
34
+ /// A borrowed, standalone static TrueType font with a Unicode character map.
35
+ ///
36
+ /// Loading checks table bounds and the data needed for glyph lookup and metrics,
37
+ /// including the `loca` offsets into `glyf`. It is not a full font sanitizer:
38
+ /// checksums, outline programs and shaping tables are not validated.
39
+ #[derive(Clone)]
40
+ pub struct TrueTypeFont<'a> {
41
+ data: &'a [u8],
42
+ cmap: CmapSubtable<'a>,
43
+ hmtx: Hmtx<'a>,
44
+ metrics: FontMetrics,
45
+ glyph_count: u16,
46
+ }
47
+
48
+ impl<'a> TrueTypeFont<'a> {
49
+ /// Load font data without copying it or accessing system fonts.
50
+ ///
51
+ /// # Errors
52
+ /// [`Error::UnsupportedFont`] for collections, WOFF, CFF, variable fonts or
53
+ /// fonts without a supported Unicode cmap. [`Error::Malformed`] for invalid
54
+ /// headers, missing required tables or inconsistent metrics/outline offsets.
55
+ pub fn from_bytes(data: &'a [u8]) -> Result<Self> {
56
+ match data.get(..4) {
57
+ Some(b"\0\x01\0\0" | b"true") => {}
58
+ Some(b"ttcf") => return Err(Error::UnsupportedFont("font collections")),
59
+ Some(b"wOFF" | b"wOF2") => return Err(Error::UnsupportedFont("WOFF/WOFF2")),
60
+ Some(b"OTTO") => return Err(Error::UnsupportedFont("CFF outlines")),
61
+ _ => return Err(invalid("invalid or truncated sfnt header")),
62
+ }
63
+ let font = FontRef::new(data).map_err(invalid)?;
64
+ for record in font.table_directory().table_records() {
65
+ if u64::from(record.offset()) + u64::from(record.length()) > data.len() as u64 {
66
+ return Err(invalid(format!(
67
+ "{} table extends beyond the font data",
68
+ record.tag()
69
+ )));
70
+ }
71
+ match record.tag() {
72
+ tag if tag == Tag::new(b"fvar") || tag == Tag::new(b"gvar") => {
73
+ return Err(Error::UnsupportedFont("variable fonts"));
74
+ }
75
+ tag if tag == Tag::new(b"CFF ") || tag == Tag::new(b"CFF2") => {
76
+ return Err(Error::UnsupportedFont("CFF outlines"));
77
+ }
78
+ _ => {}
79
+ }
80
+ }
81
+
82
+ let head = font.head().map_err(invalid)?;
83
+ if head.magic_number() != 0x5F0F_3CF5 || !(16..=16384).contains(&head.units_per_em()) {
84
+ return Err(invalid("invalid head magic number or units per em"));
85
+ }
86
+ let maxp = font.maxp().map_err(invalid)?;
87
+ let glyph_count = maxp.num_glyphs();
88
+ if maxp.version() != Version16Dot16::VERSION_1_0
89
+ || maxp.max_component_depth().is_none()
90
+ || glyph_count == 0
91
+ {
92
+ return Err(invalid("expected a complete TrueType maxp table with at least one glyph"));
93
+ }
94
+ let hhea = font.hhea().map_err(invalid)?;
95
+ let count = hhea.number_of_h_metrics();
96
+ if count == 0 || count > glyph_count {
97
+ return Err(invalid("hhea metric count is outside the glyph range"));
98
+ }
99
+ let hmtx = font.hmtx().map_err(invalid)?;
100
+ if hmtx.h_metrics().len() != usize::from(count)
101
+ || hmtx.left_side_bearings().len() < usize::from(glyph_count - count)
102
+ {
103
+ return Err(invalid("truncated hmtx table"));
104
+ }
105
+ validate_outlines(&font, head.index_to_loc_format(), glyph_count)?;
106
+ let cmap = font.cmap().map_err(invalid)?;
107
+ if cmap.version() != 0 || cmap.encoding_records().len() != usize::from(cmap.num_tables()) {
108
+ return Err(invalid("invalid or truncated cmap header"));
109
+ }
110
+ Ok(Self {
111
+ data,
112
+ cmap: unicode_cmap(&cmap)?,
113
+ hmtx,
114
+ metrics: FontMetrics {
115
+ units_per_em: head.units_per_em(),
116
+ ascender: hhea.ascender().into(),
117
+ descender: hhea.descender().into(),
118
+ line_gap: hhea.line_gap().into(),
119
+ },
120
+ glyph_count,
121
+ })
122
+ }
123
+
124
+ /// The original font bytes.
125
+ #[must_use]
126
+ pub const fn as_bytes(&self) -> &'a [u8] {
127
+ self.data
128
+ }
129
+
130
+ /// Unscaled horizontal line metrics.
131
+ #[must_use]
132
+ pub const fn metrics(&self) -> FontMetrics {
133
+ self.metrics
134
+ }
135
+
136
+ /// The number of glyphs, including glyph zero (`.notdef`).
137
+ #[must_use]
138
+ pub const fn glyph_count(&self) -> u16 {
139
+ self.glyph_count
140
+ }
141
+
142
+ /// The nominal glyph for a Unicode character, without shaping or fallback.
143
+ ///
144
+ /// Returns `None` for missing characters, mappings to `.notdef`, or mappings
145
+ /// outside the font's glyph range. Symbol and legacy cmaps are never used as
146
+ /// Unicode mappings; full-repertoire Unicode cmaps take priority over BMP ones.
147
+ #[must_use]
148
+ pub fn glyph_id(&self, character: char) -> Option<u16> {
149
+ let glyph = self.cmap.map_codepoint(character)?.to_u32();
150
+ u16::try_from(glyph).ok().filter(|&id| id != 0 && id < self.glyph_count)
151
+ }
152
+
153
+ /// The horizontal advance in font design units, without kerning or shaping.
154
+ ///
155
+ /// Glyph zero is allowed; an out-of-range glyph returns `None`. A valid
156
+ /// zero-width glyph returns `Some(0)`.
157
+ #[must_use]
158
+ pub fn advance_width(&self, glyph_id: u16) -> Option<u16> {
159
+ if glyph_id >= self.glyph_count {
160
+ return None;
161
+ }
162
+ self.hmtx.advance(GlyphId::new(u32::from(glyph_id)))
163
+ }
164
+ }
165
+
166
+ fn validate_outlines(font: &FontRef<'_>, loca_format: i16, glyph_count: u16) -> Result<()> {
167
+ if !matches!(loca_format, 0 | 1) {
168
+ return Err(invalid("invalid head indexToLocFormat"));
169
+ }
170
+ let loca = font.loca(loca_format == 1).map_err(invalid)?;
171
+ let glyf = font.glyf().map_err(invalid)?;
172
+ if loca.len() != usize::from(glyph_count)
173
+ || !loca.all_offsets_are_ascending()
174
+ || loca
175
+ .get_raw(usize::from(glyph_count))
176
+ .is_none_or(|end| u64::from(end) > glyf.offset_data().len() as u64)
177
+ {
178
+ return Err(invalid("invalid loca offsets or truncated glyf table"));
179
+ }
180
+ Ok(())
181
+ }
182
+
183
+ fn unicode_cmap<'a>(cmap: &Cmap<'a>) -> Result<CmapSubtable<'a>> {
184
+ // Fontations' best_subtable also accepts symbol and MacRoman maps. Only
185
+ // Unicode encodings belong here; prefer full repertoire over BMP mappings.
186
+ let encodings = [
187
+ (PlatformId::Windows, 10),
188
+ (PlatformId::Unicode, 6),
189
+ (PlatformId::Unicode, 4),
190
+ (PlatformId::Windows, 1),
191
+ (PlatformId::Unicode, 3),
192
+ (PlatformId::Unicode, 2),
193
+ (PlatformId::Unicode, 1),
194
+ (PlatformId::Unicode, 0),
195
+ ];
196
+ for (platform, encoding) in encodings {
197
+ for record in cmap.encoding_records() {
198
+ if record.platform_id() != platform || record.encoding_id() != encoding {
199
+ continue;
200
+ }
201
+ let subtable = record.subtable(cmap.offset_data()).map_err(invalid)?;
202
+ let length = match &subtable {
203
+ CmapSubtable::Format0(table) => u32::from(table.length()),
204
+ CmapSubtable::Format4(table) => u32::from(table.length()),
205
+ CmapSubtable::Format6(table) => u32::from(table.length()),
206
+ CmapSubtable::Format10(table) => table.length(),
207
+ CmapSubtable::Format12(table) => table.length(),
208
+ CmapSubtable::Format13(table) => table.length(),
209
+ _ => continue,
210
+ };
211
+ let length = usize::try_from(length).map_err(|_| invalid("cmap subtable too large"))?;
212
+ let data = subtable
213
+ .offset_data()
214
+ .slice(0..length)
215
+ .ok_or_else(|| invalid("truncated cmap subtable"))?;
216
+ // Constrain lazy array reads to this subtable's declared extent.
217
+ let subtable = CmapSubtable::read(data).map_err(invalid)?;
218
+ if subtable.min_byte_range().end > length {
219
+ return Err(invalid("truncated cmap arrays"));
220
+ }
221
+ return Ok(subtable);
222
+ }
223
+ }
224
+ Err(Error::UnsupportedFont("no supported Unicode cmap"))
225
+ }
226
+
227
+ fn invalid(reason: impl std::fmt::Display) -> Error {
228
+ Error::malformed("TrueType font", reason.to_string())
229
+ }
@@ -23,9 +23,12 @@ pub mod archive;
23
23
  pub mod doc;
24
24
  mod docx;
25
25
  mod error;
26
+ #[cfg(feature = "fonts")]
27
+ pub mod font;
26
28
  mod opc;
27
29
  mod pdf;
28
30
  pub mod read;
31
+ mod script;
29
32
  pub mod style;
30
33
  #[cfg(test)]
31
34
  mod test_support;
@@ -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
+ }