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,129 @@
1
+ //! TrueType metadata needed by a PDF `FontDescriptor`, in 1000-unit glyph space.
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ use std::io::Write;
7
+
8
+ use read_fonts::{
9
+ FontRead, FontRef, TableProvider,
10
+ tables::{head::MacStyle, os2::Os2, post::Post},
11
+ types::{NameId, Tag},
12
+ };
13
+
14
+ use crate::font::{TrueTypeFont, invalid};
15
+ use crate::pdf::objects::{Num, PdfWriter};
16
+ use crate::{Error, Result};
17
+
18
+ pub struct Descriptor {
19
+ /// PostScript name, escaped as a PDF name (without its leading slash).
20
+ pub name: String,
21
+ bounds: [Num; 4],
22
+ ascent: Num,
23
+ descent: Num,
24
+ cap_height: Num,
25
+ italic_angle: Num,
26
+ flags: u32,
27
+ stem: Num,
28
+ }
29
+
30
+ impl Descriptor {
31
+ pub fn read(font: &TrueTypeFont<'_>) -> Result<Self> {
32
+ let tables = FontRef::new(font.as_bytes()).map_err(invalid)?;
33
+ let head = tables.head().map_err(invalid)?;
34
+ let post =
35
+ tables.table_data(Tag::new(b"post")).map(Post::read).transpose().map_err(invalid)?;
36
+ let os2 =
37
+ tables.table_data(Tag::new(b"OS/2")).map(Os2::read).transpose().map_err(invalid)?;
38
+ if let Some(os2) = &os2 {
39
+ let rights = os2.fs_type();
40
+ // Older fonts can set several rights bits; the least restrictive wins.
41
+ (rights & 0x0002 == 0 || rights & 0x000C != 0)
42
+ .ok_or(Error::UnsupportedFont("restricted font embedding"))?;
43
+ (rights & 0x0200 == 0).ok_or(Error::UnsupportedFont("bitmap-only font embedding"))?;
44
+ // The no-subsetting bit is compatible with embedding the complete font.
45
+ }
46
+ (head.x_min() <= head.x_max() && head.y_min() <= head.y_max())
47
+ .ok_or_else(|| invalid("inverted font bounding box"))?;
48
+ let metrics = font.metrics();
49
+ let scale = 1000.0 / f64::from(metrics.units_per_em);
50
+ let cap_height = os2
51
+ .as_ref()
52
+ .and_then(Os2::s_cap_height)
53
+ .filter(|height| *height > 0)
54
+ .unwrap_or(metrics.ascender);
55
+ let angle = post.as_ref().map_or(0.0, |post| post.italic_angle().to_f64());
56
+ // Symbolic: Identity-H is a CID encoding, not the standard Latin encoding.
57
+ let mut flags = 4;
58
+ if post.as_ref().is_some_and(|post| post.is_fixed_pitch() != 0) {
59
+ flags |= 1;
60
+ }
61
+ if angle != 0.0 || head.mac_style().contains(MacStyle::ITALIC) {
62
+ flags |= 64;
63
+ }
64
+ let family = os2.as_ref().map_or(0, |os2| os2.s_family_class() >> 8);
65
+ if (1..=7).contains(&family) {
66
+ flags |= 2;
67
+ } else if family == 10 {
68
+ flags |= 8;
69
+ }
70
+ let weight = os2.as_ref().map_or(400, |os2| os2.us_weight_class().clamp(1, 1000));
71
+ let stem_weight = f64::from(weight) / 65.0;
72
+ Ok(Self {
73
+ name: postscript_name(&tables)?,
74
+ bounds: [head.x_min(), head.y_min(), head.x_max(), head.y_max()]
75
+ .map(|value| Num(f64::from(value) * scale)),
76
+ ascent: Num(f64::from(metrics.ascender) * scale),
77
+ descent: Num(f64::from(metrics.descender) * scale),
78
+ // Older OS/2 tables lack cap height; the ascender is an approximation.
79
+ cap_height: Num(f64::from(cap_height) * scale),
80
+ italic_angle: Num(angle),
81
+ flags,
82
+ // TrueType has no stem-width field: estimate it from the weight class.
83
+ stem: Num(stem_weight.mul_add(stem_weight, 50.0)),
84
+ })
85
+ }
86
+
87
+ pub fn write<W: Write>(&self, pdf: &mut PdfWriter<W>, id: usize, file: usize) -> Result<()> {
88
+ let Self { name, bounds, ascent, descent, cap_height, italic_angle, flags, stem } = self;
89
+ let [left, bottom, right, top] = bounds;
90
+ pdf.object(
91
+ id,
92
+ format_args!(
93
+ "<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \
94
+ /FontBBox [{left} {bottom} {right} {top}] /ItalicAngle {italic_angle} \
95
+ /Ascent {ascent} /Descent {descent} /CapHeight {cap_height} \
96
+ /StemV {stem} /FontFile2 {file} 0 R >>"
97
+ ),
98
+ )
99
+ }
100
+ }
101
+
102
+ fn postscript_name(font: &FontRef<'_>) -> Result<String> {
103
+ let names = font.name().map_err(invalid)?;
104
+ for platform in [3, 0, 1] {
105
+ for record in names.name_record() {
106
+ if record.name_id() != NameId::POSTSCRIPT_NAME || record.platform_id() != platform {
107
+ continue;
108
+ }
109
+ if !(record.is_unicode() || platform == 1 && record.encoding_id() == 0) {
110
+ continue;
111
+ }
112
+ if record.is_unicode() && !record.length().is_multiple_of(2) {
113
+ return Err(invalid("odd UTF-16 PostScript name length"));
114
+ }
115
+ let name = record.string(names.string_data()).map_err(invalid)?.to_string();
116
+ // OpenType name ID 6: 1..63 printable ASCII characters, excluding
117
+ // PostScript delimiters. '#' is legal there but needs escaping in PDF.
118
+ if !name.is_empty()
119
+ && name.len() <= 63
120
+ && name
121
+ .bytes()
122
+ .all(|byte| (33..=126).contains(&byte) && !b"[](){}<>/%".contains(&byte))
123
+ {
124
+ return Ok(name.replace('#', "#23"));
125
+ }
126
+ }
127
+ }
128
+ Err(Error::UnsupportedFont("no usable PostScript font name"))
129
+ }
@@ -0,0 +1,70 @@
1
+ //! A `CIDFont`'s TrueType program retains every glyph but omits the cmap table
2
+ //! (ISO 32000-1, 9.9). Rebuild the sfnt directory, padding and checksums; glyph
3
+ //! outlines, IDs, metrics and hinting tables are unchanged.
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ use read_fonts::{FontRef, TableRecord, types::Tag};
9
+
10
+ use crate::Result;
11
+ use crate::font::{TrueTypeFont, invalid};
12
+
13
+ pub fn for_pdf(font: &TrueTypeFont<'_>) -> Result<Vec<u8>> {
14
+ let source = FontRef::new(font.as_bytes()).map_err(invalid)?;
15
+ let mut tags: Vec<_> = source
16
+ .table_directory()
17
+ .table_records()
18
+ .iter()
19
+ .map(TableRecord::tag)
20
+ // Rewriting the directory also invalidates any digital signature.
21
+ .filter(|tag| *tag != Tag::new(b"cmap") && *tag != Tag::new(b"DSIG"))
22
+ .collect();
23
+ tags.sort_unstable();
24
+ (!tags.windows(2).any(|pair| pair[0] == pair[1]))
25
+ .ok_or_else(|| invalid("duplicate sfnt tables"))?;
26
+ let count = u16::try_from(tags.len()).map_err(invalid)?;
27
+ // searchRange and rangeShift are byte counts in 16-bit fields.
28
+ (count > 0 && count <= 4095).ok_or_else(|| invalid("sfnt table count out of range"))?;
29
+ let selector = u16::try_from(count.ilog2()).map_err(invalid)?;
30
+ let range = (1_u16 << selector) * 16;
31
+ let mut data = Vec::with_capacity(font.as_bytes().len());
32
+ data.extend_from_slice(&font.as_bytes()[..4]);
33
+ for value in [count, range, selector, count * 16 - range] {
34
+ data.extend_from_slice(&value.to_be_bytes());
35
+ }
36
+ data.resize(12 + 16 * tags.len(), 0);
37
+ let mut head = None;
38
+ for (index, tag) in tags.into_iter().enumerate() {
39
+ let table = source.table_data(tag).ok_or_else(|| invalid("missing sfnt table"))?;
40
+ let start = data.len();
41
+ let offset = u32::try_from(start).map_err(invalid)?;
42
+ let length = u32::try_from(table.len()).map_err(invalid)?;
43
+ data.extend_from_slice(table.as_bytes());
44
+ if tag == Tag::new(b"head") {
45
+ // head's own checksum is calculated with checkSumAdjustment zero.
46
+ data[start + 8..start + 12].fill(0);
47
+ head = Some(start);
48
+ }
49
+ let sum = checksum(&data[start..]);
50
+ data.resize(data.len().next_multiple_of(4), 0);
51
+ let record = 12 + 16 * index;
52
+ data[record..record + 4].copy_from_slice(&tag.to_be_bytes());
53
+ for (field, value) in [sum, offset, length].into_iter().enumerate() {
54
+ let field = record + 4 * (field + 1);
55
+ data[field..field + 4].copy_from_slice(&value.to_be_bytes());
56
+ }
57
+ }
58
+ let head = head.ok_or_else(|| invalid("missing head table"))?;
59
+ let adjustment = 0xB1B0_AFBA_u32.wrapping_sub(checksum(&data));
60
+ data[head + 8..head + 12].copy_from_slice(&adjustment.to_be_bytes());
61
+ Ok(data)
62
+ }
63
+
64
+ fn checksum(data: &[u8]) -> u32 {
65
+ data.chunks(4).fold(0_u32, |sum, bytes| {
66
+ let mut word = [0; 4];
67
+ word[..bytes.len()].copy_from_slice(bytes);
68
+ sum.wrapping_add(u32::from_be_bytes(word))
69
+ })
70
+ }
@@ -0,0 +1,149 @@
1
+ //! A complete TrueType font as a horizontal Type 0 / `CIDFontType2` resource
2
+ //! (ISO 32000-1, 9.7 and 9.10). Document font selection and layout are separate.
3
+
4
+ mod descriptor;
5
+ mod program;
6
+ #[cfg(test)]
7
+ mod tests;
8
+
9
+ use std::collections::BTreeMap;
10
+ use std::fmt::{self, Write as _};
11
+ use std::io::Write;
12
+
13
+ use super::objects::{Num, PdfWriter};
14
+ use crate::font::TrueTypeFont;
15
+ use crate::{Error, Result};
16
+ use descriptor::Descriptor;
17
+
18
+ /// Objects written in order: Type 0 font, descendant, descriptor, font file,
19
+ /// CID-to-glyph map, `ToUnicode` character map.
20
+ pub const OBJECTS: usize = 6;
21
+
22
+ /// Collects glyph uses before writing the font resource once for all its pages.
23
+ /// CIDs are allocated per (glyph, original text), not per glyph alone: distinct
24
+ /// characters can share an outline, and a ligature can represent several.
25
+ pub struct EmbeddedFont<'a> {
26
+ font: TrueTypeFont<'a>,
27
+ descriptor: Descriptor,
28
+ program: Vec<u8>,
29
+ /// CID 1 onwards, with unscaled advances; CID 0 is reserved for .notdef.
30
+ glyphs: Vec<(u16, u16)>,
31
+ codes: BTreeMap<(u16, String), u16>,
32
+ }
33
+
34
+ impl<'a> EmbeddedFont<'a> {
35
+ /// Reads the metadata needed for a PDF descriptor and checks embedding rights.
36
+ pub fn new(font: TrueTypeFont<'a>) -> Result<Self> {
37
+ let descriptor = Descriptor::read(&font)?;
38
+ let program = program::for_pdf(&font)?;
39
+ Ok(Self { font, descriptor, program, glyphs: Vec::new(), codes: BTreeMap::new() })
40
+ }
41
+
42
+ /// The two big-endian bytes to put in a PDF text string for this glyph use.
43
+ /// `text` is its original Unicode sequence, in logical order. Empty text
44
+ /// omits its `ToUnicode` entry, for glyphs with no text of their own.
45
+ /// This does not shape text or infer glyphs from characters.
46
+ pub fn encode_glyph(&mut self, glyph: u16, text: &str) -> Result<[u8; 2]> {
47
+ // A ToUnicode destination string may contain at most 512 bytes.
48
+ (text.encode_utf16().take(257).count() <= 256)
49
+ .ok_or_else(|| invalid("Unicode mapping exceeds 256 UTF-16 code units"))?;
50
+ let width = self
51
+ .font
52
+ .advance_width(glyph)
53
+ .filter(|_| glyph != 0)
54
+ .ok_or_else(|| invalid("glyph is missing or outside the font's glyph range"))?;
55
+ let key = (glyph, text.to_owned());
56
+ if let Some(code) = self.codes.get(&key) {
57
+ return Ok(code.to_be_bytes());
58
+ }
59
+ let code = u16::try_from(self.glyphs.len() + 1)
60
+ .map_err(|_| invalid("more than 65535 distinct glyph mappings"))?;
61
+ self.glyphs.push((glyph, width));
62
+ self.codes.insert(key, code);
63
+ Ok(code.to_be_bytes())
64
+ }
65
+
66
+ /// Writes six consecutive objects, starting at `first`, which is the font
67
+ /// reference for a page's resources. Collect every glyph before calling this.
68
+ /// Every glyph is embedded with its original ID and outline. The font's cmap
69
+ /// is replaced by PDF mappings; its table directory and checksums are rebuilt.
70
+ pub fn write<W: Write>(&self, pdf: &mut PdfWriter<W>, first: usize) -> Result<()> {
71
+ let (descendant, descriptor, file, mapping, unicode) =
72
+ (first + 1, first + 2, first + 3, first + 4, first + 5);
73
+ let name = &self.descriptor.name;
74
+ pdf.object(
75
+ first,
76
+ format_args!(
77
+ "<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H \
78
+ /DescendantFonts [{descendant} 0 R] /ToUnicode {unicode} 0 R >>"
79
+ ),
80
+ )?;
81
+ let scale = 1000.0 / f64::from(self.font.metrics().units_per_em);
82
+ let default_width = Num(f64::from(self.font.advance_width(0).unwrap_or(0)) * scale);
83
+ let widths = fmt::from_fn(|f| {
84
+ // Keep both the outer W array and each inner width array below the
85
+ // PDF 1.7 implementation limit of 8191 elements (Annex C).
86
+ for (index, glyphs) in self.glyphs.chunks(256).enumerate() {
87
+ if index > 0 {
88
+ f.write_str(" ")?;
89
+ }
90
+ write!(f, "{} [", 1 + index * 256)?;
91
+ for &(_, width) in glyphs {
92
+ write!(f, "{} ", Num(f64::from(width) * scale))?;
93
+ }
94
+ f.write_str("]")?;
95
+ }
96
+ Ok(())
97
+ });
98
+ pdf.object(
99
+ descendant,
100
+ format_args!(
101
+ "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} \
102
+ /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
103
+ /FontDescriptor {descriptor} 0 R /DW {default_width} /W [{widths}] \
104
+ /CIDToGIDMap {mapping} 0 R >>"
105
+ ),
106
+ )?;
107
+ self.descriptor.write(pdf, descriptor, file)?;
108
+ pdf.stream(file, &self.program, &[("Length1", &self.program.len())])?;
109
+ let mut gids = Vec::with_capacity(2 * (self.glyphs.len() + 1));
110
+ gids.extend_from_slice(&[0, 0]);
111
+ for &(glyph, _) in &self.glyphs {
112
+ gids.extend_from_slice(&glyph.to_be_bytes());
113
+ }
114
+ pdf.stream(mapping, &gids, &[])?;
115
+ pdf.stream(unicode, self.to_unicode().as_bytes(), &[])
116
+ }
117
+
118
+ fn to_unicode(&self) -> String {
119
+ let mut cmap = String::from(
120
+ "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
121
+ /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
122
+ /CMapName /Imprimatur-UCS def\n/CMapType 2 def\n\
123
+ 1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
124
+ );
125
+ let mappings: Vec<_> =
126
+ self.codes.iter().filter(|((_, text), _)| !text.is_empty()).collect();
127
+ // CMap mapping operators accept at most 100 entries per block.
128
+ for chunk in mappings.chunks(100) {
129
+ // Writing to a String cannot fail.
130
+ let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
131
+ for ((_, text), code) in chunk {
132
+ // Writing to a String cannot fail.
133
+ let _ = write!(cmap, "<{code:04X}> <");
134
+ for unit in text.encode_utf16() {
135
+ // Writing to a String cannot fail.
136
+ let _ = write!(cmap, "{unit:04X}");
137
+ }
138
+ cmap.push_str(">\n");
139
+ }
140
+ cmap.push_str("endbfchar\n");
141
+ }
142
+ cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
143
+ cmap
144
+ }
145
+ }
146
+
147
+ fn invalid(reason: &str) -> Error {
148
+ Error::malformed("PDF font", reason)
149
+ }
@@ -1,14 +1,22 @@
1
1
  //! PDF output: the document laid out with the standard Helvetica fonts
