oxedyne/fe2o3/fe2o3_file/src/office/docx/write.rs
14.7 KiB, 40 runs
created by r1870400018:22583, 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 | //! Creating a `.docx` from the neutral document tree. |
| 2 | //! |
| 3 | //! The direction that is genuinely easy: every byte is written here, so there is nothing to preserve |
| 4 | //! and nothing to guess. What arrives is [`Doc`], which knows what a passage *is* -- a heading, a |
| 5 | //! quotation, a list item -- and what leaves is a document in which those are Word's own heading, |
| 6 | //! quotation and list, named rather than drawn. |
| 7 | //! |
| 8 | //! # What is not carried, and is said rather than dropped quietly |
| 9 | //! |
| 10 | //! [`write`] hands back the parts it could not carry along with the bytes, so a caller can |
| 11 | //! tell the user. Today that is one thing: an image, whose bytes this cannot reach -- the tree holds |
| 12 | //! the *source* an image was written with, a path or a URL, and this crate has no filesystem and no |
| 13 | //! network. The alt text is written in its place and the image is counted. A caller that can resolve |
| 14 | //! the source is where images will be added. |
| 15 | //! |
| 16 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 17 | //! Anthropic Claude |
| 18 | |
| 19 | use oxedyne_fe2o3_text::doc::{ |
| 20 | Align, |
| 21 | Block, |
| 22 | Cell, |
| 23 | Doc, |
| 24 | Inline, |
| 25 | Row, |
| 26 | }; |
| 27 | use crate::office::docx::{ |
| 28 | MARGIN, |
| 29 | NS_W, |
| 30 | NUM_BULLET, |
| 31 | NUM_ORDERED, |
| 32 | PAGE_H, |
| 33 | PAGE_W, |
| 34 | TEXT_W, |
| 35 | parts, |
| 36 | }; |
| 37 | use crate::office::opc::{ |
| 38 | CT_DOCUMENT, |
| 39 | CT_NUMBERING, |
| 40 | CT_STYLES, |
| 41 | NS_R, |
| 42 | REL_DOC, |
| 43 | REL_HYPERLINK, |
| 44 | REL_NUMBERING, |
| 45 | REL_STYLES, |
| 46 | Rels, |
| 47 | Types, |
| 48 | }; |
| 49 | use oxedyne_fe2o3_text::xml::write::Out; |
| 50 | |
| 51 | use oxedyne_fe2o3_core::prelude::*; |
| 52 | use crate::zip::{ |
| 53 | Method, |
| 54 | Zip, |
| 55 | }; |
| 56 | |
| 57 | // The deepest a list may nest before its items stop being indented further. `parts::numbering` |
| 58 | // defines nine levels, so a tenth would name a level the document does not define and lose its |
| 59 | // bullet. Deeper items sit at the ninth rather than vanish. |
| 60 | const MAX_LEVEL: usize = 8; |
| 61 | |
| 62 | /// What a created document could not carry. |
| 63 | /// |
| 64 | /// Counted and named rather than dropped in silence. A reader who is told "one image is not drawn" |
| 65 | /// knows what they are looking at; a reader who is told nothing thinks the document is complete. |
| 66 | #[derive(Clone, Debug, Default, PartialEq)] |
| 67 | pub struct Left { |
| 68 | pub images: Vec<String>, // by the source each was written with |
| 69 | } |
| 70 | |
| 71 | impl Left { |
| 72 | |
| 73 | /// Whether everything in the tree reached the document. |
| 74 | pub fn is_empty(&self) -> bool { |
| 75 | self.images.is_empty() |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// Writes a document tree as the bytes of a `.docx`, and says what did not fit. |
| 80 | pub fn write(doc: &Doc) -> Outcome<(Vec<u8>, Left)> { |
| 81 | let mut b = Build { |
| 82 | out: Out::declared(), |
| 83 | rels: Rels::new(), |
| 84 | left: Left::default(), |
| 85 | }; |
| 86 | // The two parts the document always refers to. They are added before the body is written so a |
| 87 | // hyperlink's id, handed out during the body, never collides with them. |
| 88 | let _ = b.rels.add(REL_STYLES, "styles.xml"); |
| 89 | let _ = b.rels.add(REL_NUMBERING, "numbering.xml"); |
| 90 | |
| 91 | b.out.open("w:document", &[("xmlns:w", NS_W), ("xmlns:r", NS_R)]); |
| 92 | b.out.open("w:body", &[]); |
| 93 | res!(b.blocks(&doc.blocks, Ctx::default())); |
| 94 | res!(b.section()); |
| 95 | res!(b.out.close("w:body")); |
| 96 | res!(b.out.close("w:document")); |
| 97 | let document = res!(b.out.finish()); |
| 98 | |
| 99 | let mut types = Types::new(); |
| 100 | types.over("/word/document.xml", CT_DOCUMENT); |
| 101 | types.over("/word/styles.xml", CT_STYLES); |
| 102 | types.over("/word/numbering.xml", CT_NUMBERING); |
| 103 | |
| 104 | let mut root = Rels::new(); |
| 105 | let _ = root.add(REL_DOC, "word/document.xml"); |
| 106 | |
| 107 | let mut zip = Zip::new(); |
| 108 | // The order is the order Word writes them in. Nothing depends on it, and matching it means a |
| 109 | // document made here and one made there differ in their content rather than in their shape. |
| 110 | zip.set("[Content_Types].xml", res!(types.write()).into_bytes(), Method::Deflate); |
| 111 | zip.set("_rels/.rels", res!(root.write()).into_bytes(), Method::Deflate); |
| 112 | zip.set("word/document.xml", document.into_bytes(), Method::Deflate); |
| 113 | zip.set("word/_rels/document.xml.rels", res!(b.rels.write()).into_bytes(), Method::Deflate); |
| 114 | zip.set("word/styles.xml", res!(parts::styles()).into_bytes(), Method::Deflate); |
| 115 | zip.set("word/numbering.xml", res!(parts::numbering()).into_bytes(), Method::Deflate); |
| 116 | Ok((res!(zip.write()), b.left)) |
| 117 | } |
| 118 | |
| 119 | /// What a block is being written inside, which decides what its paragraphs look like. |
| 120 | /// |
| 121 | /// Carried down rather than looked up, because the same paragraph is a quotation inside a quotation |
| 122 | /// and a list item inside a list, and nothing about the paragraph itself says which. |
| 123 | #[derive(Clone, Copy, Debug, Default)] |
| 124 | struct Ctx { |
| 125 | style: Option<&'static str>, // where the block does not name one of its own |
| 126 | num: Option<(&'static str, usize)>, // the `w:numId`, and how deep it sits |
| 127 | } |
| 128 | |
| 129 | /// How a run of text is marked. |
| 130 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 131 | struct Fmt { |
| 132 | bold: bool, |
| 133 | italic: bool, |
| 134 | code: bool, // a span of code within a line |
| 135 | link: bool, // part of a link, which is what the `Hyperlink` character style is for |
| 136 | } |
| 137 | |
| 138 | /// The state of one document being written. |
| 139 | struct Build { |
| 140 | out: Out, // the document part, under construction |
| 141 | rels: Rels, // what the document part refers to |
| 142 | left: Left, // what could not be carried |
| 143 | } |
| 144 | |
| 145 | impl Build { |
| 146 | |
| 147 | fn blocks(&mut self, blocks: &[Block], ctx: Ctx) -> Outcome<()> { |
| 148 | for block in blocks { |
| 149 | res!(self.block(block, ctx)); |
| 150 | } |
| 151 | Ok(()) |
| 152 | } |
| 153 | |
| 154 | fn block(&mut self, block: &Block, ctx: Ctx) -> Outcome<()> { |
| 155 | match block { |
| 156 | Block::Heading { level, content } => { |
| 157 | // A heading is Word's OWN heading, by name, so the navigation pane and a generated |
| 158 | // contents page both find it. Six levels, and anything past six is a level six. |
| 159 | let style = match level.clamp(&1, &6) { |
| 160 | 1 => "Heading1", |
| 161 | 2 => "Heading2", |
| 162 | 3 => "Heading3", |
| 163 | 4 => "Heading4", |
| 164 | 5 => "Heading5", |
| 165 | _ => "Heading6", |
| 166 | }; |
| 167 | res!(self.para(content, Ctx { style: Some(style), num: None })) |
| 168 | } |
| 169 | Block::Para(content) => res!(self.para(content, ctx)), |
| 170 | Block::Quote(inner) => { |
| 171 | res!(self.blocks(inner, Ctx { style: Some("Quote"), ..ctx })) |
| 172 | } |
| 173 | Block::List { ordered, items } => { |
| 174 | let num = match ordered { |
| 175 | true => NUM_ORDERED, |
| 176 | false => NUM_BULLET, |
| 177 | }; |
| 178 | let lvl = match ctx.num { |
| 179 | Some((_, l)) => (l + 1).min(MAX_LEVEL), |
| 180 | None => 0, |
| 181 | }; |
| 182 | let inner = Ctx { style: Some("ListParagraph"), num: Some((num, lvl)) }; |
| 183 | for item in items { |
| 184 | res!(self.blocks(item, inner)); |
| 185 | } |
| 186 | } |
| 187 | Block::Code { text, .. } => { |
| 188 | // One paragraph to a line: a listing's line structure is what it says, and a single |
| 189 | // paragraph holding newlines would be reflowed by Word into one long line. |
| 190 | for line in text.lines() { |
| 191 | let content = [Inline::Text(line.to_string())]; |
| 192 | res!(self.para(&content, Ctx { style: Some("SourceCode"), num: None })); |
| 193 | } |
| 194 | } |
| 195 | Block::Rule => res!(self.rule()), |
| 196 | Block::Table { head, rows, cols } => res!(self.table(head, rows, cols)), |
| 197 | // A division names a region and says nothing about how it looks, which is the tree's whole |
| 198 | // design. There is nothing to draw, so its content is written where it stands. |
| 199 | Block::Div { content, .. } => res!(self.blocks(content, ctx)), |
| 200 | } |
| 201 | Ok(()) |
| 202 | } |
| 203 | |
| 204 | fn para(&mut self, content: &[Inline], ctx: Ctx) -> Outcome<()> { |
| 205 | self.out.open("w:p", &[]); |
| 206 | if ctx.style.is_some() || ctx.num.is_some() { |
| 207 | self.out.open("w:pPr", &[]); |
| 208 | if let Some(style) = ctx.style { |
| 209 | self.out.empty("w:pStyle", &[("w:val", style)]); |
| 210 | } |
| 211 | if let Some((num, lvl)) = ctx.num { |
| 212 | let ilvl = fmt!("{}", lvl); |
| 213 | self.out.open("w:numPr", &[]); |
| 214 | self.out.empty("w:ilvl", &[("w:val", &ilvl)]); |
| 215 | self.out.empty("w:numId", &[("w:val", num)]); |
| 216 | res!(self.out.close("w:numPr")); |
| 217 | } |
| 218 | res!(self.out.close("w:pPr")); |
| 219 | } |
| 220 | res!(self.inlines(content, Fmt::default())); |
| 221 | res!(self.out.close("w:p")); |
| 222 | Ok(()) |
| 223 | } |
| 224 | |
| 225 | fn inlines(&mut self, content: &[Inline], fmt: Fmt) -> Outcome<()> { |
| 226 | for item in content { |
| 227 | match item { |
| 228 | Inline::Text(text) => res!(self.run(text, fmt)), |
| 229 | Inline::Code(text) => { |
| 230 | res!(self.run(text, Fmt { code: true, ..fmt })) |
| 231 | } |
| 232 | Inline::Emph { strong, content } => { |
| 233 | let fmt = match strong { |
| 234 | true => Fmt { bold: true, ..fmt }, |
| 235 | false => Fmt { italic: true, ..fmt }, |
| 236 | }; |
| 237 | res!(self.inlines(content, fmt)) |
| 238 | } |
| 239 | Inline::Link { to, content } => { |
| 240 | // A link out of the document is a relationship, not an attribute: the URL lives |
| 241 | // in the rels part and the body names it by id. |
| 242 | let id = self.rels.add_external(REL_HYPERLINK, to); |
| 243 | self.out.open("w:hyperlink", &[("r:id", &id)]); |
| 244 | res!(self.inlines(content, Fmt { link: true, ..fmt })); |
| 245 | res!(self.out.close("w:hyperlink")); |
| 246 | } |
| 247 | Inline::Image { src, alt } => { |
| 248 | // The tree holds where an image is, not what it holds, and nothing here can |
| 249 | // fetch it. The alt text stands in its place and the omission is counted, so the |
| 250 | // caller can say so rather than the reader having to notice. |
| 251 | self.left.images.push(src.clone()); |
| 252 | res!(self.run(alt, Fmt { italic: true, ..fmt })); |
| 253 | } |
| 254 | // A span names a region and says nothing about how it looks. Its content stands. |
| 255 | Inline::Span { content, .. } => res!(self.inlines(content, fmt)), |
| 256 | Inline::Break => { |
| 257 | self.out.open("w:r", &[]); |
| 258 | self.out.empty("w:br", &[]); |
| 259 | res!(self.out.close("w:r")); |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | Ok(()) |
| 264 | } |
| 265 | |
| 266 | /// Writes one run of text, split at any newline it holds. |
| 267 | /// |
| 268 | /// A newline inside a `w:t` is whitespace to Word, and would join two lines into one. It is a |
| 269 | /// break, so it is written as one. |
| 270 | fn run(&mut self, text: &str, fmt: Fmt) -> Outcome<()> { |
| 271 | for (i, line) in text.split('\n').enumerate() { |
| 272 | if i > 0 { |
| 273 | self.out.open("w:r", &[]); |
| 274 | self.out.empty("w:br", &[]); |
| 275 | res!(self.out.close("w:r")); |
| 276 | } |
| 277 | if line.is_empty() { |
| 278 | continue; |
| 279 | } |
| 280 | self.out.open("w:r", &[]); |
| 281 | if fmt != Fmt::default() { |
| 282 | self.out.open("w:rPr", &[]); |
| 283 | // Only one character style may apply, and a link that happens to be in code is a link |
| 284 | // first: what the reader does with it is follow it. |
| 285 | match (fmt.link, fmt.code) { |
| 286 | (true, _) => self.out.empty("w:rStyle", &[("w:val", "Hyperlink")]), |
| 287 | (false, true) => self.out.empty("w:rStyle", &[("w:val", "InlineCode")]), |
| 288 | (false, false) => {} |
| 289 | } |
| 290 | if fmt.bold { |
| 291 | self.out.empty("w:b", &[]); |
| 292 | } |
| 293 | if fmt.italic { |
| 294 | self.out.empty("w:i", &[]); |
| 295 | } |
| 296 | res!(self.out.close("w:rPr")); |
| 297 | } |
| 298 | // `xml:space="preserve"` or the leading and trailing spaces of a run go, and a sentence |
| 299 | // built from three runs loses the spaces between them. |
| 300 | self.out.leaf("w:t", &[("xml:space", "preserve")], line); |
| 301 | res!(self.out.close("w:r")); |
| 302 | } |
| 303 | Ok(()) |
| 304 | } |
| 305 | |
| 306 | /// Writes a thematic break, which Word has no element for. |
| 307 | /// |
| 308 | /// It is an empty paragraph with a rule under it. That is what Word itself writes when a person |
| 309 | /// types three hyphens, so it is what a person opening the document will recognise. |
| 310 | fn rule(&mut self) -> Outcome<()> { |
| 311 | self.out.open("w:p", &[]); |
| 312 | self.out.open("w:pPr", &[]); |
| 313 | self.out.open("w:pBdr", &[]); |
| 314 | self.out.empty("w:bottom", &[ |
| 315 | ("w:val", "single"), |
| 316 | ("w:sz", "6"), |
| 317 | ("w:space", "1"), |
| 318 | ("w:color", "auto"), |
| 319 | ]); |
| 320 | res!(self.out.close("w:pBdr")); |
| 321 | res!(self.out.close("w:pPr")); |
| 322 | res!(self.out.close("w:p")); |
| 323 | Ok(()) |
| 324 | } |
| 325 | |
| 326 | fn table(&mut self, head: &Option<Row>, rows: &[Row], cols: &[Align]) -> Outcome<()> { |
| 327 | // The widest row decides the grid, because a row with fewer cells than the header is a table |
| 328 | // somebody wrote by hand and Word still has to lay it out. |
| 329 | let n = head.iter().chain(rows).map(|r| r.0.len()).max().unwrap_or(0).max(cols.len()); |
| 330 | if n == 0 { |
| 331 | return Ok(()); |
| 332 | } |
| 333 | let width = fmt!("{}", TEXT_W as usize / n); |
| 334 | self.out.open("w:tbl", &[]); |
| 335 | self.out.open("w:tblPr", &[]); |
| 336 | self.out.empty("w:tblW", &[("w:w", "0"), ("w:type", "auto")]); |
| 337 | // The borders are written on the table rather than taken from a style, so the table has lines |
| 338 | // in a document that carries no theme and no table styles. |
| 339 | self.out.open("w:tblBorders", &[]); |
| 340 | for side in ["top", "left", "bottom", "right", "insideH", "insideV"] { |
| 341 | self.out.empty(&fmt!("w:{}", side), &[ |
| 342 | ("w:val", "single"), |
| 343 | ("w:sz", "4"), |
| 344 | ("w:space", "0"), |
| 345 | ("w:color", "auto"), |
| 346 | ]); |
| 347 | } |
| 348 | res!(self.out.close("w:tblBorders")); |
| 349 | res!(self.out.close("w:tblPr")); |
| 350 | self.out.open("w:tblGrid", &[]); |
| 351 | for _ in 0..n { |
| 352 | self.out.empty("w:gridCol", &[("w:w", &width)]); |
| 353 | } |
| 354 | res!(self.out.close("w:tblGrid")); |
| 355 | if let Some(head) = head { |
| 356 | res!(self.row(head, cols, n, true)); |
| 357 | } |
| 358 | for row in rows { |
| 359 | res!(self.row(row, cols, n, false)); |
| 360 | } |
| 361 | res!(self.out.close("w:tbl")); |
| 362 | // A table may not be the last thing in a body, and two tables in a row would run together. An |
| 363 | // empty paragraph after one is what Word writes and what keeps both true. |
| 364 | self.out.open("w:p", &[]); |
| 365 | res!(self.out.close("w:p")); |
| 366 | Ok(()) |
| 367 | } |
| 368 | |
| 369 | /// Writes one row of a table, padded to the width of the grid. |
| 370 | fn row(&mut self, row: &Row, cols: &[Align], n: usize, head: bool) -> Outcome<()> { |
| 371 | self.out.open("w:tr", &[]); |
| 372 | if head { |
| 373 | // A header row repeats at the top of each page it runs onto, which is what makes a long |
| 374 | // table readable and what a reader will notice the absence of. |
| 375 | self.out.open("w:trPr", &[]); |
| 376 | self.out.empty("w:tblHeader", &[]); |
| 377 | res!(self.out.close("w:trPr")); |
| 378 | } |
| 379 | let empty = Cell::default(); |
| 380 | for i in 0..n { |
| 381 | let cell = row.0.get(i).unwrap_or(&empty); |
| 382 | let align = cols.get(i).copied().unwrap_or(Align::None); |
| 383 | self.out.open("w:tc", &[]); |
| 384 | self.out.open("w:tcPr", &[]); |
| 385 | self.out.empty("w:tcW", &[("w:w", "0"), ("w:type", "auto")]); |
| 386 | res!(self.out.close("w:tcPr")); |
| 387 | self.out.open("w:p", &[]); |
| 388 | // The tree names the sides `Start` and `End` because it does not know which way its text |
| 389 | // runs, and OOXML has the same two words for the same reason. They line up exactly, so |
| 390 | // nothing here has to decide what "left" means. |
| 391 | let jc = match align { |
| 392 | Align::None => None, |
| 393 | Align::Start => Some("start"), |
| 394 | Align::Centre => Some("center"), |
| 395 | Align::End => Some("end"), |
| 396 | }; |
| 397 | match (jc, head) { |
| 398 | (None, false) => {} |
| 399 | (jc, head) => { |
| 400 | self.out.open("w:pPr", &[]); |
| 401 | if let Some(jc) = jc { |
| 402 | self.out.empty("w:jc", &[("w:val", jc)]); |
| 403 | } |
| 404 | if head { |
| 405 | self.out.open("w:rPr", &[]); |
| 406 | self.out.empty("w:b", &[]); |
| 407 | res!(self.out.close("w:rPr")); |
| 408 | } |
| 409 | res!(self.out.close("w:pPr")); |
| 410 | } |
| 411 | } |
| 412 | res!(self.inlines(&cell.0, Fmt { bold: head, ..Fmt::default() })); |
| 413 | res!(self.out.close("w:p")); |
| 414 | res!(self.out.close("w:tc")); |
| 415 | } |
| 416 | res!(self.out.close("w:tr")); |
| 417 | Ok(()) |
| 418 | } |
| 419 | |
| 420 | /// Writes the section properties, which say what the page is. Last thing in the body, as the |
| 421 | /// schema requires. |
| 422 | fn section(&mut self) -> Outcome<()> { |
| 423 | let (w, h) = (fmt!("{}", PAGE_W), fmt!("{}", PAGE_H)); |
| 424 | let m = fmt!("{}", MARGIN); |
| 425 | self.out.open("w:sectPr", &[]); |
| 426 | self.out.empty("w:pgSz", &[("w:w", &w), ("w:h", &h)]); |
| 427 | self.out.empty("w:pgMar", &[ |
| 428 | ("w:top", &m), |
| 429 | ("w:right", &m), |
| 430 | ("w:bottom", &m), |
| 431 | ("w:left", &m), |
| 432 | ("w:header", "720"), |
| 433 | ("w:footer", "720"), |
| 434 | ("w:gutter", "0"), |
| 435 | ]); |
| 436 | self.out.empty("w:cols", &[("w:space", "720")]); |
| 437 | res!(self.out.close("w:sectPr")); |
| 438 | Ok(()) |
| 439 | } |
| 440 | } |