oxedyne/fe2o3/fe2o3_file/src/office/docx/read.rs
31.3 KiB, 75 runs
created by r1870400018:22703, 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 | //! Reading a `.docx` into the neutral document tree, and saying what did not come with it. |
| 2 | //! |
| 3 | //! # The coverage trap, and the way round it |
| 4 | //! |
| 5 | //! Counting element names gets this wrong. Across a real corpus the thirty commonest names cover 92% |
| 6 | //! of the *content* and **zero of the documents**, because what breaks a reader is not the long tail |
| 7 | //! -- it is the structural singletons, `w:document`, `w:body`, `w:sectPr`, `w:tbl`, which appear once |
| 8 | //! each in every file. A reader that handled the top thirty would fail on all of them. |
| 9 | //! |
| 10 | //! So the names are not enumerated. Elements fall into four sets and the fourth is the important one: |
| 11 | //! |
| 12 | //! 1. **Handled** -- the ones that mean something to the tree: paragraphs, runs, text, tables, links. |
| 13 | //! 2. **Dropped** -- the ones whose content is not prose in reading order: properties, field |
| 14 | //! instructions, deleted text, section breaks. |
| 15 | //! 3. **Counted and not drawn** -- pictures, charts, text boxes, embedded objects. These are real |
| 16 | //! content and this cannot render them, so they are counted BY KIND and the count is handed back. |
| 17 | //! 4. **Descended through, transparently** -- everything else, whatever it is called. A content |
| 18 | //! control, a smart tag, a bidirectional override, a custom XML block and every element invented |
| 19 | //! since this was written contribute nothing themselves and their content is read where they |
| 20 | //! stood. |
| 21 | //! |
| 22 | //! The fourth set is what makes the reader work on documents nobody had when it was written. It is |
| 23 | //! the same move [`oxedyne_fe2o3_text::doc::html::read`] makes with an unknown tag, for the same |
| 24 | //! reason. |
| 25 | //! |
| 26 | //! # It reads the document's own vocabulary rather than assuming Word's |
| 27 | //! |
| 28 | //! A paragraph is a heading because *its style resolves to a built-in heading name*, not because its |
| 29 | //! style id happens to be `Heading1`. A list is numbered rather than bulleted because |
| 30 | //! `word/numbering.xml` says its level's format is not `bullet`. A link's target comes from the |
| 31 | //! relationships part. A document written by LibreOffice, by Pages, or by a generator agrees with |
| 32 | //! Word on none of the ids and on all of the URIs and built-in names. |
| 33 | //! |
| 34 | //! # Tracked changes are read as the document stands |
| 35 | //! |
| 36 | //! An insertion's text is in the document, so it is read. A deletion's text is not, so it is not. |
| 37 | //! That is display, and display only -- nothing here authors `w:ins` or `w:del`, because getting |
| 38 | //! those subtly wrong corrupts a legal review and the person who finds out is a lawyer. |
| 39 | //! |
| 40 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 41 | //! Anthropic Claude |
| 42 | |
| 43 | use crate::office::opc::{ |
| 44 | REL_DOC, |
| 45 | REL_NUMBERING, |
| 46 | REL_STYLES, |
| 47 | }; |
| 48 | use crate::zip::Zip; |
| 49 | |
| 50 | use oxedyne_fe2o3_core::prelude::*; |
| 51 | use oxedyne_fe2o3_text::doc::{ |
| 52 | Align, |
| 53 | Block, |
| 54 | Cell, |
| 55 | Doc, |
| 56 | Inline, |
| 57 | Row, |
| 58 | }; |
| 59 | use oxedyne_fe2o3_text::xml::{ |
| 60 | Elem, |
| 61 | Xml, |
| 62 | }; |
| 63 | |
| 64 | use std::collections::BTreeMap; |
| 65 | |
| 66 | // The most a single part is inflated to. A `word/document.xml` is XML, which compresses about ten to |
| 67 | // one, so a part this size came from an archive member of tens of megabytes. Well past any document |
| 68 | // and well short of trouble. |
| 69 | pub const MAX_PART: u64 = 64 * 1024 * 1024; |
| 70 | |
| 71 | /// The leading bytes of an OLE compound file, which is what an encrypted Office document is. |
| 72 | const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; |
| 73 | |
| 74 | /// Something a reading view cannot draw. |
| 75 | /// |
| 76 | /// Named by kind rather than counted as a lump, because "4 things are not drawn" tells a reader |
| 77 | /// nothing and "3 text boxes and 1 chart" tells them whether to go and open the file properly. |
| 78 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
| 79 | pub enum Undrawable { |
| 80 | Image, |
| 81 | Chart, // data plus a drawing of it, not re-rendered here |
| 82 | Diagram, // SmartArt or another diagram |
| 83 | TextBox, // prose sitting outside the flow of the document |
| 84 | Object, // a spreadsheet, a slide, another program's document |
| 85 | Equation, |
| 86 | Footnote, // whose text is in another part |
| 87 | Endnote, |
| 88 | Comment, // whose text is in another part |
| 89 | } |
| 90 | |
| 91 | impl Undrawable { |
| 92 | |
| 93 | /// What to call some number of these, in English, singular or plural. |
| 94 | /// |
| 95 | /// The number is the information. A band that said "some content is not shown" would be saying |
| 96 | /// only that the reader cannot be trusted. |
| 97 | pub fn say(&self, n: usize) -> String { |
| 98 | let (one, many) = match self { |
| 99 | Self::Image => ("image", "images"), |
| 100 | Self::Chart => ("chart", "charts"), |
| 101 | Self::Diagram => ("diagram", "diagrams"), |
| 102 | Self::TextBox => ("text box", "text boxes"), |
| 103 | Self::Object => ("embedded object", "embedded objects"), |
| 104 | Self::Equation => ("equation", "equations"), |
| 105 | Self::Footnote => ("footnote", "footnotes"), |
| 106 | Self::Endnote => ("endnote", "endnotes"), |
| 107 | Self::Comment => ("comment", "comments"), |
| 108 | }; |
| 109 | match n { |
| 110 | 1 => fmt!("{} {}", n, one), |
| 111 | _ => fmt!("{} {}", n, many), |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// A document read for reading: the prose, and an honest account of what is missing from it. |
| 117 | #[derive(Clone, Debug, Default)] |
| 118 | pub struct Reading { |
| 119 | pub doc: Doc, |
| 120 | pub undrawn: Vec<(Undrawable, usize)>, // by kind and count, in a fixed order |
| 121 | // Said, never run. A `.docm` is a `.docx` with `word/vbaProject.bin` in it, and a reader who is |
| 122 | // not told is a reader who does not know what they have been sent. |
| 123 | pub macros: bool, |
| 124 | // Insertions read as part of the text, which is what tracked changes look like when they are |
| 125 | // displayed as accepted. |
| 126 | pub tracked: usize, |
| 127 | } |
| 128 | |
| 129 | impl Reading { |
| 130 | |
| 131 | /// The whole account of what is not drawn, as one phrase, or nothing where everything is. |
| 132 | /// |
| 133 | /// `"4 things are not drawn: 3 text boxes, 1 chart"`. |
| 134 | pub fn say_undrawn(&self) -> Option<String> { |
| 135 | if self.undrawn.is_empty() { |
| 136 | return None; |
| 137 | } |
| 138 | let total: usize = self.undrawn.iter().map(|(_, n)| n).sum(); |
| 139 | let parts: Vec<String> = self.undrawn.iter().map(|(k, n)| k.say(*n)).collect(); |
| 140 | let what = match total { |
| 141 | 1 => "thing is", |
| 142 | _ => "things are", |
| 143 | }; |
| 144 | Some(fmt!("{} {} not drawn: {}", total, what, parts.join(", "))) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | pub fn read(bytes: &[u8]) -> Outcome<Reading> { |
| 149 | if bytes.len() >= OLE_MAGIC.len() && bytes[..OLE_MAGIC.len()] == OLE_MAGIC { |
| 150 | return Err(err!( |
| 151 | "This document is encrypted. Office writes an encrypted file as an OLE compound file \ |
| 152 | with the real document inside it, and there is no password here to open it with. \ |
| 153 | Nothing is guessed at and nothing is shown."; Invalid, Input, Unimplemented)); |
| 154 | } |
| 155 | let zip = res!(Zip::read(bytes.to_vec())); |
| 156 | let mut out = Reading::default(); |
| 157 | out.macros = zip.names().iter().any(|n| n.ends_with("vbaProject.bin")); |
| 158 | |
| 159 | // The main part is whatever the package's own relationships point at. Every writer in practice |
| 160 | // calls it `word/document.xml`, and a reader that assumed so would be right until it was not. |
| 161 | let root_rels = res!(rels_of(&zip, "")); |
| 162 | let main = res!(root_rels.values() |
| 163 | .find(|(kind, _)| kind == REL_DOC) |
| 164 | .map(|(_, target)| target.clone()) |
| 165 | .ok_or_else(|| err!( |
| 166 | "The package names no main document part, so this is not a Word document. It holds: \ |
| 167 | {}.", zip.names().join(", "); Invalid, Input, Missing))); |
| 168 | let dir = dir_of(&main); |
| 169 | let xml = res!(Xml::parse(&res!(part_text(&zip, &main)))); |
| 170 | |
| 171 | let rels = res!(rels_of(&zip, &main)); |
| 172 | let styles = res!(styles_of(&zip, &dir, &rels)); |
| 173 | let lists = res!(lists_of(&zip, &dir, &rels)); |
| 174 | |
| 175 | let mut r = Read { |
| 176 | xml: &xml, |
| 177 | styles: &styles, |
| 178 | lists: &lists, |
| 179 | rels: &rels, |
| 180 | undrawn: BTreeMap::new(), |
| 181 | tracked: 0, |
| 182 | }; |
| 183 | let body = match res!(xml.root()).child("w:body") { |
| 184 | Some(body) => body, |
| 185 | None => return Err(err!( |
| 186 | "'{}' has no <w:body>, so it is not a word-processing document.", main; |
| 187 | Invalid, Input, Missing)), |
| 188 | }; |
| 189 | out.doc = Doc { blocks: r.body(body) }; |
| 190 | out.undrawn = r.undrawn.into_iter().collect(); |
| 191 | out.tracked = r.tracked; |
| 192 | Ok(out) |
| 193 | } |
| 194 | |
| 195 | /// What one style says about the paragraphs that wear it. |
| 196 | #[derive(Clone, Debug, Default)] |
| 197 | struct Style { |
| 198 | name: String, // the built-in name, lowered: `heading 1`, `quote` |
| 199 | outline: Option<u8>, // the outline level the style itself sets |
| 200 | // A writer that marks code with a CHARACTER STYLE rather than a font -- which is the tidier way |
| 201 | // to write one, and what this crate's own writer does -- says nothing about the face on the run |
| 202 | // itself. A reader that looked only at the run would read every code span as ordinary prose. |
| 203 | mono: bool, |
| 204 | based_on: Option<String>, |
| 205 | } |
| 206 | |
| 207 | /// What one numbering definition says at each of its levels. |
| 208 | type Lists = BTreeMap<String, BTreeMap<usize, bool>>; |
| 209 | |
| 210 | /// The state of one document being read. |
| 211 | struct Read<'a> { |
| 212 | xml: &'a Xml, // the document part |
| 213 | styles: &'a BTreeMap<String, Style>, // style id to what the style says |
| 214 | lists: &'a Lists, // numbering id to level to whether that level is ordered |
| 215 | rels: &'a BTreeMap<String, (String, String)>, // relationship id to its type and target |
| 216 | undrawn: BTreeMap<Undrawable, usize>, |
| 217 | tracked: usize, // how many insertions were read |
| 218 | } |
| 219 | |
| 220 | /// One paragraph that belongs to a list, on its way to being nested. |
| 221 | struct Item { |
| 222 | lvl: usize, // how deep it sits |
| 223 | ordered: bool, // numbered rather than bulleted |
| 224 | blocks: Vec<Block>, |
| 225 | } |
| 226 | |
| 227 | impl<'a> Read<'a> { |
| 228 | |
| 229 | /// Reads the body into blocks, gathering runs of list paragraphs as it goes. |
| 230 | fn body(&mut self, body: &Elem) -> Vec<Block> { |
| 231 | let mut out = Vec::new(); |
| 232 | let mut items: Vec<Item> = Vec::new(); |
| 233 | for kid in body.elems() { |
| 234 | match kid.name.qname.as_str() { |
| 235 | // The section properties say what the page is, which is layout and not prose. |
| 236 | "w:sectPr" => {} |
| 237 | "w:tbl" => { |
| 238 | flush(&mut out, &mut items); |
| 239 | if let Some(table) = self.table(kid) { |
| 240 | out.push(table); |
| 241 | } |
| 242 | } |
| 243 | "w:p" => { |
| 244 | match self.list_of(kid) { |
| 245 | Some((lvl, ordered)) => { |
| 246 | let blocks = self.para(kid, true); |
| 247 | if !blocks.is_empty() { |
| 248 | items.push(Item { lvl, ordered, blocks }); |
| 249 | } |
| 250 | } |
| 251 | None => { |
| 252 | flush(&mut out, &mut items); |
| 253 | out.extend(self.para(kid, false)); |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | // Anything else that stands at body level -- a content control, a custom XML block -- |
| 258 | // holds body-level content, and is read where it stood. |
| 259 | _ => { |
| 260 | flush(&mut out, &mut items); |
| 261 | out.extend(self.body(kid)); |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | flush(&mut out, &mut items); |
| 266 | out |
| 267 | } |
| 268 | |
| 269 | fn list_of(&self, p: &Elem) -> Option<(usize, bool)> { |
| 270 | let num = p.find(&["w:pPr", "w:numPr"])?; |
| 271 | let id = num.child("w:numId")?.attr("w:val")?; |
| 272 | // `w:numId` of zero means "no numbering", and is how Word turns a list item back into a |
| 273 | // paragraph without taking the property off it. |
| 274 | if id == "0" { |
| 275 | return None; |
| 276 | } |
| 277 | let lvl = num.child("w:ilvl") |
| 278 | .and_then(|e| e.attr("w:val")) |
| 279 | .and_then(|v| v.parse::<usize>().ok()) |
| 280 | .unwrap_or(0); |
| 281 | // A numbering the document refers to and does not define is still a list; the safe reading of |
| 282 | // an unknown format is a bullet, which says less than a wrong number would. |
| 283 | let ordered = self.lists.get(id) |
| 284 | .and_then(|levels| levels.get(&lvl)) |
| 285 | .copied() |
| 286 | .unwrap_or(false); |
| 287 | Some((lvl, ordered)) |
| 288 | } |
| 289 | |
| 290 | /// A list item is never a heading and never a rule. |
| 291 | fn para(&mut self, p: &Elem, in_list: bool) -> Vec<Block> { |
| 292 | let style = p.find(&["w:pPr", "w:pStyle"]).and_then(|e| e.attr("w:val")).unwrap_or(""); |
| 293 | let mut content = Vec::new(); |
| 294 | self.inlines(p, &mut content, Fmt::default()); |
| 295 | let content = coalesce(content); |
| 296 | // An empty paragraph is spacing, not prose. A document that used them for spacing -- and many |
| 297 | // do -- would otherwise read as a column of blank lines. |
| 298 | if content.is_empty() { |
| 299 | return Vec::new(); |
| 300 | } |
| 301 | if !in_list { |
| 302 | if let Some(level) = self.heading_of(p, style) { |
| 303 | return vec![Block::Heading { level, content }]; |
| 304 | } |
| 305 | let kind = self.kind_of(style); |
| 306 | match kind { |
| 307 | Kind::Quote => return vec![Block::Quote(vec![Block::Para(content)])], |
| 308 | Kind::Code => { |
| 309 | let text = oxedyne_fe2o3_text::doc::text_of(&content); |
| 310 | return vec![Block::Code { lang: None, text }]; |
| 311 | } |
| 312 | Kind::Plain => {} |
| 313 | } |
| 314 | } |
| 315 | vec![Block::Para(content)] |
| 316 | } |
| 317 | |
| 318 | /// The outline level a paragraph sets itself wins, then the one its style sets, then the style's |
| 319 | /// built-in name. Asking the id would be asking Word's spelling of a question the document |
| 320 | /// answers for itself. |
| 321 | fn heading_of(&self, p: &Elem, style: &str) -> Option<u8> { |
| 322 | if let Some(lvl) = p.find(&["w:pPr", "w:outlineLvl"]) |
| 323 | .and_then(|e| e.attr("w:val")) |
| 324 | .and_then(|v| v.parse::<u8>().ok()) |
| 325 | { |
| 326 | // A body-level paragraph carries outline level 9, which is "not in the outline". |
| 327 | if lvl < 9 { |
| 328 | return Some(lvl + 1); |
| 329 | } |
| 330 | } |
| 331 | let mut at = style; |
| 332 | for _ in 0..8 { |
| 333 | let s = self.styles.get(at)?; |
| 334 | if let Some(lvl) = s.outline { |
| 335 | if lvl < 9 { |
| 336 | return Some(lvl + 1); |
| 337 | } |
| 338 | } |
| 339 | if let Some(rest) = s.name.strip_prefix("heading ") { |
| 340 | if let Ok(n) = rest.trim().parse::<u8>() { |
| 341 | return Some(n.clamp(1, 6)); |
| 342 | } |
| 343 | } |
| 344 | if s.name == "title" { |
| 345 | return Some(1); |
| 346 | } |
| 347 | if s.name == "subtitle" { |
| 348 | return Some(2); |
| 349 | } |
| 350 | at = s.based_on.as_deref()?; |
| 351 | } |
| 352 | None |
| 353 | } |
| 354 | |
| 355 | /// What a style makes of the paragraphs that wear it, beyond being a heading. |
| 356 | fn kind_of(&self, style: &str) -> Kind { |
| 357 | let mut at = style; |
| 358 | for _ in 0..8 { |
| 359 | let s = match self.styles.get(at) { |
| 360 | Some(s) => s, |
| 361 | None => { |
| 362 | // A style the document does not define is still worth reading by its id, since |
| 363 | // an id is what a generator that wrote no styles part will have used. |
| 364 | let low = at.to_ascii_lowercase(); |
| 365 | return Kind::of(&low); |
| 366 | } |
| 367 | }; |
| 368 | let kind = Kind::of(&s.name); |
| 369 | if kind != Kind::Plain { |
| 370 | return kind; |
| 371 | } |
| 372 | let kind = Kind::of(&at.to_ascii_lowercase()); |
| 373 | if kind != Kind::Plain { |
| 374 | return kind; |
| 375 | } |
| 376 | at = match s.based_on.as_deref() { |
| 377 | Some(b) => b, |
| 378 | None => return Kind::Plain, |
| 379 | }; |
| 380 | } |
| 381 | Kind::Plain |
| 382 | } |
| 383 | |
| 384 | /// Reads the inline content of an element, descending through whatever it does not know. |
| 385 | fn inlines(&mut self, at: &Elem, out: &mut Vec<Inline>, fmt: Fmt) { |
| 386 | for kid in at.elems() { |
| 387 | match kid.name.qname.as_str() { |
| 388 | // Properties, not content. |
| 389 | "w:pPr" | "w:rPr" | "w:tblPr" | "w:trPr" | "w:tcPr" | "w:sectPr" => {} |
| 390 | // Marks that carry no words. |
| 391 | "w:bookmarkStart" | "w:bookmarkEnd" | "w:proofErr" | "w:permStart" |
| 392 | | "w:permEnd" | "w:lastRenderedPageBreak" | "w:commentRangeStart" |
| 393 | | "w:commentRangeEnd" | "w:tblGrid" => {} |
| 394 | // Removed text is not what the document says. |
| 395 | "w:del" | "w:moveFrom" | "w:delText" | "w:delInstrText" => {} |
| 396 | // A field's instruction is a program, not prose. Its cached result is read as the |
| 397 | // surrounding runs. |
| 398 | "w:instrText" => {} |
| 399 | "w:r" => self.run(kid, out, fmt), |
| 400 | "w:ins" | "w:moveTo" => { |
| 401 | self.tracked += 1; |
| 402 | self.inlines(kid, out, fmt); |
| 403 | } |
| 404 | "w:hyperlink" => { |
| 405 | let to = self.target_of(kid); |
| 406 | let mut inner = Vec::new(); |
| 407 | self.inlines(kid, &mut inner, fmt); |
| 408 | let inner = coalesce(inner); |
| 409 | match (to, inner.is_empty()) { |
| 410 | (_, true) => {} |
| 411 | (Some(to), _) => out.push(Inline::Link { to, content: inner }), |
| 412 | (None, _) => out.extend(inner), |
| 413 | } |
| 414 | } |
| 415 | // Two renderings of the same content, one for readers that understand a newer |
| 416 | // vocabulary and one for those that do not. Reading both would say everything twice. |
| 417 | "mc:AlternateContent" => { |
| 418 | let pick = kid.child("mc:Choice").or_else(|| kid.child("mc:Fallback")); |
| 419 | if let Some(pick) = pick { |
| 420 | self.inlines(pick, out, fmt); |
| 421 | } |
| 422 | } |
| 423 | // Everything else contributes nothing itself: a content control, a smart tag, a |
| 424 | // bidirectional override, and every element invented since this was written. |
| 425 | _ => self.inlines(kid, out, fmt), |
| 426 | } |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | fn run(&mut self, r: &Elem, out: &mut Vec<Inline>, fmt: Fmt) { |
| 431 | let fmt = match r.child("w:rPr") { |
| 432 | Some(pr) => Fmt { |
| 433 | // `w:b` with `w:val="0"` or `"false"` turns bold OFF, which is how a run inside a |
| 434 | // bold heading says "not this bit". |
| 435 | bold: on(pr.child("w:b"), fmt.bold), |
| 436 | italic: on(pr.child("w:i"), fmt.italic), |
| 437 | code: fmt.code |
| 438 | || mono(pr.child("w:rFonts")) |
| 439 | || self.style_mono(pr.child("w:rStyle").and_then(|e| e.attr("w:val"))), |
| 440 | }, |
| 441 | None => fmt, |
| 442 | }; |
| 443 | for kid in r.elems() { |
| 444 | match kid.name.qname.as_str() { |
| 445 | "w:rPr" | "w:instrText" | "w:delText" | "w:lastRenderedPageBreak" |
| 446 | | "w:fldChar" | "w:footnoteRef" | "w:endnoteRef" | "w:annotationRef" => {} |
| 447 | "w:t" => push_text(out, &self.xml.text_of(kid), fmt), |
| 448 | "w:tab" => push_text(out, "\t", fmt), |
| 449 | "w:br" | "w:cr" => out.push(Inline::Break), |
| 450 | "w:noBreakHyphen" => push_text(out, "\u{2011}", fmt), |
| 451 | // A soft hyphen is a place a word MAY break, and says nothing when it does not. |
| 452 | "w:softHyphen" => {} |
| 453 | "w:sym" => { |
| 454 | // A symbol names a character by its code point in a symbol font. |
| 455 | if let Some(c) = kid.attr("w:char") |
| 456 | .and_then(|h| u32::from_str_radix(h, 16).ok()) |
| 457 | .and_then(char::from_u32) |
| 458 | { |
| 459 | push_text(out, &c.to_string(), fmt); |
| 460 | } |
| 461 | } |
| 462 | "w:drawing" | "w:pict" | "w:object" => self.count_drawing(kid), |
| 463 | "w:footnoteReference" => self.count(Undrawable::Footnote), |
| 464 | "w:endnoteReference" => self.count(Undrawable::Endnote), |
| 465 | "w:commentReference" => self.count(Undrawable::Comment), |
| 466 | _ => self.inlines(kid, out, fmt), |
| 467 | } |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | /// Whether a character style resolves to a monospaced face, following what it is based on. |
| 472 | fn style_mono(&self, id: Option<&str>) -> bool { |
| 473 | let mut at = match id { |
| 474 | Some(id) => id, |
| 475 | None => return false, |
| 476 | }; |
| 477 | for _ in 0..8 { |
| 478 | let s = match self.styles.get(at) { |
| 479 | Some(s) => s, |
| 480 | None => return false, |
| 481 | }; |
| 482 | if s.mono { |
| 483 | return true; |
| 484 | } |
| 485 | at = match s.based_on.as_deref() { |
| 486 | Some(b) => b, |
| 487 | None => return false, |
| 488 | }; |
| 489 | } |
| 490 | false |
| 491 | } |
| 492 | |
| 493 | /// Counts a drawing as what it actually is, which its own subtree says. |
| 494 | /// |
| 495 | /// A `w:drawing` is a picture, a chart, a diagram or a text box, and calling all four "an image" |
| 496 | /// would tell a reader looking for the missing chart that there isn't one. |
| 497 | fn count_drawing(&mut self, at: &Elem) { |
| 498 | let kind = if !at.all("w:txbxContent").is_empty() { |
| 499 | Undrawable::TextBox |
| 500 | } else if holds_local(at, "chart") { |
| 501 | Undrawable::Chart |
| 502 | } else if holds_local(at, "relIds") || holds_local(at, "dgm") { |
| 503 | Undrawable::Diagram |
| 504 | } else if at.name.qname == "w:object" { |
| 505 | Undrawable::Object |
| 506 | } else if holds_local(at, "oMath") || holds_local(at, "oMathPara") { |
| 507 | Undrawable::Equation |
| 508 | } else { |
| 509 | Undrawable::Image |
| 510 | }; |
| 511 | self.count(kind); |
| 512 | } |
| 513 | |
| 514 | fn count(&mut self, what: Undrawable) { |
| 515 | *self.undrawn.entry(what).or_insert(0) += 1; |
| 516 | } |
| 517 | |
| 518 | /// Where a link points: a relationship for a link out, an anchor for one within. |
| 519 | fn target_of(&self, link: &Elem) -> Option<String> { |
| 520 | if let Some(id) = link.attr("r:id") { |
| 521 | if let Some((_, target)) = self.rels.get(id) { |
| 522 | return Some(target.clone()); |
| 523 | } |
| 524 | } |
| 525 | link.attr("w:anchor").map(|a| fmt!("#{}", a)) |
| 526 | } |
| 527 | |
| 528 | fn table(&mut self, tbl: &Elem) -> Option<Block> { |
| 529 | let mut rows = Vec::new(); |
| 530 | let mut head = None; |
| 531 | for (i, tr) in tbl.children("w:tr").into_iter().enumerate() { |
| 532 | let mut cells = Vec::new(); |
| 533 | for tc in tr.children("w:tc") { |
| 534 | let mut content = Vec::new(); |
| 535 | for (k, p) in tc.children("w:p").into_iter().enumerate() { |
| 536 | if k > 0 { |
| 537 | content.push(Inline::Break); |
| 538 | } |
| 539 | self.inlines(p, &mut content, Fmt::default()); |
| 540 | } |
| 541 | // A cell holds a phrase, not a document: see `Cell`'s own note on why. |
| 542 | cells.push(Cell(coalesce(content))); |
| 543 | } |
| 544 | // A header row is one the table SAYS repeats, or a first row whose every cell is bold. |
| 545 | // Both are signals a writer actually emits; guessing from anything less would put a row of |
| 546 | // data where a reader expects column names. |
| 547 | let is_head = i == 0 && (tr.find(&["w:trPr", "w:tblHeader"]).is_some() || all_bold(tr)); |
| 548 | match is_head { |
| 549 | true => head = Some(Row(cells)), |
| 550 | false => rows.push(Row(cells)), |
| 551 | } |
| 552 | } |
| 553 | if head.is_none() && rows.is_empty() { |
| 554 | return None; |
| 555 | } |
| 556 | let n = head.iter().chain(rows.iter()).map(|r| r.0.len()).max().unwrap_or(0); |
| 557 | // The alignment of a column is a property of each cell in OOXML rather than of the column, so |
| 558 | // the first row that says anything decides. A table whose cells disagree has no column |
| 559 | // alignment to report. |
| 560 | let cols = (0..n).map(|i| self.align_of(tbl, i)).collect(); |
| 561 | Some(Block::Table { head, rows, cols }) |
| 562 | } |
| 563 | |
| 564 | /// The alignment of a column, from the first cell in it that names one. |
| 565 | fn align_of(&self, tbl: &Elem, col: usize) -> Align { |
| 566 | for tr in tbl.children("w:tr") { |
| 567 | if let Some(tc) = tr.children("w:tc").get(col) { |
| 568 | if let Some(p) = tc.child("w:p") { |
| 569 | if let Some(jc) = p.find(&["w:pPr", "w:jc"]).and_then(|e| e.attr("w:val")) { |
| 570 | return match jc { |
| 571 | "start" | "left" => Align::Start, |
| 572 | "center" | "centre" => Align::Centre, |
| 573 | "end" | "right" => Align::End, |
| 574 | _ => Align::None, |
| 575 | }; |
| 576 | } |
| 577 | } |
| 578 | } |
| 579 | } |
| 580 | Align::None |
| 581 | } |
| 582 | } |
| 583 | |
| 584 | /// What a style makes of a paragraph, beyond a heading. |
| 585 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 586 | enum Kind { |
| 587 | Plain, |
| 588 | Quote, |
| 589 | Code, // a listing |
| 590 | } |
| 591 | |
| 592 | impl Kind { |
| 593 | |
| 594 | /// What a lowered style name or id says the paragraph is. |
| 595 | fn of(low: &str) -> Self { |
| 596 | match low { |
| 597 | // The spellings are the ones writers actually emit. `block quotation` is |
| 598 | // LibreOffice's; `intense quote` is Word's; `quotations` is OpenDocument's. Reading |
| 599 | // only Word's would call two of the three ordinary paragraphs. |
| 600 | "quote" | "blockquote" | "block text" | "intense quote" | "quotations" |
| 601 | | "blocktext" | "intensequote" | "block quotation" | "blockquotation" => Self::Quote, |
| 602 | "source code" | "sourcecode" | "html preformatted" | "htmlpreformatted" |
| 603 | | "preformatted text" | "preformattedtext" | "code" | "plain text" |
| 604 | | "plaintext" | "macro text" => Self::Code, |
| 605 | _ => Self::Plain, |
| 606 | } |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | /// How a run of text is marked. |
| 611 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 612 | struct Fmt { |
| 613 | bold: bool, |
| 614 | italic: bool, |
| 615 | code: bool, // monospaced, which the tree carries as a code span |
| 616 | } |
| 617 | |
| 618 | /// Whether a toggle property is on. |
| 619 | /// |
| 620 | /// An element that is simply there is on; one carrying `w:val="0"` or `"false"` is off, which is how a |
| 621 | /// run inside a bold heading says "not this bit". Absent, it inherits. |
| 622 | fn on(e: Option<&Elem>, inherited: bool) -> bool { |
| 623 | match e { |
| 624 | None => inherited, |
| 625 | Some(e) => match e.attr("w:val") { |
| 626 | Some("0") | Some("false") | Some("off") => false, |
| 627 | _ => true, |
| 628 | }, |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// Whether a font specification names a monospaced face. |
| 633 | /// |
| 634 | /// A short list of the faces a writer actually reaches for. It is a guess and it is a cheap one: at |
| 635 | /// worst a passage arrives as a code span rather than as prose, which loses nothing a reader needs. |
| 636 | fn mono(e: Option<&Elem>) -> bool { |
| 637 | let name = match e.and_then(|e| e.attr("w:ascii")) { |
| 638 | Some(n) => n.to_ascii_lowercase(), |
| 639 | None => return false, |
| 640 | }; |
| 641 | matches!(name.as_str(), |
| 642 | "consolas" | "courier" | "courier new" | "monaco" | "menlo" | "liberation mono" |
| 643 | | "dejavu sans mono" | "lucida console" | "andale mono" | "cascadia code" |
| 644 | | "cascadia mono" | "sf mono" | "jetbrains mono" | "fira code" | "source code pro") |
| 645 | } |
| 646 | |
| 647 | /// Whether every run in a row is bold, which is one of the two signals that says a header row. |
| 648 | fn all_bold(tr: &Elem) -> bool { |
| 649 | let runs = tr.all("w:r"); |
| 650 | !runs.is_empty() && runs.iter().all(|r| { |
| 651 | // A run holding no text says nothing either way, so it does not veto. |
| 652 | r.all("w:t").is_empty() || r.find(&["w:rPr", "w:b"]).is_some() |
| 653 | }) |
| 654 | } |
| 655 | |
| 656 | /// Whether an element or any of its descendants has that local name, whatever prefix it wears. |
| 657 | /// |
| 658 | /// By local name because the prefix is the document's choice: a chart is `c:chart` in one writer's |
| 659 | /// output and `chart` under a default namespace in another's. |
| 660 | fn holds_local(at: &Elem, local: &str) -> bool { |
| 661 | if at.name.local() == local { |
| 662 | return true; |
| 663 | } |
| 664 | at.elems().any(|k| holds_local(k, local)) |
| 665 | } |
| 666 | |
| 667 | fn push_text(out: &mut Vec<Inline>, text: &str, fmt: Fmt) { |
| 668 | if text.is_empty() { |
| 669 | return; |
| 670 | } |
| 671 | let mut item = match fmt.code { |
| 672 | true => Inline::Code(text.to_string()), |
| 673 | false => Inline::Text(text.to_string()), |
| 674 | }; |
| 675 | // Strong outside emphasis, so `**a *b* **` nests the way a writer would have written it. |
| 676 | if fmt.italic { |
| 677 | item = Inline::Emph { strong: false, content: vec![item] }; |
| 678 | } |
| 679 | if fmt.bold { |
| 680 | item = Inline::Emph { strong: true, content: vec![item] }; |
| 681 | } |
| 682 | out.push(item); |
| 683 | } |
| 684 | |
| 685 | /// Joins adjacent inlines that are marked alike. |
| 686 | /// |
| 687 | /// A run is the unit formatting applies to in OOXML, and a writer splits one wherever it likes -- a |
| 688 | /// spell-check mark, a bookmark, a language change. Left alone, a bold phrase arrives as six bold |
| 689 | /// inlines and renders as `**a****b**`, which is not what it says. |
| 690 | fn coalesce(items: Vec<Inline>) -> Vec<Inline> { |
| 691 | let mut out: Vec<Inline> = Vec::with_capacity(items.len()); |
| 692 | for item in items { |
| 693 | match (out.last_mut(), item) { |
| 694 | (Some(Inline::Text(a)), Inline::Text(b)) => a.push_str(&b), |
| 695 | (Some(Inline::Code(a)), Inline::Code(b)) => a.push_str(&b), |
| 696 | ( |
| 697 | Some(Inline::Emph { strong: sa, content: ca }), |
| 698 | Inline::Emph { strong: sb, content: cb }, |
| 699 | ) if *sa == sb => { |
| 700 | let mut joined = std::mem::take(ca); |
| 701 | joined.extend(cb); |
| 702 | *ca = coalesce(joined); |
| 703 | } |
| 704 | (_, item) => out.push(item), |
| 705 | } |
| 706 | } |
| 707 | // Trailing whitespace on a paragraph is the writer's, not the author's. |
| 708 | if let Some(Inline::Text(last)) = out.last_mut() { |
| 709 | let trimmed = last.trim_end_matches(['\n', '\r']); |
| 710 | if trimmed.len() != last.len() { |
| 711 | *last = trimmed.to_string(); |
| 712 | } |
| 713 | } |
| 714 | out.retain(|i| !matches!(i, Inline::Text(t) if t.is_empty())); |
| 715 | out |
| 716 | } |
| 717 | |
| 718 | /// Turns a gathered run of list paragraphs into the nested lists they make, and adds them. |
| 719 | fn flush(out: &mut Vec<Block>, items: &mut Vec<Item>) { |
| 720 | if items.is_empty() { |
| 721 | return; |
| 722 | } |
| 723 | let taken = std::mem::take(items); |
| 724 | out.extend(nest(&taken, 0)); |
| 725 | } |
| 726 | |
| 727 | /// Nests a run of list items by their levels. |
| 728 | /// |
| 729 | /// A document says only what level each item is at; the nesting is this. An item deeper than the one |
| 730 | /// before it belongs inside it, which is what makes a flat run of paragraphs a tree. |
| 731 | fn nest(items: &[Item], depth: usize) -> Vec<Block> { |
| 732 | let mut out: Vec<Block> = Vec::new(); |
| 733 | let mut i = 0; |
| 734 | while i < items.len() { |
| 735 | let ordered = items[i].ordered; |
| 736 | let mut list: Vec<Vec<Block>> = Vec::new(); |
| 737 | // One list runs while the items stay at this level and keep the same kind. |
| 738 | while i < items.len() && items[i].lvl <= depth { |
| 739 | if items[i].ordered != ordered && items[i].lvl == depth { |
| 740 | break; |
| 741 | } |
| 742 | let mut blocks = items[i].blocks.clone(); |
| 743 | i += 1; |
| 744 | // Everything deeper that follows belongs to the item just taken. |
| 745 | let from = i; |
| 746 | while i < items.len() && items[i].lvl > depth { |
| 747 | i += 1; |
| 748 | } |
| 749 | if i > from { |
| 750 | blocks.extend(nest(&items[from..i], depth + 1)); |
| 751 | } |
| 752 | list.push(blocks); |
| 753 | } |
| 754 | if list.is_empty() { |
| 755 | // An item deeper than its depth with nothing above it: take it at this level rather than |
| 756 | // spin. |
| 757 | list.push(items[i].blocks.clone()); |
| 758 | i += 1; |
| 759 | } |
| 760 | out.push(Block::List { ordered, items: list }); |
| 761 | } |
| 762 | out |
| 763 | } |
| 764 | |
| 765 | fn part_text(zip: &Zip, name: &str) -> Outcome<String> { |
| 766 | let bytes = res!(zip.content_capped(name, MAX_PART)); |
| 767 | Ok(res!(String::from_utf8(bytes), Decode, String)) |
| 768 | } |
| 769 | |
| 770 | /// The directory a part sits in, with its trailing slash, so a relative target resolves against it. |
| 771 | fn dir_of(part: &str) -> String { |
| 772 | match part.rfind('/') { |
| 773 | Some(k) => part[..k + 1].to_string(), |
| 774 | None => String::new(), |
| 775 | } |
| 776 | } |
| 777 | |
| 778 | /// Where a relationship target actually is within the package. |
| 779 | fn resolve(dir: &str, target: &str) -> String { |
| 780 | match target.starts_with('/') { |
| 781 | true => target[1..].to_string(), |
| 782 | false => fmt!("{}{}", dir, target), |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | /// The relationships a part owns, by id. |
| 787 | /// |
| 788 | /// A part's relationships live beside it, in a `_rels` directory, in a file named after it. The |
| 789 | /// package's own are in `_rels/.rels`, which is the same rule with an empty name. |
| 790 | fn rels_of(zip: &Zip, part: &str) -> Outcome<BTreeMap<String, (String, String)>> { |
| 791 | let dir = dir_of(part); |
| 792 | let name = &part[dir.len()..]; |
| 793 | let path = fmt!("{}_rels/{}.rels", dir, name); |
| 794 | let mut out = BTreeMap::new(); |
| 795 | if !zip.has(&path) { |
| 796 | return Ok(out); |
| 797 | } |
| 798 | let src = res!(part_text(zip, &path)); |
| 799 | let xml = res!(Xml::parse(&src)); |
| 800 | for rel in res!(xml.root()).children("Relationship") { |
| 801 | let id = match rel.attr("Id") { |
| 802 | Some(id) => id.to_string(), |
| 803 | None => continue, |
| 804 | }; |
| 805 | let kind = rel.attr("Type").unwrap_or("").to_string(); |
| 806 | let target = rel.attr("Target").unwrap_or("").to_string(); |
| 807 | // An external target is a URL and stays as written; an internal one is a path within the |
| 808 | // package and is resolved against the part that names it. |
| 809 | let target = match rel.attr("TargetMode") { |
| 810 | Some("External") => target, |
| 811 | _ => resolve(&dir, &target), |
| 812 | }; |
| 813 | out.insert(id, (kind, target)); |
| 814 | } |
| 815 | Ok(out) |
| 816 | } |
| 817 | |
| 818 | /// The styles the document defines, by id. |
| 819 | fn styles_of( |
| 820 | zip: &Zip, |
| 821 | dir: &str, |
| 822 | rels: &BTreeMap<String, (String, String)>, |
| 823 | ) |
| 824 | -> Outcome<BTreeMap<String, Style>> |
| 825 | { |
| 826 | let mut out = BTreeMap::new(); |
| 827 | let part = match part_of(rels, REL_STYLES, dir, "styles.xml", zip) { |
| 828 | Some(p) => p, |
| 829 | None => return Ok(out), |
| 830 | }; |
| 831 | let src = res!(part_text(zip, &part)); |
| 832 | let xml = res!(Xml::parse(&src)); |
| 833 | for s in res!(xml.root()).children("w:style") { |
| 834 | let id = match s.attr("w:styleId") { |
| 835 | Some(id) => id.to_string(), |
| 836 | None => continue, |
| 837 | }; |
| 838 | out.insert(id, Style { |
| 839 | name: s.child("w:name") |
| 840 | .and_then(|e| e.attr("w:val")) |
| 841 | .unwrap_or("") |
| 842 | .to_ascii_lowercase(), |
| 843 | outline: s.find(&["w:pPr", "w:outlineLvl"]) |
| 844 | .and_then(|e| e.attr("w:val")) |
| 845 | .and_then(|v| v.parse::<u8>().ok()), |
| 846 | mono: mono(s.find(&["w:rPr", "w:rFonts"])), |
| 847 | based_on: s.child("w:basedOn") |
| 848 | .and_then(|e| e.attr("w:val")) |
| 849 | .map(|v| v.to_string()), |
| 850 | }); |
| 851 | } |
| 852 | Ok(out) |
| 853 | } |
| 854 | |
| 855 | /// What each numbering definition's levels are, by the id a paragraph names. |
| 856 | /// |
| 857 | /// Two hops: a paragraph names a `w:num`, a `w:num` names an abstract definition, and the abstract |
| 858 | /// definition is where the levels live. A reader that skipped the indirection would read the abstract |
| 859 | /// id as the paragraph's, and every list in a document with more than one would be the wrong kind. |
| 860 | fn lists_of( |
| 861 | zip: &Zip, |
| 862 | dir: &str, |
| 863 | rels: &BTreeMap<String, (String, String)>, |
| 864 | ) |
| 865 | -> Outcome<Lists> |
| 866 | { |
| 867 | let mut out = Lists::new(); |
| 868 | let part = match part_of(rels, REL_NUMBERING, dir, "numbering.xml", zip) { |
| 869 | Some(p) => p, |
| 870 | None => return Ok(out), |
| 871 | }; |
| 872 | let src = res!(part_text(zip, &part)); |
| 873 | let xml = res!(Xml::parse(&src)); |
| 874 | let root = res!(xml.root()); |
| 875 | let mut abstracts: BTreeMap<String, BTreeMap<usize, bool>> = BTreeMap::new(); |
| 876 | for a in root.children("w:abstractNum") { |
| 877 | let id = match a.attr("w:abstractNumId") { |
| 878 | Some(id) => id.to_string(), |
| 879 | None => continue, |
| 880 | }; |
| 881 | let mut levels = BTreeMap::new(); |
| 882 | for lvl in a.children("w:lvl") { |
| 883 | let n = lvl.attr("w:ilvl").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0); |
| 884 | let fmt = lvl.child("w:numFmt").and_then(|e| e.attr("w:val")).unwrap_or("bullet"); |
| 885 | levels.insert(n, fmt != "bullet" && fmt != "none"); |
| 886 | } |
| 887 | abstracts.insert(id, levels); |
| 888 | } |
| 889 | for num in root.children("w:num") { |
| 890 | let id = match num.attr("w:numId") { |
| 891 | Some(id) => id.to_string(), |
| 892 | None => continue, |
| 893 | }; |
| 894 | let at = num.child("w:abstractNumId").and_then(|e| e.attr("w:val")).unwrap_or(""); |
| 895 | if let Some(levels) = abstracts.get(at) { |
| 896 | out.insert(id, levels.clone()); |
| 897 | } |
| 898 | } |
| 899 | Ok(out) |
| 900 | } |
| 901 | |
| 902 | /// Where a supporting part is: what the relationships say, or the conventional name where they say |
| 903 | /// nothing. |
| 904 | /// |
| 905 | /// The relationship is the authority. The fallback is for a document whose rels part is missing or |
| 906 | /// which never declared one, which is common in generator output and which a reader can still make |
| 907 | /// sense of rather than refuse. |
| 908 | fn part_of( |
| 909 | rels: &BTreeMap<String, (String, String)>, |
| 910 | kind: &str, |
| 911 | dir: &str, |
| 912 | usual: &str, |
| 913 | zip: &Zip, |
| 914 | ) |
| 915 | -> Option<String> |
| 916 | { |
| 917 | if let Some((_, target)) = rels.values().find(|(k, _)| k == kind) { |
| 918 | if zip.has(target) { |
| 919 | return Some(target.clone()); |
| 920 | } |
| 921 | } |
| 922 | let guess = fmt!("{}{}", dir, usual); |
| 923 | match zip.has(&guess) { |
| 924 | true => Some(guess), |
| 925 | false => None, |
| 926 | } |
| 927 | } |