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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Cargo.lock +123 -3
- data/Cargo.toml +1 -1
- data/README.md +98 -20
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +6 -1
- data/ext/imprimatur_native/core/README.md +12 -3
- data/ext/imprimatur_native/core/src/archive.rs +3 -16
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
- data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
- data/ext/imprimatur_native/core/src/doc.rs +86 -2
- data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
- data/ext/imprimatur_native/core/src/docx.rs +91 -28
- data/ext/imprimatur_native/core/src/error.rs +12 -0
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +3 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
- data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
- data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
- data/ext/imprimatur_native/core/src/pdf.rs +31 -3
- data/ext/imprimatur_native/core/src/read/content.rs +75 -2
- data/ext/imprimatur_native/core/src/read/json.rs +94 -7
- data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
- data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
- data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
- data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
- data/ext/imprimatur_native/core/src/read.rs +4 -1
- data/ext/imprimatur_native/core/src/script.rs +116 -0
- data/ext/imprimatur_native/core/src/style.rs +21 -0
- data/ext/imprimatur_native/core/src/test_support.rs +3 -2
- data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
- data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
- data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
- data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
- data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
- data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +38 -1
- data/ext/imprimatur_native/ffi/src/read.rs +8 -11
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -6
- data/lib/imprimatur/reader.rb +2 -2
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur.rb +15 -3
- metadata +11 -1
|
@@ -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
|
|
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
|
}
|
|
@@ -144,13 +144,86 @@ pub struct Mark {
|
|
|
144
144
|
pub link: Option<String>,
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/// A workbook sheet: its cells,
|
|
148
|
-
|
|
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.
|
|
@@ -9,17 +9,34 @@ mod tests;
|
|
|
9
9
|
|
|
10
10
|
use serde_json::{Map, Value as Json, json};
|
|
11
11
|
|
|
12
|
-
use super::{
|
|
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
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
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
|
|
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
use std::fmt::Write as _;
|
|
6
6
|
|
|
7
|
-
use crate::Result;
|
|
8
|
-
use crate::read::Budget;
|
|
9
7
|
use crate::read::content::Value;
|
|
10
8
|
use crate::xlsx::DateTime;
|
|
11
|
-
use crate::xml::reader::{Event, XmlReader};
|
|
12
9
|
|
|
13
10
|
/// How a number format shows a number.
|
|
14
11
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
@@ -17,43 +14,24 @@ pub enum Shown {
|
|
|
17
14
|
Date,
|
|
18
15
|
Time,
|
|
19
16
|
DateTime,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
while let Some(event) = xml.next()? {
|
|
28
|
-
match event {
|
|
29
|
-
Event::Start(tag) => match tag.name() {
|
|
30
|
-
"numFmt" => {
|
|
31
|
-
if let (Some(id), Some(code)) = (tag.attr("numFmtId"), tag.attr("formatCode")) {
|
|
32
|
-
custom.push((id.parse::<u32>().unwrap_or(u32::MAX), classify(&code)));
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
"cellXfs" => in_cell_formats = true,
|
|
36
|
-
"xf" if in_cell_formats => {
|
|
37
|
-
let id =
|
|
38
|
-
tag.attr("numFmtId").and_then(|id| id.parse::<u32>().ok()).unwrap_or(0);
|
|
39
|
-
let custom =
|
|
40
|
-
custom.iter().find(|&&(custom, _)| custom == id).map(|&(_, shown)| shown);
|
|
41
|
-
shown.push(custom.unwrap_or_else(|| built_in(id)));
|
|
42
|
-
}
|
|
43
|
-
_ => {}
|
|
44
|
-
},
|
|
45
|
-
Event::End("cellXfs") => in_cell_formats = false,
|
|
46
|
-
_ => {}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
Ok(shown)
|
|
17
|
+
/// A fixed number of decimals, thousands grouped with `,` or not, as a
|
|
18
|
+
/// percentage or not: `0.00`, `#,##0`, `0%`.
|
|
19
|
+
Fixed {
|
|
20
|
+
decimals: u8,
|
|
21
|
+
grouping: bool,
|
|
22
|
+
percent: bool,
|
|
23
|
+
},
|
|
50
24
|
}
|
|
51
25
|
|
|
52
26
|
/// Excel's built-in number formats (ECMA-376 Part 1, 18.8.30), with the
|
|
53
27
|
/// East Asian date formats.
|
|
54
28
|
pub const fn built_in(id: u32) -> Shown {
|
|
55
29
|
match id {
|
|
56
|
-
|
|
30
|
+
2 => fixed(2, false, false),
|
|
31
|
+
3 | 37 | 38 => fixed(0, true, false),
|
|
32
|
+
4 | 39 | 40 => fixed(2, true, false),
|
|
33
|
+
9 => fixed(0, false, true),
|
|
34
|
+
10 => fixed(2, false, true),
|
|
57
35
|
14..=17 | 27..=31 | 34..=36 | 50..=58 => Shown::Date,
|
|
58
36
|
18..=21 | 32 | 33 | 45..=47 => Shown::Time,
|
|
59
37
|
22 => Shown::DateTime,
|
|
@@ -61,14 +39,26 @@ pub const fn built_in(id: u32) -> Shown {
|
|
|
61
39
|
}
|
|
62
40
|
}
|
|
63
41
|
|
|
42
|
+
pub const fn fixed(decimals: u8, grouping: bool, percent: bool) -> Shown {
|
|
43
|
+
Shown::Fixed { decimals, grouping, percent }
|
|
44
|
+
}
|
|
45
|
+
|
|
64
46
|
/// What a custom format code shows, from its first section: date and time
|
|
65
|
-
/// tokens, outside quoted text, escapes and
|
|
66
|
-
/// `[h]` is elapsed time).
|
|
47
|
+
/// tokens, decimals, `,` grouping and `%`, outside quoted text, escapes and
|
|
48
|
+
/// `[...]` (colors, locales; `[h]` is elapsed time). Scientific formats
|
|
49
|
+
/// (`0.00E+00`) are shown as General.
|
|
67
50
|
pub fn classify(code: &str) -> Shown {
|
|
68
51
|
let (mut date, mut time, mut month, mut percent) = (false, false, false, false);
|
|
69
|
-
let mut
|
|
52
|
+
let (mut digits, mut point, mut decimals, mut grouping, mut scientific) =
|
|
53
|
+
(false, false, 0_u8, false, false);
|
|
54
|
+
let mut chars = code.chars().peekable();
|
|
70
55
|
while let Some(ch) = chars.next() {
|
|
71
56
|
match ch.to_ascii_lowercase() {
|
|
57
|
+
'0' | '#' | '?' if point => decimals = decimals.saturating_add(1),
|
|
58
|
+
'0' | '#' | '?' => digits = true,
|
|
59
|
+
'.' => point = true,
|
|
60
|
+
',' if digits && !point => grouping = true,
|
|
61
|
+
'e' if matches!(chars.peek(), Some('+' | '-')) => scientific = true,
|
|
72
62
|
';' => break,
|
|
73
63
|
'"' => {
|
|
74
64
|
chars.by_ref().find(|&ch| ch == '"');
|
|
@@ -91,8 +81,8 @@ pub fn classify(code: &str) -> Shown {
|
|
|
91
81
|
(true, true) => Shown::DateTime,
|
|
92
82
|
(true, false) => Shown::Date,
|
|
93
83
|
(false, true) => Shown::Time,
|
|
94
|
-
(false, false) if percent => Shown::
|
|
95
|
-
(false, false) => Shown::
|
|
84
|
+
(false, false) if scientific || !(decimals > 0 || grouping || percent) => Shown::General,
|
|
85
|
+
(false, false) => Shown::Fixed { decimals, grouping, percent },
|
|
96
86
|
}
|
|
97
87
|
}
|
|
98
88
|
|
|
@@ -130,15 +120,48 @@ pub fn push_number(
|
|
|
130
120
|
let _ =
|
|
131
121
|
write!(out, "{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60);
|
|
132
122
|
}
|
|
133
|
-
Shown::
|
|
134
|
-
|
|
135
|
-
out
|
|
123
|
+
Shown::Fixed { decimals, grouping, percent } => {
|
|
124
|
+
let number = if percent { number * 100.0 } else { number };
|
|
125
|
+
push_fixed(out, number, decimals, grouping, scratch);
|
|
126
|
+
if percent {
|
|
127
|
+
out.push('%');
|
|
128
|
+
}
|
|
136
129
|
}
|
|
137
130
|
_ => push_general(out, number, scratch),
|
|
138
131
|
}
|
|
139
132
|
Value::Number(number)
|
|
140
133
|
}
|
|
141
134
|
|
|
135
|
+
/// A number with `decimals` decimals, its thousands grouped with `,`,
|
|
136
|
+
/// rounded half away from zero as Excel does (`12.5` shows `13`, `1.005`
|
|
137
|
+
/// `1.01`): a few units in the last place toward the tie undo the binary
|
|
138
|
+
/// error that makes `1.005` `1.00499...`, which Rust would round down, as it
|
|
139
|
+
/// rounds an exact tie to even.
|
|
140
|
+
fn push_fixed(out: &mut String, number: f64, decimals: u8, grouping: bool, scratch: &mut String) {
|
|
141
|
+
let number = (number.abs() * 4.0 * f64::EPSILON).mul_add(number.signum(), number);
|
|
142
|
+
scratch.clear();
|
|
143
|
+
// Writing to a String cannot fail.
|
|
144
|
+
let _ = write!(scratch, "{number:.*}", usize::from(decimals));
|
|
145
|
+
if !grouping {
|
|
146
|
+
out.push_str(scratch);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let (sign, digits) =
|
|
150
|
+
scratch.strip_prefix('-').map_or(("", scratch.as_str()), |rest| ("-", rest));
|
|
151
|
+
let (whole, fraction) = digits.split_once('.').map_or((digits, None), |(w, f)| (w, Some(f)));
|
|
152
|
+
out.push_str(sign);
|
|
153
|
+
for (index, digit) in whole.char_indices() {
|
|
154
|
+
if index > 0 && (whole.len() - index).is_multiple_of(3) {
|
|
155
|
+
out.push(',');
|
|
156
|
+
}
|
|
157
|
+
out.push(digit);
|
|
158
|
+
}
|
|
159
|
+
if let Some(fraction) = fraction {
|
|
160
|
+
out.push('.');
|
|
161
|
+
out.push_str(fraction);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
/// A number as Excel's General format shows it: 15 significant digits.
|
|
143
166
|
pub fn push_general(out: &mut String, number: f64, scratch: &mut String) {
|
|
144
167
|
#[expect(clippy::cast_possible_truncation, reason = "whole and under 1e15: exact in i64")]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
//! Right-to-left text, from the order a PDF draws it to the order it reads.
|
|
2
|
+
//!
|
|
3
|
+
//! A PDF draws every line left to right, whatever its script: a Hebrew or
|
|
4
|
+
//! Arabic line comes out backwards, and so does an Arabic name inside an
|
|
5
|
+
//! English line. Each piece of a line gets the level the Unicode
|
|
6
|
+
//! bidirectional algorithm (UAX #9) gives it, even for left to right, odd
|
|
7
|
+
//! for right to left, and the runs are reversed the way a renderer reverses
|
|
8
|
+
//! them: reversed twice, a line reads as it was written. The page's own
|
|
9
|
+
//! direction stands in for the paragraph's, which a PDF does not record.
|
|
10
|
+
|
|
11
|
+
#[cfg(test)]
|
|
12
|
+
mod tests;
|
|
13
|
+
|
|
14
|
+
use std::ops::Range;
|
|
15
|
+
|
|
16
|
+
use crate::script::{Class, Counts, class};
|
|
17
|
+
|
|
18
|
+
/// Reusable buffers for [`reading_order`].
|
|
19
|
+
#[derive(Debug, Default)]
|
|
20
|
+
pub struct Scratch {
|
|
21
|
+
classes: Vec<Class>,
|
|
22
|
+
levels: Vec<u8>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Combining marks: diacritics, and the vowel points and signs of the
|
|
26
|
+
/// right-to-left scripts. Drawn as glyphs of their own, they never start a
|
|
27
|
+
/// word.
|
|
28
|
+
pub const fn is_mark(ch: char) -> bool {
|
|
29
|
+
matches!(
|
|
30
|
+
ch,
|
|
31
|
+
'\u{0300}'..='\u{036F}'
|
|
32
|
+
// Hebrew points and cantillation.
|
|
33
|
+
| '\u{0591}'..='\u{05C7}'
|
|
34
|
+
| '\u{FB1E}'
|
|
35
|
+
// Arabic harakat, Quranic annotation, extended Arabic marks.
|
|
36
|
+
| '\u{0610}'..='\u{061A}'
|
|
37
|
+
| '\u{064B}'..='\u{065F}'
|
|
38
|
+
| '\u{0670}'
|
|
39
|
+
| '\u{06D6}'..='\u{06ED}'
|
|
40
|
+
| '\u{0898}'..='\u{089F}'
|
|
41
|
+
| '\u{08CA}'..='\u{08E1}'
|
|
42
|
+
| '\u{08E3}'..='\u{08FF}'
|
|
43
|
+
// Syriac, Thaana, N'Ko, Samaritan, Mandaic.
|
|
44
|
+
| '\u{0711}'
|
|
45
|
+
| '\u{0730}'..='\u{074A}'
|
|
46
|
+
| '\u{07A6}'..='\u{07B0}'
|
|
47
|
+
| '\u{07EB}'..='\u{07F3}'
|
|
48
|
+
| '\u{07FD}'
|
|
49
|
+
| '\u{0816}'..='\u{0819}'
|
|
50
|
+
| '\u{081B}'..='\u{0823}'
|
|
51
|
+
| '\u{0825}'..='\u{0827}'
|
|
52
|
+
| '\u{0829}'..='\u{082D}'
|
|
53
|
+
| '\u{0859}'..='\u{085B}'
|
|
54
|
+
| '\u{1AB0}'..='\u{1AFF}'
|
|
55
|
+
| '\u{20D0}'..='\u{20FF}'
|
|
56
|
+
| '\u{FE20}'..='\u{FE2F}'
|
|
57
|
+
// Hanifi Rohingya, Adlam.
|
|
58
|
+
| '\u{10D24}'..='\u{10D27}'
|
|
59
|
+
| '\u{1E944}'..='\u{1E94A}'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Whether a line reads right to left, from its letters and its page's
|
|
64
|
+
/// direction. On a right-to-left page a line does when it has a
|
|
65
|
+
/// right-to-left letter at all, or only figures (a table row reads from the
|
|
66
|
+
/// right); on a left-to-right page only when its letters are all, or nearly
|
|
67
|
+
/// all, right to left: an Arabic name in an English line stays in an
|
|
68
|
+
/// English line.
|
|
69
|
+
pub const fn line_right_to_left(line: Counts, page_right_to_left: bool) -> bool {
|
|
70
|
+
if page_right_to_left {
|
|
71
|
+
line.right > 0 || line.left == 0 && line.numbers > 0
|
|
72
|
+
} else {
|
|
73
|
+
line.right > 0 && line.right >= 4 * line.left
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Puts a line's pieces, drawn left to right, in reading order. Lines
|
|
78
|
+
/// with nothing right to left in them are left as they are.
|
|
79
|
+
pub fn reading_order(pieces: &mut [&str], page_right_to_left: bool, scratch: &mut Scratch) {
|
|
80
|
+
let mut counts = Counts::default();
|
|
81
|
+
for piece in pieces.iter() {
|
|
82
|
+
counts.add(piece);
|
|
83
|
+
}
|
|
84
|
+
let right_to_left = line_right_to_left(counts, page_right_to_left);
|
|
85
|
+
if right_to_left || counts.right > 0 {
|
|
86
|
+
reorder(pieces, right_to_left, scratch);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// UAX #9 on pieces in drawn order: classes, numbers (W2, W7, W4, W5),
|
|
91
|
+
/// neutrals (N1, N2), levels (I1, I2), then the runs reversed (L2).
|
|
92
|
+
fn reorder(pieces: &mut [&str], right_to_left: bool, scratch: &mut Scratch) {
|
|
93
|
+
let Scratch { classes, levels } = scratch;
|
|
94
|
+
classes.clear();
|
|
95
|
+
classes.extend(pieces.iter().map(|piece| {
|
|
96
|
+
piece.chars().map(class).find(|&class| class != Class::Neutral).unwrap_or(Class::Neutral)
|
|
97
|
+
}));
|
|
98
|
+
resolve_numbers(classes, right_to_left);
|
|
99
|
+
join_numbers(pieces, classes);
|
|
100
|
+
resolve_neutrals(classes, right_to_left);
|
|
101
|
+
levels.clear();
|
|
102
|
+
levels.extend(classes.iter().map(|class| match class {
|
|
103
|
+
Class::Right | Class::Arabic => 1,
|
|
104
|
+
Class::Number | Class::ArabicNumber => 2,
|
|
105
|
+
Class::Left | Class::Neutral => {
|
|
106
|
+
if right_to_left {
|
|
107
|
+
2
|
|
108
|
+
} else {
|
|
109
|
+
0
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
// From the highest level to the lowest odd one, every run at that
|
|
114
|
+
// level or above reversed.
|
|
115
|
+
for level in [2, 1] {
|
|
116
|
+
let mut from = 0;
|
|
117
|
+
while let Some(run) = run(levels, from, |&piece| piece >= level) {
|
|
118
|
+
from = run.end;
|
|
119
|
+
pieces[run].reverse();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// The next run of items, from `from` on, that `wanted` picks.
|
|
125
|
+
fn run<T>(items: &[T], from: usize, wanted: impl Fn(&T) -> bool) -> Option<Range<usize>> {
|
|
126
|
+
let start = from + items.get(from..)?.iter().position(&wanted)?;
|
|
127
|
+
let length = items[start..].iter().position(|item| !wanted(item));
|
|
128
|
+
Some(start..length.map_or(items.len(), |length| start + length))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// What joins two digits into one number, Arabic numbers included:
|
|
132
|
+
/// `1,234.5`, `9:30`, `3/4`.
|
|
133
|
+
const SEPARATORS: [&str; 8] = [",", ".", ":", "/", "\u{00A0}", "\u{060C}", "\u{066B}", "\u{066C}"];
|
|
134
|
+
|
|
135
|
+
/// What joins two digits into one number in any but Arabic text:
|
|
136
|
+
/// `2024-03-15`, `+966`.
|
|
137
|
+
const SIGNS_BETWEEN: [&str; 3] = ["+", "-", "\u{2212}"];
|
|
138
|
+
|
|
139
|
+
/// Signs that go with the number next to them, in any but Arabic text:
|
|
140
|
+
/// `50%`, `$5`.
|
|
141
|
+
const SIGNS: [&str; 11] = ["%", "‰", "‱", "°", "$", "€", "£", "¥", "¢", "#", "\u{066A}"];
|
|
142
|
+
|
|
143
|
+
/// What kind of number each run of digits is. A number touching a letter
|
|
144
|
+
/// (`ISO9001`) goes with its word. In a right-to-left line, a number after
|
|
145
|
+
/// left-to-right text, the next letter to its left, is part of that text
|
|
146
|
+
/// (`ISO 9001`); in a left-to-right line, a number is part of a
|
|
147
|
+
/// right-to-left run when one follows it, the next letter to its right.
|
|
148
|
+
/// Digits following Arabic letters, in reading order, are Arabic numbers.
|
|
149
|
+
fn resolve_numbers(classes: &mut [Class], right_to_left: bool) {
|
|
150
|
+
let mut from = 0;
|
|
151
|
+
while let Some(Range { start, end: at }) = run(classes, from, |&class| class == Class::Number) {
|
|
152
|
+
from = at;
|
|
153
|
+
let glued = (start > 0 && classes[start - 1] == Class::Left)
|
|
154
|
+
|| classes.get(at) == Some(&Class::Left);
|
|
155
|
+
let before = classes[..start].iter().rev().find(|class| class.strong());
|
|
156
|
+
let after = classes[at..].iter().find(|class| class.strong());
|
|
157
|
+
let left_to_right = if right_to_left {
|
|
158
|
+
before == Some(&Class::Left)
|
|
159
|
+
} else {
|
|
160
|
+
!after.is_some_and(|class| class.right_to_left())
|
|
161
|
+
};
|
|
162
|
+
if glued || left_to_right {
|
|
163
|
+
classes[start..at].fill(Class::Left);
|
|
164
|
+
} else if after == Some(&Class::Arabic) {
|
|
165
|
+
classes[start..at].fill(Class::ArabicNumber);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Separators between two digits, and signs next to one, as part of the
|
|
171
|
+
/// number, as far as its kind allows.
|
|
172
|
+
fn join_numbers(pieces: &[&str], classes: &mut [Class]) {
|
|
173
|
+
for at in 1..classes.len().saturating_sub(1) {
|
|
174
|
+
let (before, after) = (classes[at - 1], classes[at + 1]);
|
|
175
|
+
let joins = classes[at] == Class::Neutral
|
|
176
|
+
&& before == after
|
|
177
|
+
&& match before {
|
|
178
|
+
Class::Number => {
|
|
179
|
+
SEPARATORS.contains(&pieces[at]) || SIGNS_BETWEEN.contains(&pieces[at])
|
|
180
|
+
}
|
|
181
|
+
Class::ArabicNumber => SEPARATORS.contains(&pieces[at]),
|
|
182
|
+
_ => false,
|
|
183
|
+
};
|
|
184
|
+
if joins {
|
|
185
|
+
classes[at] = before;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for at in (0..classes.len()).chain((0..classes.len()).rev()) {
|
|
189
|
+
let next_to_number = (at > 0 && classes[at - 1] == Class::Number)
|
|
190
|
+
|| classes.get(at + 1) == Some(&Class::Number);
|
|
191
|
+
if classes[at] == Class::Neutral && SIGNS.contains(&pieces[at]) && next_to_number {
|
|
192
|
+
classes[at] = Class::Number;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Spaces and punctuation between two pieces of one direction take it,
|
|
198
|
+
/// numbers counting as right to left; others take the line's.
|
|
199
|
+
fn resolve_neutrals(classes: &mut [Class], right_to_left: bool) {
|
|
200
|
+
let line = if right_to_left { Class::Right } else { Class::Left };
|
|
201
|
+
let side = |class: Class| {
|
|
202
|
+
if class.right_to_left() || class.number() { Class::Right } else { Class::Left }
|
|
203
|
+
};
|
|
204
|
+
let mut from = 0;
|
|
205
|
+
while let Some(Range { start, end: at }) = run(classes, from, |&class| class == Class::Neutral)
|
|
206
|
+
{
|
|
207
|
+
from = at;
|
|
208
|
+
let before = if start == 0 { line } else { side(classes[start - 1]) };
|
|
209
|
+
let after = classes.get(at).map_or(line, |&class| side(class));
|
|
210
|
+
classes[start..at].fill(if before == after { before } else { line });
|
|
211
|
+
}
|
|
212
|
+
}
|