2
2
  //! (`WinAnsiEncoding`, so Windows-1252 text), written with our own object
3
- //! writer. Embedded TrueType fonts, and with them every other script, come next.
3
+ //! writer. `truetype` serializes embedded font resources; connecting these to
4
+ //! document layout, shaping and bidirectional text comes next.
4
5
 
5
6
  mod font;
6
7
  mod layout;
7
8
  #[rustfmt::skip]
8
9
  pub mod metrics;
9
10
  mod objects;
11
+ mod picture;
10
12
  #[cfg(test)]
11
13
  mod tests;
14
+ #[cfg(feature = "fonts")]
15
+ #[cfg_attr(
16
+ not(test),
17
+ expect(dead_code, reason = "font resources are connected to document layout in a follow-up")
18
+ )]
19
+ mod truetype;
12
20
 
13
21
  use std::fmt;
14
22
  use std::io::Write;
@@ -26,12 +34,18 @@ const FIRST_FONT: usize = 3;
26
34
  const INFO: usize = FIRST_FONT + Face::ALL.len();
27
35
 
28
36
  pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
29
- let mut layout = Layout::new(document.page_size());
37
+ let mut layout = Layout::new(document)?;
38
+ let mut pictures = Vec::new();
30
39
  for block in document.blocks() {
31
40
  match block {
32
41
  Block::Heading { level, text } => layout.heading(*level, text)?,
33
42
  Block::Paragraph(paragraph) => layout.paragraph(paragraph)?,
34
43
  Block::Table(table) => layout.table(table)?,
44
+ Block::Picture(picture) => {
45
+ layout.picture(picture, pictures.len());
46
+ pictures.push(picture);
47
+ }
48
+ Block::Symbol(symbol) => layout.symbol(symbol),
35
49
  Block::PageBreak => layout.page_break(),
36
50
  }
37
51
  }
