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
@@ -7,6 +7,7 @@ mod layout;
7
7
  #[rustfmt::skip]
8
8
  pub mod metrics;
9
9
  mod objects;
10
+ mod picture;
10
11
  #[cfg(test)]
11
12
  mod tests;
12
13
 
@@ -26,12 +27,17 @@ const FIRST_FONT: usize = 3;
26
27
  const INFO: usize = FIRST_FONT + Face::ALL.len();
27
28
 
28
29
  pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
29
- let mut layout = Layout::new(document.page_size());
30
+ let mut layout = Layout::new(document)?;
31
+ let mut pictures = Vec::new();
30
32
  for block in document.blocks() {
31
33
  match block {
32
34
  Block::Heading { level, text } => layout.heading(*level, text)?,
33
35
  Block::Paragraph(paragraph) => layout.paragraph(paragraph)?,
34
36
  Block::Table(table) => layout.table(table)?,
37
+ Block::Picture(picture) => {
38
+ layout.picture(picture, pictures.len());
39
+ pictures.push(picture);
40
+ }
35
41
  Block::PageBreak => layout.page_break(),
36
42
  }
37
43
  }
@@ -67,6 +73,23 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
67
73
  }
68
74
  Ok(())
69
75
  });
76
+ // Pictures follow the pages: an image, then its soft mask if it has one.
77
+ let mut ids = Vec::with_capacity(pictures.len());
78
+ let mut next = page_id(pages.len());
79
+ for picture in &pictures {
80
+ ids.push(next);
81
+ next += 1 + usize::from(picture::has_mask(picture));
82
+ }
83
+ let images = fmt::from_fn(|f| {
84
+ if !ids.is_empty() {
85
+ f.write_str("/XObject << ")?;
86
+ for (index, id) in ids.iter().enumerate() {
87
+ write!(f, "/Im{index} {id} 0 R ")?;
88
+ }
89
+ f.write_str(">> ")?;
90
+ }
91
+ Ok(())
92
+ });
70
93
  let (width, height) = (Num(width), Num(height));
71
94
  for (index, content) in pages.iter().enumerate() {
72
95
  let id = page_id(index);
@@ -74,11 +97,16 @@ pub fn write<W: Write>(document: &Document, out: W) -> Result<W> {
74
97
  id,
75
98
  format_args!(
76
99
  "<< /Type /Page /Parent {PAGES} 0 R /MediaBox [0 0 {width} {height}] \
77
- /Resources << /Font << {fonts}>> >> /Contents {} 0 R >>",
100
+ /Resources << /Font << {fonts}>> {images}>> /Contents {} 0 R >>",
78
101
  id + 1
79
102
  ),
80
103
  )?;
81
- pdf.stream(id + 1, content)?;
104
+ pdf.stream(id + 1, content, &[])?;
105
+ }
106
+ for (picture, id) in pictures.into_iter().zip(ids) {
107
+ for (offset, stream) in (0..).zip(picture::streams(picture, id + 1)?) {
108
+ pdf.stored(id + offset, format_args!("{}", stream.dictionary), &stream.data)?;
109
+ }
82
110
  }
83
111
  pdf.finish(CATALOG, INFO)
84
112
  }
@@ -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.
@@ -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
 
@@ -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
- Percent,
21
- }
22
-
23
- /// How each cell format (`cellXfs`, by index) shows numbers.
24
- pub fn styles(text: &str, budget: &Budget<'_>) -> Result<Vec<Shown>> {
25
- let mut xml = XmlReader::new(text, budget.max_depth());
26
- let (mut custom, mut shown, mut in_cell_formats) = (Vec::new(), Vec::new(), false);
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
- 9 | 10 => Shown::Percent,
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 `[...]` (colors, locales;
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 chars = code.chars();
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::Percent,
95
- (false, false) => Shown::General,
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::Percent => {
134
- push_general(out, number * 100.0, scratch);
135
- out.push('%');
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")]
@@ -120,8 +120,7 @@ impl Reader {
120
120
  self.sheets.push(Sheet {
121
121
  name: tag.attr("name").map(std::borrow::Cow::into_owned).unwrap_or_default(),
122
122
  hidden,
123
- cells: Grid::default(),
124
- comments: Vec::new(),
123
+ ..Sheet::default()
125
124
  });
126
125
  self.row = 0;
127
126
  }
@@ -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
+ }