imprimatur 0.1.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 (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. metadata +157 -0
@@ -0,0 +1,163 @@
1
+ //! Helpers shared by the unit tests.
2
+
3
+ use std::io::{Cursor, Read};
4
+
5
+ /// Every entry of a zip archive as `(name, bytes)`, read with the `zip`
6
+ /// crate: an implementation independent of ours, which verifies each CRC.
7
+ pub fn read_zip(bytes: Vec<u8>) -> Vec<(String, Vec<u8>)> {
8
+ let mut archive = ::zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
9
+ (0..archive.len())
10
+ .map(|index| {
11
+ let mut file = archive.by_index(index).unwrap();
12
+ let mut data = Vec::new();
13
+ file.read_to_end(&mut data).unwrap();
14
+ (file.name().unwrap().into_owned(), data)
15
+ })
16
+ .collect()
17
+ }
18
+
19
+ /// Every entry of a zip archive as text, by name, read as [`read_zip`] does.
20
+ pub fn text_parts(bytes: Vec<u8>) -> Vec<(String, String)> {
21
+ read_zip(bytes)
22
+ .into_iter()
23
+ .map(|(name, data)| (name, String::from_utf8(data).unwrap()))
24
+ .collect()
25
+ }
26
+
27
+ /// The text of the part `name` among `parts`.
28
+ pub fn part<'a>(parts: &'a [(String, String)], name: &str) -> &'a str {
29
+ parts
30
+ .iter()
31
+ .find(|(part, _)| part == name)
32
+ .map_or_else(|| panic!("no part {name}"), |(_, xml)| xml)
33
+ }
34
+
35
+ /// A zip of `(name, text)` parts, written with our own writer.
36
+ pub fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
37
+ let mut zip = crate::zip::ZipWriter::new(Vec::new());
38
+ for (name, text) in parts {
39
+ zip.add(name, text.as_bytes()).unwrap();
40
+ }
41
+ zip.finish().unwrap()
42
+ }
43
+
44
+ /// A PDF of `objects`, numbered from 1 (the first is the catalog), with a
45
+ /// classic cross-reference table.
46
+ pub fn pdf_of(objects: &[Vec<u8>]) -> Vec<u8> {
47
+ use std::io::Write as _;
48
+ let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
49
+ let mut offsets = Vec::new();
50
+ for (number, body) in (1..).zip(objects) {
51
+ offsets.push(out.len());
52
+ writeln!(out, "{number} 0 obj").unwrap();
53
+ out.extend_from_slice(body);
54
+ out.extend_from_slice(b"\nendobj\n");
55
+ }
56
+ let xref = out.len();
57
+ write!(out, "xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).unwrap();
58
+ for offset in offsets {
59
+ writeln!(out, "{offset:010} 00000 n ").unwrap();
60
+ }
61
+ write!(
62
+ out,
63
+ "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
64
+ objects.len() + 1
65
+ )
66
+ .unwrap();
67
+ out
68
+ }
69
+
70
+ /// A stream object: `dict` holds the entries besides `/Length`.
71
+ pub fn stream_object(dict: &str, data: &[u8]) -> Vec<u8> {
72
+ let mut out = format!("<< /Length {} {dict} >>\nstream\n", data.len()).into_bytes();
73
+ out.extend_from_slice(data);
74
+ out.extend_from_slice(b"\nendstream");
75
+ out
76
+ }
77
+
78
+ /// One page of `content` with Helvetica as `/F1` and a 612 by 792 page.
79
+ pub fn page_pdf(content: &str, page_extra: &str) -> Vec<u8> {
80
+ pdf_of(&[
81
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
82
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
83
+ format!(
84
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
85
+ /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R {page_extra} >>"
86
+ )
87
+ .into_bytes(),
88
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
89
+ .to_vec(),
90
+ stream_object("", content.as_bytes()),
91
+ ])
92
+ }
93
+
94
+ /// A PNG as `read::image::png` writes it, read back.
95
+ #[derive(Debug)]
96
+ pub struct Png {
97
+ pub width: u32,
98
+ pub height: u32,
99
+ pub bits: u8,
100
+ pub color: u8,
101
+ pub palette: Option<Vec<u8>>,
102
+ /// The rows, their filter bytes (always 0, none) taken out.
103
+ pub rows: Vec<u8>,
104
+ }
105
+
106
+ /// Reads a PNG, checking each chunk's CRC and that every row is unfiltered.
107
+ pub fn png_of(data: &[u8]) -> Png {
108
+ let be = |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());
109
+ assert!(data.starts_with(b"\x89PNG\r\n\x1a\n"), "{data:?}");
110
+ let (mut header, mut palette, mut compressed) = (Vec::new(), None, Vec::new());
111
+ let mut at = 8;
112
+ while at < data.len() {
113
+ let length = be(at) as usize;
114
+ let (kind, body) = (&data[at + 4..at + 8], &data[at + 8..at + 8 + length]);
115
+ assert_eq!(
116
+ be(at + 8 + length),
117
+ crc32fast::hash(&data[at + 4..at + 8 + length]),
118
+ "{kind:?}"
119
+ );
120
+ match kind {
121
+ b"IHDR" => header = body.to_vec(),
122
+ b"PLTE" => palette = Some(body.to_vec()),
123
+ b"IDAT" => compressed.extend_from_slice(body),
124
+ _ => {}
125
+ }
126
+ at += 12 + length;
127
+ }
128
+ let (width, height) = (be(16), be(20));
129
+ let (bits, color) = (header[8], header[9]);
130
+ let channels = match color {
131
+ 0 | 3 => 1,
132
+ 4 => 2,
133
+ 2 => 3,
134
+ _ => 4,
135
+ };
136
+ let stride = (width as usize * channels * usize::from(bits)).div_ceil(8);
137
+ let filtered = miniz_oxide::inflate::decompress_to_vec_zlib(&compressed).unwrap();
138
+ assert_eq!(filtered.len(), (stride + 1) * height as usize);
139
+ let mut rows = Vec::new();
140
+ for row in filtered.chunks(stride + 1) {
141
+ assert_eq!(row[0], 0, "filter type");
142
+ rows.extend_from_slice(&row[1..]);
143
+ }
144
+ Png { width, height, bits, color, palette, rows }
145
+ }
146
+
147
+ /// A one-strip little-endian TIFF's tags, by number, and its strip.
148
+ pub fn tiff_of(data: &[u8]) -> (std::collections::BTreeMap<u16, u32>, &[u8]) {
149
+ let le16 = |at: usize| u16::from_le_bytes(data[at..at + 2].try_into().unwrap());
150
+ let le32 = |at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap());
151
+ assert!(data.starts_with(b"II*\0"));
152
+ let directory = le32(4) as usize;
153
+ let tags: std::collections::BTreeMap<u16, u32> = (0..usize::from(le16(directory)))
154
+ .map(|index| {
155
+ let entry = directory + 2 + index * 12;
156
+ let value =
157
+ if le16(entry + 2) == 3 { u32::from(le16(entry + 8)) } else { le32(entry + 8) };
158
+ (le16(entry), value)
159
+ })
160
+ .collect();
161
+ let (offset, length) = (tags[&273] as usize, tags[&279] as usize);
162
+ (tags, &data[offset..offset + length])
163
+ }
@@ -0,0 +1,112 @@
1
+ #[cfg(test)]
2
+ mod tests;
3
+
4
+ use std::fmt::Write as _;
5
+
6
+ use super::DateTime;
7
+
8
+ /// A cell value.
9
+ #[derive(Debug, Clone, PartialEq)]
10
+ #[non_exhaustive]
11
+ pub enum Cell {
12
+ Text(String),
13
+ Number(f64),
14
+ Bool(bool),
15
+ /// A formula without its leading `=`, such as `SUM(A1:A3)`. Excel
16
+ /// computes it when the workbook opens.
17
+ Formula(String),
18
+ /// A date, stored as its serial number. Without a number format of its
19
+ /// own the cell gets `yyyy-mm-dd`, or `yyyy-mm-dd hh:mm:ss` with a time.
20
+ DateTime(DateTime),
21
+ }
22
+
23
+ impl Cell {
24
+ /// A formula cell. A leading `=` is accepted and dropped.
25
+ pub fn formula(expression: impl Into<String>) -> Self {
26
+ let mut expression = expression.into();
27
+ if expression.starts_with('=') {
28
+ expression.remove(0);
29
+ }
30
+ Self::Formula(expression)
31
+ }
32
+ }
33
+
34
+ impl From<&str> for Cell {
35
+ fn from(text: &str) -> Self {
36
+ Self::Text(text.to_owned())
37
+ }
38
+ }
39
+
40
+ impl From<String> for Cell {
41
+ fn from(text: String) -> Self {
42
+ Self::Text(text)
43
+ }
44
+ }
45
+
46
+ impl From<bool> for Cell {
47
+ fn from(value: bool) -> Self {
48
+ Self::Bool(value)
49
+ }
50
+ }
51
+
52
+ impl From<DateTime> for Cell {
53
+ fn from(value: DateTime) -> Self {
54
+ Self::DateTime(value)
55
+ }
56
+ }
57
+
58
+ impl From<f64> for Cell {
59
+ fn from(value: f64) -> Self {
60
+ Self::Number(value)
61
+ }
62
+ }
63
+
64
+ /// Integers that convert to `f64` without loss; Excel stores every number as one.
65
+ macro_rules! lossless_numbers {
66
+ ($($int:ty),*) => {$(
67
+ impl From<$int> for Cell {
68
+ fn from(value: $int) -> Self {
69
+ Self::Number(f64::from(value))
70
+ }
71
+ }
72
+ )*};
73
+ }
74
+ lossless_numbers!(f32, i8, i16, i32, u8, u16, u32);
75
+
76
+ /// The column's letters: 0 is `A`, 25 is `Z`, 26 is `AA`.
77
+ #[must_use]
78
+ pub fn column_name(column: u16) -> String {
79
+ let mut name = String::with_capacity(3);
80
+ push_column_name(&mut name, column);
81
+ name
82
+ }
83
+
84
+ /// A1-style reference for a zero-based row and column.
85
+ #[must_use]
86
+ pub fn cell_reference(row: u32, column: u16) -> String {
87
+ let mut reference = String::with_capacity(10);
88
+ push_cell_reference(&mut reference, row, column);
89
+ reference
90
+ }
91
+
92
+ /// Appends the column's letters to `out`, without allocating.
93
+ pub fn push_column_name(out: &mut String, column: u16) {
94
+ const ALPHABET: &[u8; 26] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
95
+ // XFD, the last column, is three letters.
96
+ let mut letters = [0_u8; 3];
97
+ let mut length = 0;
98
+ let mut rest = usize::from(column) + 1;
99
+ while rest > 0 && length < letters.len() {
100
+ letters[length] = ALPHABET[(rest - 1) % 26];
101
+ length += 1;
102
+ rest = (rest - 1) / 26;
103
+ }
104
+ out.extend(letters[..length].iter().rev().map(|&letter| char::from(letter)));
105
+ }
106
+
107
+ /// Appends an A1-style reference to `out`, without allocating.
108
+ pub fn push_cell_reference(out: &mut String, row: u32, column: u16) {
109
+ push_column_name(out, column);
110
+ // Writing to a String cannot fail.
111
+ let _ = write!(out, "{}", u64::from(row) + 1);
112
+ }
@@ -0,0 +1,256 @@
1
+ //! Cell comments (Excel's notes). The text lives in `xl/commentsN.xml`; the
2
+ //! box Excel draws for each one is a legacy VML shape in
3
+ //! `xl/drawings/vmlDrawingN.vml`, which Excel still requires to show them.
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ use std::collections::{BTreeMap, HashMap};
9
+
10
+ use super::cell::push_cell_reference;
11
+ use super::package::MAIN_NS;
12
+ use super::text::{check_length, escape, needs_preserve};
13
+ use super::worksheet::{MAX_COLUMNS, MAX_ROWS};
14
+ use crate::Result;
15
+ use crate::xml::XmlWriter;
16
+
17
+ /// VML shape ids come in blocks of 1024 per `o:idmap` entry.
18
+ pub const SHAPES_PER_BLOCK: u32 = 1024;
19
+ /// Excel's default note: 128 by 74 pixels, 15 to the right of the next
20
+ /// column and 10 below the row above, on a grid of 64 by 20 pixel cells.
21
+ const NOTE_SIZE: (u32, u32) = (128, 74);
22
+ const DEFAULT_COLUMN_PIXELS: u32 = 64;
23
+ const ROW_PIXELS: u32 = 20;
24
+ /// Calibri 11, the default font: its widest digit and the cell padding.
25
+ const DIGIT_PIXELS: f64 = 7.0;
26
+ const PADDING_PIXELS: f64 = 5.0;
27
+
28
+ /// A note on a cell, set with
29
+ /// [`Worksheet::set_comment`](super::Worksheet::set_comment). Hidden until
30
+ /// the pointer rests on the cell, unless made visible.
31
+ #[derive(Debug, Clone, PartialEq, Eq)]
32
+ pub struct Comment {
33
+ text: String,
34
+ author: Option<String>,
35
+ visible: bool,
36
+ }
37
+
38
+ impl Comment {
39
+ /// # Errors
40
+ /// [`Error::TextTooLong`](crate::Error::TextTooLong) over 32,767 characters.
41
+ pub fn new(text: impl Into<String>) -> Result<Self> {
42
+ let text = text.into();
43
+ check_length(&text)?;
44
+ Ok(Self { text, author: None, visible: false })
45
+ }
46
+
47
+ #[must_use]
48
+ pub fn author(mut self, author: impl Into<String>) -> Self {
49
+ self.author = Some(author.into()).filter(|author| !author.is_empty());
50
+ self
51
+ }
52
+
53
+ /// Shows the note all the time, not only on hover.
54
+ #[must_use]
55
+ pub const fn visible(mut self, visible: bool) -> Self {
56
+ self.visible = visible;
57
+ self
58
+ }
59
+
60
+ #[must_use]
61
+ pub fn text(&self) -> &str {
62
+ &self.text
63
+ }
64
+ }
65
+
66
+ /// `xl/commentsN.xml`: the authors, then each comment in row-major order.
67
+ /// A comment without an author refers to an empty one.
68
+ pub fn comments_xml(comments: &BTreeMap<(u32, u16), Comment>) -> String {
69
+ let mut authors: Vec<&str> = Vec::new();
70
+ let mut author_ids: HashMap<&str, usize> = HashMap::new();
71
+ let ids: Vec<usize> = comments
72
+ .values()
73
+ .map(|comment| {
74
+ let author = comment.author.as_deref().unwrap_or("");
75
+ *author_ids.entry(author).or_insert_with(|| {
76
+ authors.push(author);
77
+ authors.len() - 1
78
+ })
79
+ })
80
+ .collect();
81
+
82
+ let mut xml = XmlWriter::new();
83
+ xml.open("comments", &[("xmlns", MAIN_NS)]);
84
+ xml.open("authors", &[]);
85
+ for author in &authors {
86
+ xml.element("author", &[], &escape(author));
87
+ }
88
+ xml.close("authors");
89
+ xml.open("commentList", &[]);
90
+ let mut reference = String::new();
91
+ for ((&(row, column), comment), author) in comments.iter().zip(ids) {
92
+ reference.clear();
93
+ push_cell_reference(&mut reference, row, column);
94
+ xml.open("comment", &[("ref", &reference), ("authorId", &author.to_string())]);
95
+ xml.open("text", &[]);
96
+ let preserve: &[(&str, &str)] =
97
+ if needs_preserve(&comment.text) { &[("xml:space", "preserve")] } else { &[] };
98
+ xml.element("t", preserve, &escape(&comment.text));
99
+ xml.close("text");
100
+ xml.close("comment");
101
+ }
102
+ xml.close("commentList");
103
+ xml.close("comments");
104
+ xml.finish()
105
+ }
106
+
107
+ /// How many shape id blocks a drawing of `count` notes takes: ids run from
108
+ /// `block * 1024 + 1`, so the last of a full block spills into the next.
109
+ pub fn blocks_for(count: usize) -> u32 {
110
+ u32::try_from(count).map_or(u32::MAX, |count| count / SHAPES_PER_BLOCK + 1)
111
+ }
112
+
113
+ /// `xl/drawings/vmlDrawingN.vml`: one note shape per comment, with ids
114
+ /// from `first_block`, which no other drawing in the workbook uses.
115
+ pub fn vml(
116
+ comments: &BTreeMap<(u32, u16), Comment>,
117
+ widths: &BTreeMap<u16, f64>,
118
+ first_block: u32,
119
+ ) -> String {
120
+ let mut xml = XmlWriter::bare();
121
+ xml.open(
122
+ "xml",
123
+ &[
124
+ ("xmlns:v", "urn:schemas-microsoft-com:vml"),
125
+ ("xmlns:o", "urn:schemas-microsoft-com:office:office"),
126
+ ("xmlns:x", "urn:schemas-microsoft-com:office:excel"),
127
+ ],
128
+ );
129
+ xml.open("o:shapelayout", &[("v:ext", "edit")]);
130
+ let blocks: Vec<String> =
131
+ (first_block..first_block + blocks_for(comments.len())).map(|b| b.to_string()).collect();
132
+ xml.empty("o:idmap", &[("v:ext", "edit"), ("data", &blocks.join(","))]);
133
+ xml.close("o:shapelayout");
134
+ xml.open(
135
+ "v:shapetype",
136
+ &[
137
+ ("id", "_x0000_t202"),
138
+ ("coordsize", "21600,21600"),
139
+ ("o:spt", "202"),
140
+ ("path", "m,l,21600r21600,l21600,xe"),
141
+ ],
142
+ );
143
+ xml.empty("v:stroke", &[("joinstyle", "miter")]);
144
+ xml.empty("v:path", &[("gradientshapeok", "t"), ("o:connecttype", "rect")]);
145
+ xml.close("v:shapetype");
146
+ let first_id = first_block * SHAPES_PER_BLOCK + 1;
147
+ for (index, (&(row, column), comment)) in (0..).zip(comments) {
148
+ write_shape(&mut xml, first_id + index, index + 1, (row, column, comment.visible), widths);
149
+ }
150
+ xml.close("xml");
151
+ xml.finish()
152
+ }
153
+
154
+ fn write_shape(
155
+ xml: &mut XmlWriter,
156
+ id: u32,
157
+ z_index: u32,
158
+ (row, column, visible): (u32, u16, bool),
159
+ widths: &BTreeMap<u16, f64>,
160
+ ) {
161
+ let anchor = Anchor::new(row, column, widths);
162
+ let points = |pixels: u32| f64::from(pixels) * 0.75;
163
+ let style = format!(
164
+ "position:absolute;margin-left:{}pt;margin-top:{}pt;width:{}pt;height:{}pt;\
165
+ z-index:{z_index};visibility:{}",
166
+ points(anchor.left),
167
+ points(anchor.top),
168
+ points(NOTE_SIZE.0),
169
+ points(NOTE_SIZE.1),
170
+ if visible { "visible" } else { "hidden" },
171
+ );
172
+ let shape_id = format!("_x0000_s{id}");
173
+ xml.open(
174
+ "v:shape",
175
+ &[
176
+ ("id", &shape_id),
177
+ ("type", "#_x0000_t202"),
178
+ ("style", &style),
179
+ ("fillcolor", "#ffffe1"),
180
+ ("o:insetmode", "auto"),
181
+ ],
182
+ );
183
+ xml.empty("v:fill", &[("color2", "#ffffe1")]);
184
+ xml.empty("v:shadow", &[("on", "t"), ("color", "black"), ("obscured", "t")]);
185
+ xml.empty("v:path", &[("o:connecttype", "none")]);
186
+ xml.open("v:textbox", &[("style", "mso-direction-alt:auto")]);
187
+ xml.element("div", &[("style", "text-align:left")], "");
188
+ xml.close("v:textbox");
189
+ xml.open("x:ClientData", &[("ObjectType", "Note")]);
190
+ xml.empty("x:MoveWithCells", &[]);
191
+ xml.empty("x:SizeWithCells", &[]);
192
+ xml.element("x:Anchor", &[], &anchor.cells);
193
+ xml.element("x:AutoFill", &[], "False");
194
+ xml.element("x:Row", &[], &row.to_string());
195
+ xml.element("x:Column", &[], &column.to_string());
196
+ if visible {
197
+ xml.empty("x:Visible", &[]);
198
+ }
199
+ xml.close("x:ClientData");
200
+ xml.close("v:shape");
201
+ }
202
+
203
+ /// Where a note's box sits: `x:Anchor` in cells and pixel offsets, and its
204
+ /// top-left corner in pixels from the sheet's.
205
+ struct Anchor {
206
+ cells: String,
207
+ left: u32,
208
+ top: u32,
209
+ }
210
+
211
+ impl Anchor {
212
+ /// Beside the cell, from the next column and the row above, as Excel
213
+ /// places a new note; held inside the sheet at its right and bottom edges.
214
+ fn new(row: u32, column: u16, widths: &BTreeMap<u16, f64>) -> Self {
215
+ let width = |column: u16| widths.get(&column).map_or(DEFAULT_COLUMN_PIXELS, |&w| pixels(w));
216
+ // Past the columns an offset spans, as Excel counts them: narrower
217
+ // columns are skipped.
218
+ let walk = |mut column: u16, mut offset: u32| {
219
+ while offset >= width(column) && column < MAX_COLUMNS - 1 {
220
+ offset -= width(column);
221
+ column += 1;
222
+ }
223
+ (column, offset)
224
+ };
225
+ let (first_column, x1) = walk((column + 1).min(MAX_COLUMNS - 4), 15);
226
+ let (last_column, x2) = walk(first_column, x1 + NOTE_SIZE.0);
227
+ let (first_row, y1) =
228
+ if row == 0 { (0, 2) } else { ((row - 1).min(MAX_ROWS - 6), ROW_PIXELS / 2) };
229
+ let height = y1 + NOTE_SIZE.1;
230
+ let (last_row, y2) = (first_row + height / ROW_PIXELS, height % ROW_PIXELS);
231
+
232
+ let custom: i64 = widths
233
+ .range(..first_column)
234
+ .map(|(_, &w)| i64::from(pixels(w)) - i64::from(DEFAULT_COLUMN_PIXELS))
235
+ .sum();
236
+ let left = i64::from(first_column) * i64::from(DEFAULT_COLUMN_PIXELS) + custom;
237
+ let left = u32::try_from(left).unwrap_or(0) + x1;
238
+ let top = first_row.saturating_mul(ROW_PIXELS).saturating_add(y1);
239
+ let cells = format!(
240
+ "{first_column}, {x1}, {first_row}, {y1}, {last_column}, {x2}, {last_row}, {y2}"
241
+ );
242
+ Self { cells, left, top }
243
+ }
244
+ }
245
+
246
+ /// A column width in characters as Excel draws it, in pixels.
247
+ #[expect(clippy::cast_possible_truncation, reason = "widths are 0 to 255: at most 1791")]
248
+ #[expect(clippy::cast_sign_loss, reason = "widths are never negative")]
249
+ fn pixels(width: f64) -> u32 {
250
+ let pixels = if width < 1.0 {
251
+ width.mul_add(DIGIT_PIXELS + PADDING_PIXELS, 0.5)
252
+ } else {
253
+ width.mul_add(DIGIT_PIXELS, 0.5) + PADDING_PIXELS
254
+ };
255
+ pixels.floor() as u32
256
+ }