@@ -67,6 +81,23 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
67
81
  }
68
82
  Ok(())
69
83
  });
84
+ // Pictures follow the pages: an image, then its soft mask if it has one.
85
+ let mut ids = Vec::with_capacity(pictures.len());
86
+ let mut next = page_id(pages.len());
87
+ for picture in &pictures {
88
+ ids.push(next);
89
+ next += 1 + usize::from(picture::has_mask(picture));
90
+ }
91
+ let images = fmt::from_fn(|f| {
92
+ if !ids.is_empty() {
93
+ f.write_str("/XObject << ")?;
94
+ for (index, id) in ids.iter().enumerate() {
95
+ write!(f, "/Im{index} {id} 0 R ")?;
96
+ }
97
+ f.write_str(">> ")?;
98
+ }
99
+ Ok(())
100
+ });
70
101
  let (width, height) = (Num(width), Num(height));
71
102
  for (index, content) in pages.iter().enumerate() {
72
103
  let id = page_id(index);
@@ -74,11 +105,16 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
74
105
  id,
75
106
  format_args!(
76
107
  "<< /Type /Page /Parent {PAGES} 0 R /MediaBox [0 0 {width} {height}] \
77
- /Resources << /Font << {fonts}>> >> /Contents {} 0 R >>",
108
+ /Resources << /Font << {fonts}>> {images}>> /Contents {} 0 R >>",
78
109
  id + 1
79
110
  ),
80
111
  )?;
