Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/office.rs

19.2 KiB, 1 run

created by r2519314175:989, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The Office document edge: a `.docx` read into prose the panel and the model can both use.
2//!
3//! # Why this returns Markdown rather than HTML
4//!
5//! It would be easy to hand back HTML -- `fe2o3_text::doc::html` renders the same tree -- and it
6//! would be worse in three ways at once.
7//!
8//! The document being read is **a stranger's**. It arrived by mail, or from a share, or the user
9//! dragged it in; nothing about it is ours. HTML from a stranger reaching this origin is the risk
10//! `viewer.js` spends its opening comment on, and the answer there is a frame with
11//! `sandbox="allow-scripts"` and never `allow-same-origin`. Markdown needs none of that, because
12//! `DaimondRender.md` already sanitises -- it drops `script style iframe form input button svg`
13//! whole -- and it is the sanitiser this app already trusts for agent-written prose.
14//!
15//! It is also the form the *model* reads best. `## Findings` says what it is; `<h2>` has to be
16//! explained, in every request, forever. And it means the Doc panel and `file_read` render the same
17//! bytes by the same path, so a user and a model looking at one document are looking at one document.
18//!
19//! # It says what it did not draw, in PARTS rather than in a sentence
20//!
21//! A reading view that quietly dropped a chart would be lying by omission, and the number is the
22//! whole of the information: a reader told only that *something* is missing has been told that the
23//! reader cannot be trusted, and nothing else. So `undrawn` carries a kind and a count for each --
24//! `[{ kind: "TextBox", n: 3 }, { kind: "Chart", n: 1 }]` -- and the panel composes the sentence.
25//!
26//! Handing over the finished English would have been shorter by twenty lines and wrong.
27//! `viewer.js` says of itself that every user-visible string in it goes through `opts.t`, "so the
28//! file holds no English of its own that a translation pass cannot reach", and a phrase built in
29//! Rust and printed verbatim is exactly the English a translation pass cannot reach. The MODEL is
30//! addressed in English by every other tool note and keeps the sentence; the USER is not.
31
32use crate::wasm::to_js_err;
33
34use oxedyne_fe2o3_core::prelude::*;
35use oxedyne_fe2o3_file::office::docx;
36use oxedyne_fe2o3_file::office::edit::Find;
37use oxedyne_fe2o3_file::office::odf;
38use oxedyne_fe2o3_file::office::sheet::Ref;
39use oxedyne_fe2o3_file::office::xlsx;
40use oxedyne_fe2o3_text::doc::markdown;
41
42use wasm_bindgen::JsCast;
43use wasm_bindgen::prelude::*;
44
45/// Reads a `.docx` into the prose it holds.
46///
47/// Answers an object: `{ markdown, undrawn, macros, tracked }`. `undrawn` is `null` where everything
48/// in the document reached the page, and a ready-made phrase where it did not.
49#[wasm_bindgen]
50pub fn office_read_doc(bytes: &[u8], media: &str) -> Result<JsValue, JsValue> {
51 // One door for both vocabularies. What a reader wants out of a text document is the same thing
52 // whichever it was written in, and the neutral tree is where the two meet -- which is the whole
53 // point of having one.
54 if media == "Odt" {
55 let r = odf::text::read(bytes).map_err(to_js_err)?;
56 let out = js_sys::Object::new();
57 set(&out, "markdown", &JsValue::from_str(&markdown::write::render(&r.doc)))?;
58 let undrawn = js_sys::Array::new();
59 if r.images > 0 {
60 let one = js_sys::Object::new();
61 set(&one, "kind", &JsValue::from_str("image"))?;
62 set(&one, "n", &JsValue::from_f64(r.images as i32 as f64))?;
63 undrawn.push(&one);
64 }
65 set(&out, "undrawn", &undrawn.into())?;
66 set(&out, "macros", &JsValue::from_bool(r.macros))?;
67 set(&out, "tracked", &JsValue::from_f64(0.0))?;
68 set(&out, "blocks", &JsValue::from_f64(r.doc.blocks.len() as i32 as f64))?;
69 return Ok(out.into());
70 }
71 let r = docx::read(bytes).map_err(to_js_err)?;
72 let out = js_sys::Object::new();
73 let md = markdown::write::render(&r.doc);
74 set(&out, "markdown", &JsValue::from_str(&md))?;
75 let undrawn = js_sys::Array::new();
76 for (kind, n) in &r.undrawn {
77 let one = js_sys::Object::new();
78 set(&one, "kind", &JsValue::from_str(name_of(*kind)))?;
79 set(&one, "n", &JsValue::from_f64(*n as i32 as f64))?;
80 undrawn.push(&one);
81 }
82 set(&out, "undrawn", &undrawn.into())?;
83 set(&out, "macros", &JsValue::from_bool(r.macros))?;
84 // `i32` and not `usize`: a `u64` crosses this boundary as a `BigInt`, which every arithmetic
85 // operation on the JS side then refuses to mix with a number.
86 set(&out, "tracked", &JsValue::from_f64(r.tracked as i32 as f64))?;
87 set(&out, "blocks", &JsValue::from_f64(r.doc.blocks.len() as i32 as f64))?;
88 Ok(out.into())
89}
90
91/// Reads a `.xlsx` into the sheets it holds.
92///
93/// Answers `{ sheets: [{ name, cols, rows, formulas, cut }], macros, missing }`, where each sheet's
94/// `rows` is an array of arrays of strings -- the values AS STORED, which is what the person who
95/// wrote the file saw. Nothing is recalculated; see `oxedyne_fe2o3_file::office::sheet` for why that
96/// is the correct answer and not a missing feature.
97///
98/// Each sheet is cut to a rectangle before it crosses the boundary. A sheet may be a hundred
99/// thousand rows and the panel can draw a screenful, so sending the rest would cost the copy, the
100/// JS heap and the DOM for something nobody looks at. `cut` says whether it happened, and the panel
101/// says so on screen -- a silent truncation reads as a corrupt file.
102#[wasm_bindgen]
103pub fn office_read_sheet(
104 bytes: &[u8],
105 media: &str,
106 max_rows: u32,
107 max_cols: u32,
108)
109 -> Result<JsValue, JsValue>
110{
111 use oxedyne_fe2o3_file::office::sheet::{Range, Ref, col_name};
112
113 // The same door for both, for the same reason as `office_read_doc`.
114 let (book, macros, missing) = match media {
115 "Ods" => {
116 let r = odf::sheet::read(bytes).map_err(to_js_err)?;
117 (r.book, r.macros, Vec::new())
118 }
119 _ => {
120 let r = xlsx::read(bytes).map_err(to_js_err)?;
121 (r.book, r.macros, r.missing)
122 }
123 };
124 let out = js_sys::Object::new();
125 let sheets = js_sys::Array::new();
126 for s in &book.sheets {
127 let one = js_sys::Object::new();
128 set(&one, "name", &JsValue::from_str(&s.name))?;
129 let (rows, cols) = s.size();
130 set(&one, "rows", &JsValue::from_f64(rows as i32 as f64))?;
131 set(&one, "cols", &JsValue::from_f64(cols as i32 as f64))?;
132 let keep_rows = (rows as u32).min(max_rows.max(1));
133 let keep_cols = (cols as u32).min(max_cols.max(1));
134 set(&one, "cut", &JsValue::from_bool(
135 keep_rows as usize != rows || keep_cols as usize != cols))?;
136 // The column letters, so the grid a person reads is addressed the way a person addresses it
137 // -- and the way `sheet_read` takes a range, which is the same vocabulary.
138 let heads = js_sys::Array::new();
139 for c in 0..keep_cols {
140 heads.push(&JsValue::from_str(&col_name(c)));
141 }
142 set(&one, "heads", &heads.into())?;
143 let grid = js_sys::Array::new();
144 let mut formulas = 0usize;
145 if keep_rows > 0 && keep_cols > 0 {
146 let window = s.window(&Range {
147 from: Ref { col: 0, row: 0 },
148 to: Ref { col: keep_cols - 1, row: keep_rows - 1 },
149 });
150 for line in &window {
151 let js_line = js_sys::Array::new();
152 for cell in line {
153 if cell.formula.is_some() {
154 formulas += 1;
155 }
156 js_line.push(&JsValue::from_str(&cell.value.show()));
157 }
158 grid.push(&js_line.into());
159 }
160 }
161 set(&one, "cells", &grid.into())?;
162 set(&one, "formulas", &JsValue::from_f64(formulas as i32 as f64))?;
163 sheets.push(&one);
164 }
165 set(&out, "sheets", &sheets.into())?;
166 set(&out, "macros", &JsValue::from_bool(macros))?;
167 let gone = js_sys::Array::new();
168 for name in &missing {
169 gone.push(&JsValue::from_str(name));
170 }
171 set(&out, "missing", &gone.into())?;
172 Ok(out.into())
173}
174
175/// Writes Markdown out as the bytes of a `.docx`.
176///
177/// The app action behind "save as Word". **The model does not emit document XML and there is no tool
178/// that lets it**: it writes Markdown, which it does well, and the conversion from there is
179/// deterministic code. Every design where the model emits the format puts it in the position of
180/// getting a file format right, which it will sometimes not; this removes the failure class rather
181/// than mitigating it.
182#[wasm_bindgen]
183pub fn office_write_docx(md: &str) -> Result<Vec<u8>, JsValue> {
184 let doc = markdown::parse(md).map_err(to_js_err)?;
185 let (bytes, _left) = docx::write(&doc).map_err(to_js_err)?;
186 Ok(bytes)
187}
188
189/// Writes Markdown out as the bytes of whichever Office format is named.
190///
191/// The one door for "save as a document", and the reason there is one rather than six is the argument
192/// `office_write_docx` already makes: **the model never emits document XML**. It writes Markdown, the
193/// conversion is deterministic code, and the failure class where a model gets a file format slightly
194/// wrong does not exist rather than being mitigated.
195///
196/// What each format makes of the same prose differs, and the difference is not a loss: a `.docx` and an
197/// `.odt` take the whole document; a `.pptx` and an `.odp` split it at its headings into slides; a
198/// `.xlsx` and an `.ods` take its TABLES, one sheet each, because a spreadsheet built out of paragraphs
199/// would be a spreadsheet with one long column in it.
200#[wasm_bindgen]
201pub fn office_write(md: &str, media: &str) -> Result<Vec<u8>, JsValue> {
202 use oxedyne_fe2o3_file::office::deck::Deck;
203 use oxedyne_fe2o3_file::office::pptx;
204 use oxedyne_fe2o3_file::office::sheet::Book;
205 use oxedyne_fe2o3_file::office::xlsx;
206
207 let doc = markdown::parse(md).map_err(to_js_err)?;
208 let bytes = match media {
209 "Docx" => docx::write(&doc).map_err(to_js_err)?.0,
210 "Odt" => odf::text::write(&doc).map_err(to_js_err)?.0,
211 "Xlsx" => xlsx::write(&Book::from_doc(&doc)).map_err(to_js_err)?,
212 "Ods" => odf::sheet::write(&Book::from_doc(&doc)).map_err(to_js_err)?,
213 "Pptx" => pptx::write(&Deck::from_doc(&doc)).map_err(to_js_err)?.0,
214 "Odp" => odf::slides::write(&Deck::from_doc(&doc)).map_err(to_js_err)?.0,
215 other => return Err(to_js_err(err!(
216 "'{}' is not a document format this writes. It writes Docx, Odt, Xlsx, Ods, Pptx and \
217 Odp.", other; Invalid, Input, Unimplemented))),
218 };
219 Ok(bytes)
220}
221
222/// Replaces text in a document that already exists, leaving every other byte of it alone.
223///
224/// `edits` is JSON: `[{"find": "...", "replace": "...", "nth": 1}]`, `nth` optional and counted from
225/// one through the whole document. **An unmatched `find` is an error naming the string** and NOTHING is
226/// written -- a caller told "the document was edited" has no way to discover that one of its four
227/// replacements did nothing, so a silent no-op would be reported to the user as a change.
228///
229/// Only `.docx` and `.odt`. A deck is not edited here and it is not an oversight: a slide is a position
230/// on a canvas, and changing the words without knowing the geometry puts text over other text. A
231/// spreadsheet is edited by `office_edit_sheet`, whose unit is a cell.
232#[wasm_bindgen]
233pub fn office_edit_doc(bytes: &[u8], media: &str, edits: &str) -> Result<Vec<u8>, JsValue> {
234 let asks = finds_of(edits)?;
235 let out = match media {
236 "Odt" => odf::text::edit(bytes, &asks).map_err(to_js_err)?.bytes,
237 "Docx" => docx::edit::edit(bytes, &asks).map_err(to_js_err)?.bytes,
238 other => return Err(to_js_err(err!(
239 "'{}' is not a format whose text can be edited in place. Word and OpenDocument text \
240 documents can; a presentation cannot, because a slide is a position on a canvas and \
241 changing its words without knowing the geometry puts text over other text.", other;
242 Invalid, Input, Unimplemented))),
243 };
244 Ok(out)
245}
246
247/// Writes cells into a spreadsheet that already exists.
248///
249/// `edits` is JSON: `[{"sheet": "Sheet1", "ref": "B2", "value": "3.5"}]`, or `"formula": "=B2*C2"` in
250/// place of the value. `sheet` may be left out for the first sheet. A `ref` outside the sheet is
251/// written rather than refused -- the sheet grows -- but a SHEET that is not there is an error naming
252/// what sheets the workbook has.
253#[wasm_bindgen]
254pub fn office_edit_sheet(bytes: &[u8], media: &str, edits: &str) -> Result<Vec<u8>, JsValue> {
255 use oxedyne_fe2o3_file::office::xlsx;
256
257 let asks = cells_of(edits)?;
258 let out = match media {
259 "Ods" => {
260 let sets: Vec<odf::sheet::Set> = asks.into_iter()
261 .map(|(sheet, at, value, formula)| odf::sheet::Set { sheet, at, value, formula })
262 .collect();
263 odf::sheet::edit(bytes, &sets).map_err(to_js_err)?.bytes
264 }
265 "Xlsx" => {
266 let sets: Vec<xlsx::edit::Set> = asks.into_iter()
267 .map(|(sheet, at, value, formula)| xlsx::edit::Set { sheet, at, value, formula })
268 .collect();
269 xlsx::edit::edit(bytes, &sets).map_err(to_js_err)?.bytes
270 }
271 other => return Err(to_js_err(err!(
272 "'{}' is not a spreadsheet, so it has no cells to write.", other;
273 Invalid, Input, Unimplemented))),
274 };
275 Ok(out)
276}
277
278/// The find-and-replace edits a JSON array asks for.
279///
280/// The browser's own parser rather than a second one written here: this JSON carries a user's prose,
281/// and a hand-rolled scan would be a second unescaping implementation to keep exactly in step with the
282/// first.
283fn finds_of(edits: &str) -> Result<Vec<Find>, JsValue> {
284 let list = array_of(edits, "an edit")?;
285 let mut out = Vec::new();
286 for one in list.iter() {
287 let find = str_of(&one, "find").unwrap_or_default();
288 if find.is_empty() {
289 return Err(to_js_err(err!(
290 "An edit carries no 'find', so there is nothing to look for."; Invalid, Input)));
291 }
292 out.push(Find {
293 find,
294 replace: str_of(&one, "replace").unwrap_or_default(),
295 // A zero would ask for an occurrence before the first, which the edit itself refuses in
296 // terms that say so; it is not silently turned into "every".
297 nth: num_of(&one, "nth").map(|n| n as usize),
298 });
299 }
300 if out.is_empty() {
301 return Err(to_js_err(err!("No edits were given, so there is nothing to do."; Invalid, Input)));
302 }
303 Ok(out)
304}
305
306/// The cells a JSON array asks to be written.
307fn cells_of(edits: &str) -> Result<Vec<(Option<String>, Ref, Option<String>, Option<String>)>, JsValue> {
308 let list = array_of(edits, "a cell")?;
309 let mut out = Vec::new();
310 for one in list.iter() {
311 let at = str_of(&one, "ref").unwrap_or_default();
312 let at = Ref::parse(&at).map_err(to_js_err)?;
313 let value = str_of(&one, "value");
314 let formula = str_of(&one, "formula");
315 if value.is_none() && formula.is_none() {
316 return Err(to_js_err(err!(
317 "The write to {} carries neither a value nor a formula, so it says nothing. To empty \
318 a cell, give it a value of \"\".", at.name(); Invalid, Input)));
319 }
320 out.push((str_of(&one, "sheet").filter(|s| !s.trim().is_empty()), at, value, formula));
321 }
322 if out.is_empty() {
323 return Err(to_js_err(err!("No cells were given, so there is nothing to write."; Invalid, Input)));
324 }
325 Ok(out)
326}
327
328/// The JSON as the array of objects it has to be.
329fn array_of(json: &str, what: &str) -> Result<js_sys::Array, JsValue> {
330 let val = js_sys::JSON::parse(json).map_err(|_| to_js_err(err!(
331 "The edits are not JSON. They are a list of objects, one for each {}.", what;
332 Invalid, Input)))?;
333 match val.dyn_into::<js_sys::Array>() {
334 Ok(a) => Ok(a),
335 Err(_) => Err(to_js_err(err!(
336 "The edits are not a LIST. They are a JSON array of objects, one for each {}.", what;
337 Invalid, Input))),
338 }
339}
340
341/// One string property of an object, absent where it is not a string.
342fn str_of(obj: &JsValue, key: &str) -> Option<String> {
343 js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok().and_then(|v| v.as_string())
344}
345
346/// One number property of an object.
347fn num_of(obj: &JsValue, key: &str) -> Option<f64> {
348 js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok().and_then(|v| v.as_f64())
349}
350
351/// What a document written from this Markdown would leave out, in PARTS, or nothing.
352///
353/// Asked before the write rather than after, so a caller can warn before the file exists rather than
354/// explain after it does.
355///
356/// # It answers in parts and not in a sentence, and that is not a style preference
357///
358/// This used to compose the finished English -- *"1 image is not carried into the document: cover.png"*
359/// -- which is exactly the mistake `office_read_doc` was built to avoid, twenty lines above. `viewer.js`
360/// says of itself that every user-visible string in it goes through `opts.t`, "so the file holds no
361/// English of its own that a translation pass cannot reach", and a phrase built in Rust and printed
362/// verbatim is precisely the English a translation pass cannot reach. So `undrawn` carries a kind and a
363/// count and the panel composes the sentence, and this now does the same: `[{ kind, n, names }]`.
364///
365/// One export kept the rule and its sibling broke it, which is what happens when the rule lives in a
366/// comment rather than in the shape of the answer.
367///
368/// `names` is the source each image was written with -- a path out of the user's own Markdown, not
369/// English -- so it passes through as it is, and a caller that wants to say *which* file has it.
370#[wasm_bindgen]
371pub fn office_write_left(md: &str, media: &str) -> Result<JsValue, JsValue> {
372 use oxedyne_fe2o3_file::office::deck::Deck;
373 use oxedyne_fe2o3_file::office::pptx;
374
375 let doc = markdown::parse(md).map_err(to_js_err)?;
376 // A spreadsheet has no `Left` of its own: what it leaves out is everything in the document that is
377 // not a table, which is a different question and one `office_write`'s own note answers.
378 let (images, notes) = match media {
379 "Docx" => (docx::write(&doc).map_err(to_js_err)?.1.images, 0),
380 "Odt" => (odf::text::write(&doc).map_err(to_js_err)?.1.images, 0),
381 "Pptx" => {
382 let left = pptx::write(&Deck::from_doc(&doc)).map_err(to_js_err)?.1;
383 (left.images, left.notes)
384 }
385 "Odp" => {
386 let left = odf::slides::write(&Deck::from_doc(&doc)).map_err(to_js_err)?.1;
387 (left.images, left.notes)
388 }
389 "Xlsx" | "Ods" => (Vec::new(), 0),
390 other => return Err(to_js_err(err!(
391 "'{}' is not a document format this writes, so there is nothing to say about what it \
392 would leave out.", other; Invalid, Input, Unimplemented))),
393 };
394 if images.is_empty() && notes == 0 {
395 return Ok(JsValue::NULL);
396 }
397 let out = js_sys::Array::new();
398 if !images.is_empty() {
399 let one = js_sys::Object::new();
400 set(&one, "kind", &JsValue::from_str("image"))?;
401 // `i32` and not `usize`, for the reason `office_read_doc` gives: a `u64` crosses this boundary
402 // as a `BigInt`, which every arithmetic operation on the JS side then refuses to mix.
403 set(&one, "n", &JsValue::from_f64(images.len() as i32 as f64))?;
404 let names = js_sys::Array::new();
405 for name in &images {
406 names.push(&JsValue::from_str(name));
407 }
408 set(&one, "names", &names.into())?;
409 out.push(&one);
410 }
411 if notes > 0 {
412 let one = js_sys::Object::new();
413 set(&one, "kind", &JsValue::from_str("notes"))?;
414 set(&one, "n", &JsValue::from_f64(notes as i32 as f64))?;
415 set(&one, "names", &js_sys::Array::new().into())?;
416 out.push(&one);
417 }
418 Ok(out.into())
419}
420
421/// The name a kind travels under, which is the key the panel looks its wording up by.
422///
423/// Written out rather than derived from `Debug`, because a name a translation file is keyed on is
424/// part of the interface and must not change because somebody renamed a variant.
425fn name_of(kind: docx::read::Undrawable) -> &'static str {
426 use docx::read::Undrawable as U;
427 match kind {
428 U::Image => "image",
429 U::Chart => "chart",
430 U::Diagram => "diagram",
431 U::TextBox => "textbox",
432 U::Object => "object",
433 U::Equation => "equation",
434 U::Footnote => "footnote",
435 U::Endnote => "endnote",
436 U::Comment => "comment",
437 }
438}
439
440/// Sets one property on an object, turning a failed set into a rejection rather than dropping it.
441fn set(obj: &js_sys::Object, key: &str, value: &JsValue) -> Result<(), JsValue> {
442 match js_sys::Reflect::set(obj, &JsValue::from_str(key), value) {
443 Ok(_) => Ok(()),
444 Err(e) => Err(e),
445 }
446}