Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/src/office/odf/text.rs

28.6 KiB, 74 runs

created by r1870400018:22912, 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//! `.odt`: an OpenDocument text document, written from and read back into the neutral document tree.
2//!
3//! # A heading says its own level
4//!
5//! `<text:h text:outline-level="2">` is a level two heading, full stop. There is no style to resolve
6//! and no built-in name to recognise, which is the whole of what made the WordprocessingML reader
7//! need `styles.xml` before it could tell a heading from a paragraph. This is the easier direction and
8//! it is worth saying why: OpenDocument put the meaning in the element and Microsoft put it in a
9//! style, and every consequence follows from that one choice.
10//!
11//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
12//! Anthropic Claude
13
14use crate::office::edit::{
15 Find,
16 Piece,
17 Tally,
18 apply,
19};
20use crate::office::odf::{
21 NS_FO,
22 NS_OFFICE,
23 NS_STYLE,
24 NS_TABLE,
25 NS_TEXT,
26 NS_XLINK,
27 pkg,
28};
29use crate::zip::{
30 Method,
31 Zip,
32};
33
34use oxedyne_fe2o3_core::prelude::*;
35use oxedyne_fe2o3_text::doc::{
36 Align,
37 Block,
38 Cell,
39 Doc,
40 Inline,
41 Row,
42};
43use oxedyne_fe2o3_text::xml::{
44 Elem,
45 Node,
46 Xml,
47};
48use oxedyne_fe2o3_text::xml::write::{
49 Out,
50 escape,
51};
52
53use std::collections::BTreeMap;
54
55// Declared in the package's first member, which is what names the file.
56pub const MEDIA: &str = "application/vnd.oasis.opendocument.text";
57
58// The most a single part is inflated to. An `.odt` is one `content.xml`, so this is the whole
59// document rather than a piece of it.
60pub const MAX_PART: u64 = 64 * 1024 * 1024;
61
62/// What a created document could not carry.
63#[derive(Clone, Debug, Default, PartialEq)]
64pub struct Left {
65 pub images: Vec<String>, // those whose bytes could not be reached, by source
66}
67
68impl Left {
69
70 /// Did everything in the tree reach the document?
71 pub fn is_empty(&self) -> bool {
72 self.images.is_empty()
73 }
74}
75
76pub fn write(doc: &Doc) -> Outcome<(Vec<u8>, Left)> {
77 let mut left = Left::default();
78 let mut out = Out::declared();
79 out.open("office:document-content", &[
80 ("xmlns:office", NS_OFFICE),
81 ("xmlns:text", NS_TEXT),
82 ("xmlns:table", NS_TABLE),
83 ("xmlns:style", NS_STYLE),
84 ("xmlns:fo", NS_FO),
85 ("xmlns:xlink", NS_XLINK),
86 ("office:version", pkg::VERSION),
87 ]);
88 // The list style every list refers to. One definition serves both kinds: whether a level is
89 // numbered is a property of the LEVEL here, so a bulleted and a numbered list cannot share one --
90 // hence two.
91 out.open("office:automatic-styles", &[]);
92 for (name, ordered) in [("LB", false), ("LN", true)] {
93 out.open("text:list-style", &[("style:name", name)]);
94 for lvl in 1..=9 {
95 let n = fmt!("{}", lvl);
96 match ordered {
97 true => out.open("text:list-level-style-number", &[
98 ("text:level", &n), ("style:num-suffix", "."), ("style:num-format", "1"),
99 ]),
100 false => out.open("text:list-level-style-bullet", &[
101 ("text:level", &n), ("text:bullet-char", "\u{2022}"),
102 ]),
103 }
104 res!(out.close(match ordered {
105 true => "text:list-level-style-number",
106 false => "text:list-level-style-bullet",
107 }));
108 }
109 res!(out.close("text:list-style"));
110 }
111 res!(out.close("office:automatic-styles"));
112 out.open("office:body", &[]);
113 out.open("office:text", &[]);
114 res!(blocks(&mut out, &doc.blocks, &mut left));
115 res!(out.close("office:text"));
116 res!(out.close("office:body"));
117 res!(out.close("office:document-content"));
118
119 let mut zip = pkg::start(MEDIA);
120 zip.set("content.xml", res!(out.finish()).into_bytes(), Method::Deflate);
121 zip.set("styles.xml", res!(pkg::styles_for(MEDIA)).into_bytes(), Method::Deflate);
122 zip.set("meta.xml", res!(pkg::meta()).into_bytes(), Method::Deflate);
123 res!(pkg::finish(&mut zip, MEDIA));
124 Ok((res!(zip.write()), left))
125}
126
127/// The parameter is `run` and not `blocks`: a parameter of the same name as the function shadows it,
128/// and the recursive calls below then resolve to a slice rather than to this.
129fn blocks(out: &mut Out, run: &[Block], left: &mut Left) -> Outcome<()> {
130 for block in run {
131 match block {
132 Block::Heading { level, content } => {
133 let lvl = fmt!("{}", (*level).clamp(1, 10));
134 out.open("text:h", &[("text:outline-level", &lvl)]);
135 res!(inlines(out, content, left));
136 res!(out.close("text:h"));
137 }
138 Block::Para(content) => {
139 out.open("text:p", &[]);
140 res!(inlines(out, content, left));
141 res!(out.close("text:p"));
142 }
143 Block::Quote(inner) => {
144 // A quotation is a run of paragraphs in the quotation style, because OpenDocument has
145 // no quotation element. That is what every writer does with one.
146 for b in inner {
147 match b {
148 Block::Para(content) => {
149 out.open("text:p", &[("text:style-name", "Quotations")]);
150 res!(inlines(out, content, left));
151 res!(out.close("text:p"));
152 }
153 other => res!(blocks(out, &[other.clone()], left)),
154 }
155 }
156 }
157 Block::Code { text, .. } => {
158 for line in text.lines() {
159 out.open("text:p", &[("text:style-name", "Preformatted_20_Text")]);
160 res!(inlines(out, &[Inline::Text(line.to_string())], left));
161 res!(out.close("text:p"));
162 }
163 }
164 Block::List { ordered, items } => res!(list(out, *ordered, items, left, true)),
165 Block::Rule => {
166 // OpenDocument has no thematic break element either; a paragraph in a style with a
167 // bottom border is what a reader recognises as one.
168 out.open("text:p", &[("text:style-name", "Horizontal_20_Line")]);
169 res!(out.close("text:p"));
170 }
171 Block::Table { head, rows, cols } => res!(table(out, head, rows, cols, left)),
172 Block::Div { content, .. } => res!(blocks(out, content, left)),
173 }
174 }
175 Ok(())
176}
177
178/// Nests by putting a list inside an item.
179fn list(
180 out: &mut Out,
181 ordered: bool,
182 items: &[Vec<Block>],
183 left: &mut Left,
184 top: bool,
185)
186 -> Outcome<()>
187{
188 let style = match ordered {
189 true => "LN",
190 false => "LB",
191 };
192 match top {
193 true => out.open("text:list", &[("text:style-name", style)]),
194 // A nested list carries no style name: it inherits the level from where it sits, which is
195 // how OpenDocument nests. Naming the style again restarts the numbering.
196 false => out.open("text:list", &[]),
197 }
198 for item in items {
199 out.open("text:list-item", &[]);
200 for b in item {
201 match b {
202 Block::List { ordered, items } => res!(list(out, *ordered, items, left, false)),
203 other => res!(blocks(out, &[other.clone()], left)),
204 }
205 }
206 res!(out.close("text:list-item"));
207 }
208 res!(out.close("text:list"));
209 Ok(())
210}
211
212fn table(
213 out: &mut Out,
214 head: &Option<Row>,
215 rows: &[Row],
216 cols: &[Align],
217 left: &mut Left,
218)
219 -> Outcome<()>
220{
221 let n = head.iter().chain(rows).map(|r| r.0.len()).max().unwrap_or(0).max(cols.len());
222 if n == 0 {
223 return Ok(());
224 }
225 out.open("table:table", &[("table:name", "Table1")]);
226 out.empty("table:table-column", &[("table:number-columns-repeated", &fmt!("{}", n))]);
227 if let Some(head) = head {
228 out.open("table:table-header-rows", &[]);
229 res!(row_of(out, head, n, left));
230 res!(out.close("table:table-header-rows"));
231 }
232 for r in rows {
233 res!(row_of(out, r, n, left));
234 }
235 res!(out.close("table:table"));
236 Ok(())
237}
238
239/// Padded to the width of the widest row.
240fn row_of(out: &mut Out, r: &Row, n: usize, left: &mut Left) -> Outcome<()> {
241 let empty = Cell::default();
242 out.open("table:table-row", &[]);
243 for i in 0..n {
244 out.open("table:table-cell", &[("office:value-type", "string")]);
245 out.open("text:p", &[]);
246 res!(inlines(out, &r.0.get(i).unwrap_or(&empty).0, left));
247 res!(out.close("text:p"));
248 res!(out.close("table:table-cell"));
249 }
250 res!(out.close("table:table-row"));
251 Ok(())
252}
253
254fn inlines(out: &mut Out, content: &[Inline], left: &mut Left) -> Outcome<()> {
255 for item in content {
256 match item {
257 Inline::Text(t) => res!(text_run(out, t)),
258 Inline::Code(t) => {
259 out.open("text:span", &[("text:style-name", "Source_20_Text")]);
260 res!(text_run(out, t));
261 res!(out.close("text:span"));
262 }
263 Inline::Emph { strong, content } => {
264 let style = match strong {
265 true => "Strong_20_Emphasis",
266 false => "Emphasis",
267 };
268 out.open("text:span", &[("text:style-name", style)]);
269 res!(inlines(out, content, left));
270 res!(out.close("text:span"));
271 }
272 Inline::Link { to, content } => {
273 // No relationship part, no id: the destination is on the element. This is the whole
274 // of the difference the module note is about.
275 out.open("text:a", &[("xlink:href", to), ("xlink:type", "simple")]);
276 res!(inlines(out, content, left));
277 res!(out.close("text:a"));
278 }
279 Inline::Image { src, alt } => {
280 left.images.push(src.clone());
281 out.open("text:span", &[("text:style-name", "Emphasis")]);
282 res!(text_run(out, alt));
283 res!(out.close("text:span"));
284 }
285 Inline::Span { content, .. } => res!(inlines(out, content, left)),
286 Inline::Break => out.empty("text:line-break", &[]),
287 }
288 }
289 Ok(())
290}
291
292/// Text, with runs of spaces and tabs written as the elements OpenDocument has for them.
293///
294/// A reader collapses whitespace in `text:p` exactly as HTML does, so two consecutive spaces arrive
295/// as one unless the second is a `<text:s/>`. A listing indented with spaces loses its indentation
296/// entirely without this, which is the whole point of writing a listing out.
297fn text_run(out: &mut Out, text: &str) -> Outcome<()> {
298 let mut run = String::new();
299 let mut chars = text.chars().peekable();
300 while let Some(c) = chars.next() {
301 match c {
302 '\t' => {
303 if !run.is_empty() {
304 out.text(&run);
305 run.clear();
306 }
307 out.empty("text:tab", &[]);
308 }
309 ' ' => {
310 let mut n = 1;
311 while chars.peek() == Some(&' ') {
312 chars.next();
313 n += 1;
314 }
315 // A LEADING space is dropped by every reader, so ALL of a leading run goes out as
316 // `<text:s/>`. Keeping the first as a literal is what lost one space off the front of
317 // every indented line of every listing -- four became three, and only an external
318 // reader showed it.
319 let leading = run.is_empty();
320 if !leading {
321 run.push(' ');
322 n -= 1;
323 out.text(&run);
324 run.clear();
325 }
326 match n {
327 0 => {}
328 1 => out.empty("text:s", &[]),
329 _ => out.empty("text:s", &[("text:c", &fmt!("{}", n))]),
330 }
331 }
332 _ => run.push(c),
333 }
334 }
335 if !run.is_empty() {
336 out.text(&run);
337 }
338 Ok(())
339}
340
341#[derive(Clone, Debug, Default)]
342pub struct Reading {
343 pub doc: Doc,
344 pub images: usize, // pictures held, which this does not draw
345 pub macros: bool, // a macro project is present; said, never run
346}
347
348/// What one style says about what wears it.
349///
350/// **A foreign document names almost nothing the way this crate's writer does.** LibreOffice writes
351/// `T1`, `P2`, `L3` -- automatic styles, generated per document -- and puts the meaning in their
352/// PROPERTIES. A reader matching on the name finds `Strong_20_Emphasis` in its own output and
353/// nothing at all in anybody else's, so every bold word in a real document arrives as plain text.
354/// The foreign fixture is what found that, and it is the same mistake the WordprocessingML reader
355/// would have made had it trusted style ids.
356#[derive(Clone, Debug, Default)]
357struct Style {
358 bold: bool, // where the style says so itself
359 italic: bool,
360 mono: bool, // a monospaced face
361 parent: Option<String>, // followed for the properties this one does not set
362}
363
364/// Every style a document defines, by name, and which list styles are numbered.
365#[derive(Clone, Debug, Default)]
366struct Styles {
367 by_name: BTreeMap<String, Style>,
368 lists: BTreeMap<String, bool>, // whether the FIRST level is numbered
369}
370
371impl Styles {
372
373 /// Follows what the style is based on.
374 fn of(&self, name: &str) -> Style {
375 let mut out = Style::default();
376 let mut at = name.to_string();
377 for _ in 0..8 {
378 let s = match self.by_name.get(&at) {
379 Some(s) => s.clone(),
380 None => break,
381 };
382 out.bold |= s.bold;
383 out.italic |= s.italic;
384 out.mono |= s.mono;
385 match s.parent {
386 Some(p) => at = p,
387 None => break,
388 }
389 }
390 // A style this document does not define may still be one of the well-known names, which is
391 // what this crate's own writer emits.
392 let low = name.to_ascii_lowercase();
393 out.bold |= low.contains("strong") || low.contains("bold");
394 out.italic |= low.contains("emphasis") && !low.contains("strong");
395 out.mono |= low.contains("source") || low.contains("teletype");
396 out
397 }
398
399 /// Quotation and listing, by name anywhere up the chain the style is based on.
400 fn para_kind(&self, name: &str) -> (bool, bool) {
401 let mut at = name.to_string();
402 for _ in 0..8 {
403 let low = at.to_ascii_lowercase();
404 if low.starts_with("quotation") || low.contains("block_20_quotation") {
405 return (true, false);
406 }
407 if low.starts_with("preformatted") || low.starts_with("source_20_text")
408 || low.contains("plain_20_text")
409 {
410 return (false, true);
411 }
412 match self.by_name.get(&at).and_then(|s| s.parent.clone()) {
413 Some(p) => at = p,
414 None => break,
415 }
416 }
417 (false, false)
418 }
419}
420
421/// Added to what is already known rather than replacing it.
422fn gather_styles(xml: &Xml, into: &mut Styles) {
423 for s in xml.all("style:style") {
424 let name = match s.attr("style:name") {
425 Some(n) => n.to_string(),
426 None => continue,
427 };
428 let props = s.child("style:text-properties");
429 into.by_name.insert(name, Style {
430 bold: props.and_then(|p| p.attr("fo:font-weight"))
431 .map(|v| v == "bold" || v == "600" || v == "700" || v == "800" || v == "900")
432 .unwrap_or(false),
433 italic: props.and_then(|p| p.attr("fo:font-style"))
434 .map(|v| v == "italic" || v == "oblique")
435 .unwrap_or(false),
436 mono: props.and_then(|p| p.attr("style:font-name").or(p.attr("fo:font-family")))
437 .map(|f| {
438 let f = f.to_ascii_lowercase();
439 f.contains("mono") || f.contains("courier") || f.contains("consol")
440 })
441 .unwrap_or(false),
442 parent: s.attr("style:parent-style-name").map(|v| v.to_string()),
443 });
444 }
445 for l in xml.all("text:list-style") {
446 let name = match l.attr("style:name") {
447 Some(n) => n.to_string(),
448 None => continue,
449 };
450 // Whether the list is numbered is a property of its FIRST LEVEL, and asking whether ANY level
451 // is numbered is wrong: LibreOffice writes ten levels for every list style and makes level
452 // TEN a number even in a pure bullet list. That one line turned every bulleted list in a
453 // foreign document into a numbered one, and only the foreign fixture could have shown it.
454 let ordered = l.elems()
455 .find(|e| e.attr("text:level") == Some("1"))
456 .map(|e| e.name.qname == "text:list-level-style-number")
457 .unwrap_or(false);
458 into.lists.insert(name, ordered);
459 }
460}
461
462pub fn read(bytes: &[u8]) -> Outcome<Reading> {
463 let zip = res!(Zip::read(bytes.to_vec()));
464 let mut out = Reading::default();
465 // An OpenDocument macro lives in `Basic/`, not in a `vbaProject.bin`.
466 out.macros = zip.names().iter().any(|n| n.starts_with("Basic/"));
467 let src = res!(String::from_utf8(res!(zip.content_capped("content.xml", MAX_PART))),
468 Decode, String);
469 let xml = res!(Xml::parse(&src));
470 let body = res!(res!(xml.root()).find(&["office:body", "office:text"]).ok_or_else(|| err!(
471 "This package has no <office:text>, so it is not a text document."; Invalid, Input, Missing)));
472 // Both parts, because a document splits its styles between them: the named ones a person applies
473 // live in `styles.xml` and the generated ones a writer makes live beside the content.
474 let mut styles = Styles::default();
475 if zip.has("styles.xml") {
476 if let Ok(b) = zip.content_capped("styles.xml", MAX_PART) {
477 if let Ok(t) = String::from_utf8(b) {
478 if let Ok(x) = Xml::parse(&t) {
479 gather_styles(&x, &mut styles);
480 }
481 }
482 }
483 }
484 gather_styles(&xml, &mut styles);
485 let mut blocks = Vec::new();
486 read_blocks(&xml, body, &mut blocks, &mut out.images, &styles);
487 // A listing arrives as one paragraph per line, so consecutive ones are one block. Left apart,
488 // every line of a program becomes its own fenced block.
489 out.doc = Doc { blocks: merge_code(blocks) };
490 Ok(out)
491}
492
493fn read_blocks(xml: &Xml, at: &Elem, out: &mut Vec<Block>, images: &mut usize, st: &Styles) {
494 for kid in at.elems() {
495 match kid.name.qname.as_str() {
496 "text:h" => {
497 // The level is ON the element. Nothing has to be resolved.
498 let level = kid.attr("text:outline-level")
499 .and_then(|v| v.parse::<u8>().ok())
500 .unwrap_or(1)
501 .clamp(1, 6);
502 let content = read_inlines(xml, kid, images, st);
503 if !content.is_empty() {
504 out.push(Block::Heading { level, content });
505 }
506 }
507 "text:p" => {
508 let content = read_inlines(xml, kid, images, st);
509 if content.is_empty() {
510 continue;
511 }
512 let style = kid.attr("text:style-name").unwrap_or("");
513 let (quote, code) = st.para_kind(style);
514 if quote {
515 out.push(Block::Quote(vec![Block::Para(content)]));
516 } else if code {
517 out.push(Block::Code {
518 lang: None,
519 text: oxedyne_fe2o3_text::doc::text_of(&content),
520 });
521 } else {
522 out.push(Block::Para(content));
523 }
524 }
525 "text:list" => {
526 if let Some(list) = read_list(xml, kid, images, st) {
527 out.push(list);
528 }
529 }
530 "table:table" => {
531 if let Some(t) = read_table(xml, kid, images, st) {
532 out.push(t);
533 }
534 }
535 // A section, a frame, a change region: contributes nothing itself, and what it holds is
536 // read where it stood. The same rule as everywhere else in this crate.
537 _ => read_blocks(xml, kid, out, images, st),
538 }
539 }
540}
541
542/// Whether it is numbered is a property of its STYLE, which is one level of indirection rather than
543/// the two a `.xlsx` needs. A style this cannot find is read as a bullet, which says less than a
544/// wrong number would.
545fn read_list(xml: &Xml, at: &Elem, images: &mut usize, st: &Styles) -> Option<Block> {
546 // From the STYLE DEFINITION, not from the name: a foreign document calls its list style `L2`
547 // and puts `text:list-level-style-number` inside it, so a reader matching on the name reads
548 // every numbered list in the world as a bulleted one.
549 let style = at.attr("text:style-name").unwrap_or("");
550 let ordered = match st.lists.get(style) {
551 Some(o) => *o,
552 None => style.contains("LN") || style.to_ascii_lowercase().contains("number"),
553 };
554 let mut items = Vec::new();
555 for item in at.children("text:list-item") {
556 let mut blocks = Vec::new();
557 read_blocks(xml, item, &mut blocks, images, st);
558 if !blocks.is_empty() {
559 items.push(blocks);
560 }
561 }
562 match items.is_empty() {
563 true => None,
564 false => Some(Block::List { ordered, items }),
565 }
566}
567
568fn read_table(xml: &Xml, at: &Elem, images: &mut usize, st: &Styles) -> Option<Block> {
569 let mut head = None;
570 let mut rows = Vec::new();
571 if let Some(hr) = at.child("table:table-header-rows") {
572 if let Some(first) = hr.children("table:table-row").first() {
573 head = Some(read_row(xml, first, images, st));
574 }
575 }
576 for tr in at.children("table:table-row") {
577 rows.push(read_row(xml, tr, images, st));
578 }
579 if head.is_none() && rows.is_empty() {
580 return None;
581 }
582 let n = head.iter().chain(rows.iter()).map(|r| r.0.len()).max().unwrap_or(0);
583 Some(Block::Table { head, rows, cols: vec![Align::None; n] })
584}
585
586fn read_row(xml: &Xml, tr: &Elem, images: &mut usize, st: &Styles) -> Row {
587 let mut cells = Vec::new();
588 for tc in tr.elems() {
589 match tc.name.qname.as_str() {
590 "table:table-cell" => {
591 let mut content = Vec::new();
592 for (i, p) in tc.children("text:p").into_iter().enumerate() {
593 if i > 0 {
594 content.push(Inline::Break);
595 }
596 content.extend(read_inlines(xml, p, images, st));
597 }
598 // A cell may say it repeats, which is how a run of empty cells is written.
599 let n = tc.attr("table:number-columns-repeated")
600 .and_then(|v| v.parse::<usize>().ok())
601 .unwrap_or(1)
602 .min(1024);
603 for _ in 0..n {
604 cells.push(Cell(content.clone()));
605 }
606 }
607 "table:covered-table-cell" => cells.push(Cell::default()),
608 _ => {}
609 }
610 }
611 Row(cells)
612}
613
614fn read_inlines(xml: &Xml, at: &Elem, images: &mut usize, st: &Styles) -> Vec<Inline> {
615 let mut out = Vec::new();
616 for node in &at.kids {
617 match node {
618 oxedyne_fe2o3_text::xml::Node::Text(span) => {
619 let t = xml.text(span);
620 if !t.is_empty() {
621 out.push(Inline::Text(t));
622 }
623 }
624 oxedyne_fe2o3_text::xml::Node::Elem(e) => match e.name.qname.as_str() {
625 "text:s" => {
626 let n = e.attr("text:c").and_then(|v| v.parse::<usize>().ok()).unwrap_or(1);
627 out.push(Inline::Text(" ".repeat(n.min(256))));
628 }
629 "text:tab" => out.push(Inline::Text("\t".to_string())),
630 "text:line-break" => out.push(Inline::Break),
631 "draw:frame" | "draw:image" => {
632 *images += 1;
633 // A frame may hold a caption, which is prose and is kept.
634 out.extend(read_inlines(xml, e, images, st));
635 }
636 "text:a" => {
637 let to = e.attr("xlink:href").unwrap_or("").to_string();
638 let content = read_inlines(xml, e, images, st);
639 match content.is_empty() {
640 true => {}
641 false => out.push(Inline::Link { to, content }),
642 }
643 }
644 "text:span" => {
645 let style = st.of(e.attr("text:style-name").unwrap_or(""));
646 let content = read_inlines(xml, e, images, st);
647 if content.is_empty() {
648 continue;
649 }
650 let mut content = match style.mono {
651 true => vec![Inline::Code(oxedyne_fe2o3_text::doc::text_of(&content))],
652 false => content,
653 };
654 if style.italic {
655 content = vec![Inline::Emph { strong: false, content }];
656 }
657 if style.bold {
658 content = vec![Inline::Emph { strong: true, content }];
659 }
660 out.extend(content);
661 }
662 // A bookmark, a note anchor, a soft page break: no words of their own.
663 "text:bookmark" | "text:bookmark-start" | "text:bookmark-end"
664 | "text:soft-page-break" | "text:tracked-changes" => {}
665 _ => out.extend(read_inlines(xml, e, images, st)),
666 },
667 _ => {}
668 }
669 }
670 coalesce(out)
671}
672
673/// A listing is one paragraph per line in this format, so a program arrives as a run of one-line
674/// blocks. Left apart, every line of it renders as its own fenced block.
675fn merge_code(blocks: Vec<Block>) -> Vec<Block> {
676 let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
677 for b in blocks {
678 match (out.last_mut(), b) {
679 (Some(Block::Code { text: a, .. }), Block::Code { text: b, .. }) => {
680 a.push('\n');
681 a.push_str(&b);
682 }
683 (_, b) => out.push(b),
684 }
685 }
686 out
687}
688
689/// Joins adjacent inlines that are marked alike.
690fn 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 out.retain(|i| !matches!(i, Inline::Text(t) if t.is_empty()));
708 out
709}
710
711// ---------------------------------------------------------------------------
712// Editing an `.odt` in place
713// ---------------------------------------------------------------------------
714
715/// What an edit of an `.odt` produced.
716#[derive(Clone, Debug, Default)]
717pub struct Edited {
718 pub bytes: Vec<u8>,
719 pub tallies: Vec<Tally>, // one per edit asked for, in order
720 pub runs: usize, // runs of text rewritten
721}
722
723/// Replaces text in an `.odt`, leaving every other byte of the package as it arrived.
724///
725/// The counterpart of [`crate::office::docx::edit::edit`] and the same design: the text of a paragraph
726/// is the concatenation of its runs, a match is found there and pushed back down onto the runs it
727/// covered, and [`crate::zip`] copies every member nobody touched. `styles.xml`, `meta.xml`, the
728/// manifest, the macros and the pictures are never opened.
729///
730/// Only `content.xml` is searched, so a phrase in a header or a footer -- which live in `styles.xml` --
731/// reports as absent rather than being changed in one of two places.
732pub fn edit(bytes: &[u8], edits: &[Find]) -> Outcome<Edited> {
733 if edits.is_empty() {
734 return Err(err!("An edit of a document was asked for with no edits in it."; Invalid, Input));
735 }
736 let mut zip = res!(Zip::read(bytes.to_vec()));
737 let src = res!(String::from_utf8(res!(zip.content_capped("content.xml", MAX_PART))),
738 Decode, String);
739 let mut xml = res!(Xml::parse(&src));
740 let body = res!(res!(xml.root()).find(&["office:body", "office:text"]).ok_or_else(|| err!(
741 "This package has no <office:text>, so it is not a text document."; Invalid, Input, Missing)));
742 let mut groups = Vec::new();
743 edit_walk(&xml, body, &mut groups);
744 let (changes, tallies) = res!(apply(&groups, edits));
745 let runs = changes.len();
746 for c in &changes {
747 res!(xml.splice(c.piece.span.clone(), content_markup(&c.text)));
748 }
749 zip.set("content.xml", xml.render().into_bytes(), Method::Deflate);
750 Ok(Edited { bytes: res!(zip.write()), tallies, runs })
751}
752
753/// Every paragraph and heading at or below an element, as a group of text pieces each.
754///
755/// A nested paragraph -- a frame's caption inside a paragraph -- gets its own group and its text is not
756/// also in the enclosing one, for the reason `docx::edit` gives: two splices over one span is a
757/// refusal, and rightly.
758fn edit_walk(xml: &Xml, at: &Elem, out: &mut Vec<Vec<Piece>>) {
759 match at.name.qname.as_str() {
760 "text:p" | "text:h" => {
761 let slot = out.len();
762 out.push(Vec::new());
763 let mut group = Vec::new();
764 edit_gather(xml, at, &mut group, out);
765 out[slot] = group;
766 }
767 _ => {
768 for kid in at.elems() {
769 edit_walk(xml, kid, out);
770 }
771 }
772 }
773}
774
775/// One paragraph's own text, run by run.
776///
777/// Character data, and the three elements that ARE text: `<text:s>` is a run of spaces, because
778/// OpenDocument collapses literal ones; `<text:tab>` is a tab; `<text:line-break>` is a newline. A
779/// reader that skipped them would match `Q1 2026` against a document holding `Q1<text:s/>2026` and
780/// report the phrase absent -- and the phrase is there, it is what a person typed.
781fn edit_gather(xml: &Xml, at: &Elem, group: &mut Vec<Piece>, out: &mut Vec<Vec<Piece>>) {
782 for kid in &at.kids {
783 match kid {
784 Node::Text(span) => group.push(Piece::new(span.clone(), xml.text(span))),
785 Node::Elem(e) => match e.name.qname.as_str() {
786 "text:p" | "text:h" => edit_walk(xml, e, out),
787 "text:s" => {
788 let n = e.attr("text:c").and_then(|v| v.parse::<usize>().ok()).unwrap_or(1);
789 group.push(Piece::new(e.span.clone(), " ".repeat(n.min(4096))));
790 }
791 "text:tab" => group.push(Piece::new(e.span.clone(), "\t")),
792 "text:line-break" => group.push(Piece::new(e.span.clone(), "\n")),
793 // A footnote's body, a comment's body and an index mark hold text that is not the
794 // paragraph's own, and replacing in them would edit two places for one phrase.
795 "text:note" | "office:annotation" => {}
796 _ => edit_gather(xml, e, group, out),
797 },
798 _ => {}
799 }
800 }
801}
802
803/// Text as OpenDocument paragraph content: the markup that means exactly these characters.
804///
805/// Three characters cannot be written literally and survive. A run of two or more spaces is collapsed
806/// to one by every reader, so it becomes `<text:s text:c="n"/>`; a space at either end of the run is
807/// collapsed for the same reason and becomes `<text:s/>`; a tab and a newline have elements of their
808/// own. Writing them literally produces a file that opens and says something slightly different from
809/// what the edit asked for, which is the worst of the available outcomes.
810pub fn content_markup(text: &str) -> String {
811 let mut out = String::with_capacity(text.len() + 16);
812 let chars: Vec<char> = text.chars().collect();
813 let mut i = 0;
814 while i < chars.len() {
815 match chars[i] {
816 ' ' => {
817 let mut n = 0;
818 while i + n < chars.len() && chars[i + n] == ' ' {
819 n += 1;
820 }
821 // A single space with a character either side of it is safe as itself, and leaving it
822 // alone keeps the markup readable. Anywhere else it is spelled out.
823 let interior = i > 0 && i + n < chars.len();
824 match n == 1 && interior {
825 true => out.push(' '),
826 false => match n {
827 1 => out.push_str("<text:s/>"),
828 _ => out.push_str(&fmt!("<text:s text:c=\"{}\"/>", n)),
829 },
830 }
831 i += n;
832 }
833 '\t' => {
834 out.push_str("<text:tab/>");
835 i += 1;
836 }
837 '\n' | '\r' => {
838 out.push_str("<text:line-break/>");
839 i += 1;
840 }
841 c => {
842 out.push_str(&escape(&c.to_string()));
843 i += 1;
844 }
845 }
846 }
847 out
848}
849
850/// The text of the body, paragraph by paragraph, as an edit sees it.
851///
852/// The strings a `find` is matched against, which is not the same as the document as prose: this is
853/// where a run split by a style, or a space written as `<text:s/>`, shows up.
854pub fn body_text(bytes: &[u8]) -> Outcome<Vec<String>> {
855 let zip = res!(Zip::read(bytes.to_vec()));
856 let src = res!(String::from_utf8(res!(zip.content_capped("content.xml", MAX_PART))),
857 Decode, String);
858 let xml = res!(Xml::parse(&src));
859 let body = res!(res!(xml.root()).find(&["office:body", "office:text"]).ok_or_else(|| err!(
860 "This package has no <office:text>, so it is not a text document."; Invalid, Input, Missing)));
861 let mut groups = Vec::new();
862 edit_walk(&xml, body, &mut groups);
863 Ok(groups.iter()
864 .map(|g| g.iter().map(|p| p.text.as_str()).collect::<Vec<_>>().concat())
865 .collect())
866}