81
- pdf.stream(id + 1, content)?;
112
+ pdf.stream(id + 1, content, &[])?;
113
+ }
114
+ for (picture, id) in pictures.into_iter().zip(ids) {
115
+ for (offset, stream) in (0..).zip(picture::streams(picture, id + 1)?) {
116
+ pdf.stored(id + offset, format_args!("{}", stream.dictionary), &stream.data)?;
117
+ }
82
118
  }
83
119
  pdf.finish(CATALOG, INFO)
84
120
  }
@@ -144,13 +144,86 @@ pub struct Mark {
144
144
  pub link: Option<String>,
145
145
  }
146
146
 
147
- /// A workbook sheet: its cells, then its comments.
148
- #[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)]
149
150
  pub struct Sheet {
150
151
  pub name: String,
151
152
  pub hidden: bool,
152
153
  pub cells: Grid,
153
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,
154
227
  }
155
228
 
156
229
  /// A comment on a cell.
@@ -128,6 +128,20 @@ pub fn png(
128
128
  bits: u8,
129
129
  palette: Option<&[u8]>,
130
130
  rows: &[u8],
131
+ ) -> Vec<u8> {
132
+ png_with_transparency(width, height, color, bits, palette, &[], rows)
133
+ }
134
+
135
+ /// As [`png`], with a `tRNS` chunk unless `transparency` is empty: a
136
+ /// palette's alphas by index, or the gray or RGB color that is transparent.
137
+ pub fn png_with_transparency(
138
+ width: u32,
139
+ height: u32,
140
+ color: PngColor,
141
+ bits: u8,
142
+ palette: Option<&[u8]>,
143
+ transparency: &[u8],
144
+ rows: &[u8],
131
145
  ) -> Vec<u8> {
132
146
  let channels: usize = match color {
133
147
  PngColor::Gray | PngColor::Palette => 1,
@@ -157,6 +171,9 @@ pub fn png(
157
171
  if let Some(palette) = palette {
158
172
  chunk(&mut out, *b"PLTE", palette);
159
173
  }
174
+ if !transparency.is_empty() {
175
+ chunk(&mut out, *b"tRNS", transparency);
176
+ }
160
177
  chunk(&mut out, *b"IDAT", &compressed);
161
178
  chunk(&mut out, *b"IEND", &[]);
162
179
  out
@@ -9,17 +9,34 @@ mod tests;
9
9
 
10
10
  use serde_json::{Map, Value as Json, json};
11
11
 
12
- use super::{Block, Bounds, Cell, Content, Grid, Image, ImageLocation, Mark, Page, Sheet, Value};
12
+ use super::{
13
+ Block, Bounds, Cell, Columns, Content, Grid, Image, ImageLocation, Look, Mark, Page, Sheet,
14
+ Styled, Validation, Value,
15
+ };
16
+ use crate::xlsx::column_name;
17
+
18
+ /// What [`content_with`] adds to the content.
19
+ #[derive(Debug, Clone, Copy, Default)]
20
+ pub struct Options {
21
+ /// Each formatted cell of a sheet, with its look.
22
+ pub styles: bool,
23
+ }
13
24
 
14
25
  /// The document's content, by format: `pages`, `sheets` or `blocks`.
15
26
  #[must_use]
16
27
  pub fn content(content: &Content) -> Map<String, Json> {
28
+ content_with(content, Options::default())
29
+ }
30
+
31
+ /// The document's content, with what `options` add.
32
+ #[must_use]
33
+ pub fn content_with(content: &Content, options: Options) -> Map<String, Json> {
17
34
  let mut object = Map::new();
18
35
  object.insert("format".into(), content.format().as_str().into());
19
36
  match content {
20
37
  Content::Pdf(pages) => object.insert("pages".into(), pages.iter().map(page).collect()),
21
38
  Content::Xlsx(sheets) | Content::Ods(sheets) | Content::Xls(sheets) => {
22
- object.insert("sheets".into(), sheets.iter().map(sheet).collect())
39
+ object.insert("sheets".into(), sheets.iter().map(|it| sheet(it, options)).collect())
23
40
  }
24
41
  Content::Docx(blocks) => object.insert("blocks".into(), blocks.iter().map(block).collect()),
25
42
  };
@@ -116,7 +133,19 @@ fn with_marks(mut object: Json, marks: &[Mark]) -> Json {
116
133
  object
117
134
  }
118
135
 
119
- fn sheet(sheet: &Sheet) -> Json {
136
+ fn sheet(sheet: &Sheet, options: Options) -> Json {
137
+ let mut object = Map::new();
138
+ object.insert("name".into(), sheet.name.clone().into());
139
+ object.insert("hidden".into(), sheet.hidden.into());
140
+ object.insert("rows".into(), rows(&sheet.cells, true));
141
+ object.extend(sheet_details(sheet, options));
142
+ Json::Object(object)
143
+ }
144
+
145
+ /// What a sheet has besides its values: `comments`, `columns`,
146
+ /// `validations` and, with `options.styles`, `styles`.
147
+ #[must_use]
148
+ pub fn sheet_details(sheet: &Sheet, options: Options) -> Map<String, Json> {
120
149
  let comments: Vec<Json> = sheet
121
150
  .comments
122
151
  .iter()
@@ -125,11 +154,69 @@ fn sheet(sheet: &Sheet) -> Json {
125
154
  json!({ "cell": note.reference(), "author": author, "text": note.text })
126
155
  })
127
156
  .collect();
157
+ let mut details = Map::new();
158
+ details.insert("comments".into(), comments.into());
159
+ details.insert("columns".into(), sheet.columns.iter().map(columns).collect());
160
+ details.insert("validations".into(), sheet.validations.iter().map(validation).collect());
161
+ if options.styles {
162
+ details.insert("styles".into(), sheet.styles.iter().map(styled).collect());
163
+ }
164
+ details
165
+ }
166
+
167
+ /// Columns by letter: `A`, `XFD`.
168
+ fn columns(run: &Columns) -> Json {
169
+ let name = |column: u32| column_name(u16::try_from(column).unwrap_or(u16::MAX));
170
+ let (first, last) = (name(run.first), name(run.last));
171
+ json!({ "first": first, "last": last, "width": run.width, "hidden": run.hidden })
172
+ }
173
+
174
+ fn validation(rule: &Validation) -> Json {
175
+ json!({
176
+ "range": rule.range,
177
+ "type": rule.kind,
178
+ "operator": rule.operator,
179
+ "formula1": rule.formula1,
180
+ "formula2": rule.formula2,
181
+ "allow_blank": rule.allow_blank,
182
+ "dropdown": rule.dropdown,
183
+ "alert": rule.alert,
184
+ "error_title": rule.error_title,
185
+ "error": rule.error,
186
+ "prompt_title": rule.prompt_title,
187
+ "prompt": rule.prompt,
188
+ })
189
+ }
190
+
191
+ fn styled(cell: &Styled) -> Json {
192
+ let Look {
193
+ font,
194
+ size,
195
+ bold,
196
+ italic,
197
+ underline,
198
+ color,
199
+ fill,
200
+ align,
201
+ valign,
202
+ wrap,
203
+ border,
204
+ number_format,
205
+ } = cell.look.as_ref();
128
206
  json!({
129
- "name": sheet.name,
130
- "hidden": sheet.hidden,
131
- "rows": rows(&sheet.cells, true),
132
- "comments": comments,
207
+ "cell": cell.reference(),
208
+ "font": font,
209
+ "size": size,
210
+ "bold": bold,
211
+ "italic": italic,
212
+ "underline": underline,
213
+ "color": color,
214
+ "fill": fill,
215
+ "align": align,
216
+ "valign": valign,
217
+ "wrap": wrap,
218
+ "border": border,
219
+ "number_format": number_format,
133
220
  })
134
221
  }
135
222