Skip to main content

frazaro_core/sheet/
merge.rs

1//! Adding this build's sheets to a workbook someone else made (PORT.7,
2//! slice 7d): `frazaro build --into model.xlsx`.
3//!
4//! The promise is that the model is untouched: every part of it is copied
5//! as the compressed bytes it already is, byte for byte, and only the parts
6//! that must know about the new sheets are edited, by text, at the one
7//! place each needs: the workbook (its sheet list, the defined names, the
8//! calcPr that asks the host to compute on open), the workbook's
9//! relationships, the content types, and the styles, whose fill and format
10//! lists grow at their ends so that every index the model's own cells use
11//! stays what it was. A model that already carries dynamic-array cell
12//! metadata lends this build its index; one that carries none gets this
13//! writer's part. The model's own cells, strings, theme, calculation
14//! chain and properties are never read beyond the central directory.
15//!
16//! A workbook an earlier build added sheets to is a model like any other:
17//! its Frazaro parts (`xl/worksheets/frazaro_N.xml`, the `Frazaro.Build`
18//! name, this writer's own metadata part) are dropped before this build's
19//! go in, so building into one's own output replaces, never duplicates.
20//! The cell formats an earlier build appended stay, unreferenced.
21//!
22//! New ground, said plainly: the reference opens a workbook in Excel and
23//! adds sheets through the object model. What is here is the package
24//! surgery that gives the same result in the file, with a model that is
25//! someone else's file bounded at every step: a part inflates to at most
26//! [`MAX_PART`] bytes, a checksum that does not match is refused, and no
27//! entry name is ever taken for a path.
28
29use super::inflate::inflate;
30use super::ooxml::{self, Render};
31use super::xml;
32use super::zip::{self, Entry, RawEntry};
33use super::Workbook;
34use crate::messages::{raise, Refusal};
35use crate::sha256::sha256_hex;
36
37/// The most bytes one part of a model may inflate to.
38pub const MAX_PART: usize = 256 << 20;
39
40const WORKBOOK: &str = "xl/workbook.xml";
41const WORKBOOK_RELS: &str = "xl/_rels/workbook.xml.rels";
42const CONTENT_TYPES: &str = "[Content_Types].xml";
43const STYLES: &str = "xl/styles.xml";
44const METADATA: &str = "xl/metadata.xml";
45const OUR_SHEET_PREFIX: &str = "xl/worksheets/frazaro_";
46const REL_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
47
48/// What the walk and the stamp need to know about the workbook this build
49/// adds to.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct HostInfo {
52    /// What a refusal calls the model: the path the door was given.
53    pub label: String,
54    /// The model's own sheets, which a program may name but never write to.
55    pub sheet_names: Vec<String>,
56    /// The SHA-256 of the model's bytes, as the stamp records it.
57    pub model_hex: String,
58    /// How many cell formats the model has: this build's first format
59    /// after the default takes the next index.
60    pub style_base: u32,
61    /// The cell-metadata index a dynamic-array formula points at: the
62    /// model's own dynamic-array block when it has one, 1 when this writer
63    /// adds its part, `None` when the model's metadata part has no block
64    /// to point at (a dynamic-array formula is then refused).
65    pub cm: Option<u32>,
66}
67
68/// A workbook read far enough to add sheets to it.
69#[derive(Debug)]
70pub struct Model {
71    label: String,
72    bytes: Vec<u8>,
73    entries: Vec<Entry>,
74    workbook: String,
75    rels: String,
76    content_types: String,
77    styles: String,
78    metadata: Option<String>,
79    sheet_names: Vec<String>,
80    max_sheet_id: u32,
81    rel_prefix: String,
82    rel_ns_declared: bool,
83    fills_count: u32,
84    cellxfs_count: u32,
85    dynamic_cm: Option<u32>,
86    stripped: Vec<String>,
87}
88
89fn not_a_workbook(label: &str, why: &str) -> Refusal {
90    raise(
91        "build-into-not-a-workbook",
92        &[("path", label), ("why", why)],
93    )
94}
95
96fn unsupported(label: &str, part: &str, why: &str) -> Refusal {
97    raise(
98        "build-into-unsupported",
99        &[("path", label), ("part", part), ("why", why)],
100    )
101}
102
103/// An attribute's value in a start tag's text, unescaped.
104fn attr(tag: &str, name: &str) -> Option<String> {
105    let key = format!(" {name}=\"");
106    let i = tag.find(&key)? + key.len();
107    let j = tag[i..].find('"')?;
108    Some(xml::unescape(&tag[i..i + j]))
109}
110
111/// The value of the attribute whose name ends with `suffix` (`r:id`, or
112/// another prefix's `id`).
113fn attr_ending(tag: &str, suffix: &str) -> Option<String> {
114    let key = format!("{suffix}=\"");
115    let i = tag.find(&key)? + key.len();
116    let j = tag[i..].find('"')?;
117    Some(xml::unescape(&tag[i..i + j]))
118}
119
120/// Every start tag of the element `name`: (start, end exclusive) spans of
121/// `<name ...>` or `<name .../>`.
122fn tags(text: &str, name: &str) -> Vec<(usize, usize)> {
123    let open = format!("<{name}");
124    let mut out = Vec::new();
125    for (i, _) in text.match_indices(&open) {
126        let after = text[i + open.len()..].chars().next();
127        if !matches!(
128            after,
129            Some(' ') | Some('/') | Some('>') | Some('\n') | Some('\r') | Some('\t')
130        ) {
131            continue;
132        }
133        if let Some(j) = text[i..].find('>') {
134            out.push((i, i + j + 1));
135        }
136    }
137    out
138}
139
140/// The text with `insertion` put before the first `marker`.
141fn insert_before(text: &str, marker: &str, insertion: &str) -> Option<String> {
142    let i = text.find(marker)?;
143    Some(format!("{}{}{}", &text[..i], insertion, &text[i..]))
144}
145
146/// The text with `insertion` put after the first `marker`.
147fn insert_after(text: &str, marker: &str, insertion: &str) -> Option<String> {
148    let i = text.find(marker)? + marker.len();
149    Some(format!("{}{}{}", &text[..i], insertion, &text[i..]))
150}
151
152/// The `count` attribute of the first `<element ...>` tag.
153fn count_of(text: &str, element: &str) -> Option<u32> {
154    let (s, e) = *tags(text, element).first()?;
155    attr(&text[s..e], "count")?.parse().ok()
156}
157
158/// The text with the first `<element ...>` tag's `count` raised by `add`.
159fn bump_count(text: &str, element: &str, add: u32) -> Option<String> {
160    let (s, e) = *tags(text, element).first()?;
161    let tag = &text[s..e];
162    let key = " count=\"";
163    let i = tag.find(key)? + key.len();
164    let j = tag[i..].find('"')?;
165    let n: u32 = tag[i..i + j].parse().ok()?;
166    let new_tag = format!("{}{}{}", &tag[..i], n + add, &tag[i + j..]);
167    Some(format!("{}{}{}", &text[..s], new_tag, &text[e..]))
168}
169
170/// The text without the first self-closing tag of `name` that `keep`
171/// rejects.
172fn remove_tag(text: &str, name: &str, reject: impl Fn(&str) -> bool) -> String {
173    for (s, e) in tags(text, name) {
174        if reject(&text[s..e]) {
175            let mut end = e;
176            // Swallow one line break after the tag, so the part stays tidy.
177            if text[end..].starts_with("\r\n") {
178                end += 2;
179            } else if text[end..].starts_with('\n') {
180                end += 1;
181            }
182            return format!("{}{}", &text[..s], &text[end..]);
183        }
184    }
185    text.to_string()
186}
187
188/// The text without the element whose start tag begins with `start` and
189/// which ends with `end`.
190fn remove_element(text: &str, start: &str, end: &str) -> String {
191    let Some(s) = text.find(start) else {
192        return text.to_string();
193    };
194    let Some(rel) = text[s..].find(end) else {
195        return text.to_string();
196    };
197    let mut e = s + rel + end.len();
198    if text[e..].starts_with("\r\n") {
199        e += 2;
200    } else if text[e..].starts_with('\n') {
201        e += 1;
202    }
203    format!("{}{}", &text[..s], &text[e..])
204}
205
206/// A part's text: stored or inflated, its checksum and size checked
207/// against the directory, its byte-order mark dropped; `None` when the
208/// archive has no such part, and the reason in a few words when the part
209/// does not read. The reader (PORT.8, `crate::reflect`) reads every part
210/// through this too, under its own refusal.
211pub(crate) fn part_text_raw(
212    bytes: &[u8],
213    entries: &[Entry],
214    name: &str,
215) -> Result<Option<String>, String> {
216    let Some(e) = entries.iter().find(|e| e.name == name) else {
217        return Ok(None);
218    };
219    let raw = zip::raw_data(bytes, e)
220        .ok_or_else(|| format!("its part {name} reaches past the end of the file"))?;
221    let data: Vec<u8> = match e.method {
222        zip::STORED => raw.to_vec(),
223        8 => inflate(raw, e.size as usize, MAX_PART)
224            .map_err(|why| format!("its part {name} does not inflate ({why})"))?,
225        m => {
226            return Err(format!(
227                "its part {name} uses compression method {m}, which this version does not read"
228            ))
229        }
230    };
231    if data.len() != e.size as usize {
232        return Err(format!(
233            "its part {name} is not the size its directory says"
234        ));
235    }
236    if zip::crc32(&data) != e.crc {
237        return Err(format!(
238            "its part {name} is damaged: its checksum does not match"
239        ));
240    }
241    let data = data
242        .strip_prefix(b"\xEF\xBB\xBF")
243        .map(<[u8]>::to_vec)
244        .unwrap_or(data);
245    String::from_utf8(data)
246        .map(Some)
247        .map_err(|_| format!("its part {name} is not UTF-8"))
248}
249
250/// [`part_text_raw`] under the merge's refusal.
251fn part_text(
252    bytes: &[u8],
253    entries: &[Entry],
254    name: &str,
255    label: &str,
256) -> Result<Option<String>, Refusal> {
257    part_text_raw(bytes, entries, name).map_err(|why| not_a_workbook(label, &why))
258}
259
260impl Model {
261    /// A model read from its bytes; `label` is what a refusal calls it.
262    pub fn read(bytes: Vec<u8>, label: &str) -> Result<Model, Refusal> {
263        let entries = zip::entries(&bytes).ok_or_else(|| {
264            not_a_workbook(
265                label,
266                zip::why_not_an_archive(&bytes)
267                    .unwrap_or("it is not a zip archive this reader knows"),
268            )
269        })?;
270        let Some(mut workbook) = part_text(&bytes, &entries, WORKBOOK, label)? else {
271            return Err(not_a_workbook(
272                label,
273                "it has no xl/workbook.xml part, so it is not a spreadsheet package",
274            ));
275        };
276        let mut rels = part_text(&bytes, &entries, WORKBOOK_RELS, label)?
277            .ok_or_else(|| not_a_workbook(label, "it has no xl/_rels/workbook.xml.rels part"))?;
278        let mut content_types = part_text(&bytes, &entries, CONTENT_TYPES, label)?
279            .ok_or_else(|| not_a_workbook(label, "it has no [Content_Types].xml part"))?;
280        let styles = part_text(&bytes, &entries, STYLES, label)?.ok_or_else(|| {
281            unsupported(
282                label,
283                STYLES,
284                "it is missing, and this version adds no styles part",
285            )
286        })?;
287        let mut metadata = part_text(&bytes, &entries, METADATA, label)?;
288
289        // An earlier build's parts come out first.
290        let mut stripped: Vec<String> = Vec::new();
291        for e in &entries {
292            if !e.name.starts_with(OUR_SHEET_PREFIX) {
293                continue;
294            }
295            let target = e.name.trim_start_matches("xl/").to_string();
296            let mut rid: Option<String> = None;
297            for (s, en) in tags(&rels, "Relationship") {
298                if attr(&rels[s..en], "Target").as_deref() == Some(&target) {
299                    rid = attr(&rels[s..en], "Id");
300                }
301            }
302            rels = remove_tag(&rels, "Relationship", |t| {
303                attr(t, "Target").as_deref() == Some(&target)
304            });
305            if let Some(rid) = rid {
306                workbook = remove_tag(&workbook, "sheet", |t| {
307                    attr_ending(t, ":id").as_deref() == Some(&rid)
308                });
309            }
310            let part_name = format!("/{}", e.name);
311            content_types = remove_tag(&content_types, "Override", |t| {
312                attr(t, "PartName").as_deref() == Some(&part_name)
313            });
314            stripped.push(e.name.clone());
315        }
316        workbook = remove_element(
317            &workbook,
318            &format!("<definedName name=\"{}\"", super::super::build::STAMP_NAME),
319            "</definedName>",
320        );
321        if metadata.as_deref() == Some(ooxml::metadata_xml().as_str()) {
322            rels = remove_tag(&rels, "Relationship", |t| {
323                attr(t, "Target").as_deref() == Some("metadata.xml")
324            });
325            content_types = remove_tag(&content_types, "Override", |t| {
326                attr(t, "PartName").as_deref() == Some("/xl/metadata.xml")
327            });
328            metadata = None;
329            stripped.push(METADATA.to_string());
330        }
331
332        // The model's sheets, and the ids this build's must not reuse.
333        let mut sheet_names = Vec::new();
334        let mut max_sheet_id = 0u32;
335        for (s, e) in tags(&workbook, "sheet") {
336            let tag = &workbook[s..e];
337            if let Some(name) = attr(tag, "name") {
338                sheet_names.push(name);
339            }
340            if let Some(id) = attr(tag, "sheetId").and_then(|v| v.parse::<u32>().ok()) {
341                max_sheet_id = max_sheet_id.max(id);
342            }
343        }
344        if !workbook.contains("</sheets>") {
345            return Err(unsupported(
346                label,
347                WORKBOOK,
348                "it has no sheet list to add to",
349            ));
350        }
351        // The prefix the workbook binds to the relationships namespace.
352        let (rel_prefix, rel_ns_declared) = match tags(&workbook, "workbook").first() {
353            Some(&(s, e)) => {
354                let root = &workbook[s..e];
355                let key = format!("=\"{REL_NS}\"");
356                match root.find(&key) {
357                    Some(i) => {
358                        let before = &root[..i];
359                        let p = before.rfind("xmlns:").map(|k| before[k + 6..].to_string());
360                        (p.unwrap_or_else(|| "r".to_string()), true)
361                    }
362                    None => ("r".to_string(), false),
363                }
364            }
365            None => return Err(unsupported(label, WORKBOOK, "it has no workbook element")),
366        };
367        let fills_count = count_of(&styles, "fills")
368            .ok_or_else(|| unsupported(label, STYLES, "it has no fills list"))?;
369        let cellxfs_count = count_of(&styles, "cellXfs")
370            .ok_or_else(|| unsupported(label, STYLES, "it has no cellXfs list"))?;
371        if !styles.contains("</fills>") || !styles.contains("</cellXfs>") {
372            return Err(unsupported(
373                label,
374                STYLES,
375                "its fills or cellXfs list is self-closing",
376            ));
377        }
378        let dynamic_cm = match &metadata {
379            None => Some(1),
380            Some(m) => dynamic_cm_of(m),
381        };
382        Ok(Model {
383            label: label.to_string(),
384            bytes,
385            entries,
386            workbook,
387            rels,
388            content_types,
389            styles,
390            metadata,
391            sheet_names,
392            max_sheet_id,
393            rel_prefix,
394            rel_ns_declared,
395            fills_count,
396            cellxfs_count,
397            dynamic_cm,
398            stripped,
399        })
400    }
401
402    /// What the walk and the stamp need: the sheets, the fingerprint, the
403    /// style base and the metadata index.
404    pub fn host_info(&self) -> HostInfo {
405        HostInfo {
406            label: self.label.clone(),
407            sheet_names: self.sheet_names.clone(),
408            model_hex: sha256_hex(&self.bytes),
409            style_base: self.cellxfs_count,
410            cm: self.dynamic_cm,
411        }
412    }
413
414    /// The model's sheet names, as it spells them.
415    pub fn sheet_names(&self) -> &[String] {
416        &self.sheet_names
417    }
418
419    /// The entries of an earlier build dropped on reading.
420    pub fn stripped(&self) -> &[String] {
421        &self.stripped
422    }
423}
424
425/// The sheets of a workbook that are not this writer's, read from its
426/// workbook part and relationships: those whose part is not a
427/// `frazaro_N.xml`. For `frazaro rebuild` on a workbook a build was added
428/// to, where the model is not at hand but its sheet names must be known
429/// to walk the program again.
430pub(crate) fn host_sheets_of(workbook: &str, rels: &str) -> Vec<String> {
431    let mut ours: Vec<String> = Vec::new();
432    for (s, e) in tags(rels, "Relationship") {
433        let tag = &rels[s..e];
434        if attr(tag, "Target").is_some_and(|t| t.starts_with("worksheets/frazaro_")) {
435            if let Some(id) = attr(tag, "Id") {
436                ours.push(id);
437            }
438        }
439    }
440    let mut names = Vec::new();
441    for (s, e) in tags(workbook, "sheet") {
442        let tag = &workbook[s..e];
443        let rid = attr_ending(tag, ":id").unwrap_or_default();
444        if ours.contains(&rid) {
445            continue;
446        }
447        if let Some(name) = attr(tag, "name") {
448            names.push(name);
449        }
450    }
451    names
452}
453
454/// The 1-based index of the cell-metadata block that marks a dynamic-array
455/// formula in a model's metadata part, when the part has one: the block
456/// whose record names the XLDAPR type.
457fn dynamic_cm_of(metadata: &str) -> Option<u32> {
458    let mut xldapr: Option<usize> = None;
459    for (i, (s, e)) in tags(metadata, "metadataType").iter().enumerate() {
460        if attr(&metadata[*s..*e], "name").as_deref() == Some("XLDAPR") {
461            xldapr = Some(i + 1);
462        }
463    }
464    let t = xldapr?;
465    let start = metadata.find("<cellMetadata")?;
466    let end = start + metadata[start..].find("</cellMetadata>")?;
467    let section = &metadata[start..end];
468    for (i, (s, e)) in tags(section, "rc").iter().enumerate() {
469        if attr(&section[*s..*e], "t").and_then(|v| v.parse::<usize>().ok()) == Some(t) {
470            return u32::try_from(i + 1).ok();
471        }
472    }
473    None
474}
475
476/// The model with this build's sheets added: every part of the model as it
477/// was, the four it must edit edited, this build's parts appended. `cm` is
478/// the metadata index the build's dynamic-array formulas were given.
479pub fn write(model: &Model, ours: &Workbook, cm: u32) -> Result<Vec<u8>, Refusal> {
480    let render = Render {
481        style_base: model.cellxfs_count,
482        cm,
483    };
484    let add_metadata = ours.has_dynamic_formulas() && model.metadata.is_none();
485    let too_large = || raise("build-workbook-too-large", &[]);
486
487    // The workbook: its sheet list, its names, its calcPr.
488    let mut workbook = model.workbook.clone();
489    let mut sheet_elements = String::new();
490    for (i, sheet) in ours.sheets.iter().enumerate() {
491        sheet_elements.push_str(&format!(
492            "<sheet name=\"{}\" sheetId=\"{}\" {}:id=\"rIdFrazaro{}\"/>",
493            xml::attr(&sheet.name),
494            model.max_sheet_id + i as u32 + 1,
495            model.rel_prefix,
496            i + 1
497        ));
498    }
499    workbook = insert_before(&workbook, "</sheets>", &sheet_elements).ok_or_else(too_large)?;
500    if !model.rel_ns_declared {
501        workbook = insert_after(
502            &workbook,
503            "<workbook",
504            &format!(" xmlns:{}=\"{REL_NS}\"", model.rel_prefix),
505        )
506        .ok_or_else(too_large)?;
507    }
508    if !ours.defined_names.is_empty() {
509        let mut names = String::new();
510        for (name, formula) in &ours.defined_names {
511            names.push_str(&format!(
512                "<definedName name=\"{}\">{}</definedName>",
513                xml::attr(name),
514                xml::text(formula)
515            ));
516        }
517        workbook = if workbook.contains("</definedNames>") {
518            insert_before(&workbook, "</definedNames>", &names)
519        } else if workbook.contains("<definedNames/>") {
520            Some(workbook.replacen(
521                "<definedNames/>",
522                &format!("<definedNames>{names}</definedNames>"),
523                1,
524            ))
525        } else {
526            let wrapped = format!("<definedNames>{names}</definedNames>");
527            let anchor = if workbook.contains("</externalReferences>") {
528                "</externalReferences>"
529            } else {
530                "</sheets>"
531            };
532            insert_after(&workbook, anchor, &wrapped)
533        }
534        .ok_or_else(too_large)?;
535    }
536    workbook = match tags(&workbook, "calcPr").first() {
537        Some(&(s, e)) => {
538            let tag = &workbook[s..e];
539            let new_tag = if let Some(i) = tag.find(" fullCalcOnLoad=\"") {
540                let v = i + " fullCalcOnLoad=\"".len();
541                let j = tag[v..].find('"').unwrap_or(0);
542                format!("{}1{}", &tag[..v], &tag[v + j..])
543            } else {
544                insert_after(tag, "<calcPr", " fullCalcOnLoad=\"1\"").ok_or_else(too_large)?
545            };
546            format!("{}{}{}", &workbook[..s], new_tag, &workbook[e..])
547        }
548        None => {
549            let anchor = if workbook.contains("</definedNames>") {
550                "</definedNames>"
551            } else if workbook.contains("</externalReferences>") {
552                "</externalReferences>"
553            } else {
554                "</sheets>"
555            };
556            insert_after(&workbook, anchor, "<calcPr fullCalcOnLoad=\"1\"/>")
557                .ok_or_else(too_large)?
558        }
559    };
560
561    // The relationships and the content types.
562    let mut rel_elements = String::new();
563    let mut overrides = String::new();
564    for i in 1..=ours.sheets.len() {
565        rel_elements.push_str(&format!(
566            "<Relationship Id=\"rIdFrazaro{i}\" Type=\"{}\" Target=\"worksheets/frazaro_{i}.xml\"/>",
567            ooxml::REL_WORKSHEET
568        ));
569        overrides.push_str(&format!(
570            "<Override PartName=\"/{}\" ContentType=\"{}\"/>",
571            ooxml::sheet_part(i),
572            ooxml::CT_WORKSHEET
573        ));
574    }
575    if add_metadata {
576        rel_elements.push_str(&format!(
577            "<Relationship Id=\"{}\" Type=\"{}\" Target=\"metadata.xml\"/>",
578            ooxml::METADATA_RID,
579            ooxml::REL_METADATA
580        ));
581        overrides.push_str(&format!(
582            "<Override PartName=\"/xl/metadata.xml\" ContentType=\"{}\"/>",
583            ooxml::CT_METADATA
584        ));
585    }
586    let rels =
587        insert_before(&model.rels, "</Relationships>", &rel_elements).ok_or_else(too_large)?;
588    let content_types =
589        insert_before(&model.content_types, "</Types>", &overrides).ok_or_else(too_large)?;
590
591    // The styles: this build's fills and formats at the ends of the lists.
592    let fills = ours.styles.fills();
593    let own_xfs = ours.styles.xfs().len().saturating_sub(1) as u32;
594    let mut styles = model.styles.clone();
595    if !fills.is_empty() {
596        styles = insert_before(&styles, "</fills>", &ooxml::fills_fragment(&ours.styles))
597            .ok_or_else(too_large)?;
598        styles = bump_count(&styles, "fills", fills.len() as u32).ok_or_else(too_large)?;
599    }
600    if own_xfs > 0 {
601        styles = insert_before(
602            &styles,
603            "</cellXfs>",
604            &ooxml::cellxfs_fragment(&ours.styles, model.fills_count),
605        )
606        .ok_or_else(too_large)?;
607        styles = bump_count(&styles, "cellXfs", own_xfs).ok_or_else(too_large)?;
608    }
609
610    // The entries: the model's in its order, ours after.
611    let edited: Vec<(&str, &str)> = vec![
612        (WORKBOOK, &workbook),
613        (WORKBOOK_RELS, &rels),
614        (CONTENT_TYPES, &content_types),
615        (STYLES, &styles),
616    ];
617    let mut raws: Vec<RawEntry> = Vec::with_capacity(model.entries.len() + ours.sheets.len() + 1);
618    for e in &model.entries {
619        if model.stripped.contains(&e.name) {
620            continue;
621        }
622        if let Some((_, text)) = edited.iter().find(|(n, _)| *n == e.name) {
623            raws.push(RawEntry {
624                name: &e.name,
625                method: zip::STORED,
626                crc: zip::crc32(text.as_bytes()),
627                size: u32::try_from(text.len()).map_err(|_| too_large())?,
628                bytes: text.as_bytes(),
629            });
630            continue;
631        }
632        raws.push(RawEntry {
633            name: &e.name,
634            method: e.method,
635            crc: e.crc,
636            size: e.size,
637            bytes: zip::raw_data(&model.bytes, e).ok_or_else(too_large)?,
638        });
639    }
640    let metadata_text = ooxml::metadata_xml();
641    if add_metadata {
642        raws.push(RawEntry {
643            name: METADATA,
644            method: zip::STORED,
645            crc: zip::crc32(metadata_text.as_bytes()),
646            size: u32::try_from(metadata_text.len()).map_err(|_| too_large())?,
647            bytes: metadata_text.as_bytes(),
648        });
649    }
650    let sheet_names: Vec<String> = (1..=ours.sheets.len()).map(ooxml::sheet_part).collect();
651    let sheet_texts: Vec<String> = ours
652        .sheets
653        .iter()
654        .map(|s| ooxml::sheet_xml(s, false, &render))
655        .collect();
656    for (name, text) in sheet_names.iter().zip(sheet_texts.iter()) {
657        raws.push(RawEntry {
658            name,
659            method: zip::STORED,
660            crc: zip::crc32(text.as_bytes()),
661            size: u32::try_from(text.len()).map_err(|_| too_large())?,
662            bytes: text.as_bytes(),
663        });
664    }
665    zip::write_entries(&raws).ok_or_else(too_large)
666}
667
668/// A model of the fixture's shape, stored rather than deflated, for tests
669/// that need a workbook with a sheet list, names, styles and a string
670/// table without reading one from disk.
671#[cfg(test)]
672pub fn test_model(with_metadata: Option<&str>) -> Vec<u8> {
673    let head = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n";
674    let mut parts: Vec<(String, Vec<u8>)> = vec![
675        (CONTENT_TYPES.to_string(), format!("{head}<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/><Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/><Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>{}</Types>", if with_metadata.is_some() { "<Override PartName=\"/xl/metadata.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml\"/>" } else { "" }).into_bytes()),
676        ("_rels/.rels".to_string(), format!("{head}<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/></Relationships>").into_bytes()),
677        (WORKBOOK.to_string(), format!("{head}<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"{REL_NS}\"><bookViews><workbookView activeTab=\"0\"/></bookViews><sheets><sheet name=\"Model\" sheetId=\"1\" r:id=\"rId1\"/><sheet name=\"Notes\" sheetId=\"4\" r:id=\"rId2\"/></sheets><definedNames><definedName name=\"Rate\">0.2</definedName></definedNames><calcPr calcId=\"191029\"/></workbook>").into_bytes()),
678        (WORKBOOK_RELS.to_string(), format!("{head}<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"{}\" Target=\"worksheets/sheet1.xml\"/><Relationship Id=\"rId2\" Type=\"{}\" Target=\"worksheets/sheet2.xml\"/><Relationship Id=\"rId3\" Type=\"{}\" Target=\"styles.xml\"/>{}</Relationships>", ooxml::REL_WORKSHEET, ooxml::REL_WORKSHEET, ooxml::REL_STYLES, if with_metadata.is_some() { format!("<Relationship Id=\"rId4\" Type=\"{}\" Target=\"metadata.xml\"/>", ooxml::REL_METADATA) } else { String::new() }).into_bytes()),
679        ("xl/worksheets/sheet1.xml".to_string(), format!("{head}<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetViews><sheetView tabSelected=\"1\" workbookViewId=\"0\"/></sheetViews><sheetData><row r=\"3\"><c r=\"B3\"><f>B1-B2</f><v>400</v></c></row></sheetData></worksheet>").into_bytes()),
680        ("xl/worksheets/sheet2.xml".to_string(), format!("{head}<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData/></worksheet>").into_bytes()),
681        (STYLES.to_string(), format!("{head}<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><fonts count=\"1\"><font><sz val=\"11\"/></font></fonts><fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills><borders count=\"1\"><border/></borders><cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs><cellXfs count=\"3\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/></cellXfs><cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles></styleSheet>").into_bytes()),
682    ];
683    if let Some(m) = with_metadata {
684        parts.push((METADATA.to_string(), format!("{head}{m}").into_bytes()));
685    }
686    zip::write_stored(&parts).unwrap()
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::sheet::{parse_a1_range, Cell, Content, Sheet};
693
694    const EXCEL_METADATA: &str = "<metadata xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:xlrd=\"http://schemas.microsoft.com/office/spreadsheetml/2017/richdata\" xmlns:xda=\"http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray\"><metadataTypes count=\"2\"><metadataType name=\"XLRICHVALUE\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" coerce=\"1\"/><metadataType name=\"XLDAPR\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" coerce=\"1\" cellMeta=\"1\"/></metadataTypes><futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata><cellMetadata count=\"2\"><bk><rc t=\"1\" v=\"0\"/></bk><bk><rc t=\"2\" v=\"0\"/></bk></cellMetadata></metadata>";
695
696    fn ours() -> Workbook {
697        let mut wb = Workbook::new();
698        let st = wb.styles.id(crate::sheet::Style {
699            fill: Some(crate::sheet::Rgb(1, 2, 3)),
700            num_fmt: crate::sheet::NumFmt::Text,
701            wrap: false,
702        });
703        let mut a = Sheet::new("Frazaro");
704        a.set(
705            1,
706            2,
707            Cell {
708                content: Content::Text("Put 1 into cell A1.".to_string()),
709                style: st,
710            },
711        );
712        let mut b = Sheet::new("Output");
713        b.set_formula_dynamic(parse_a1_range("a1").unwrap(), "_xlfn.IFS(1,2)", 0);
714        wb.sheets.push(a);
715        wb.sheets.push(b);
716        wb.defined_names
717            .push(("Frazaro.Build".to_string(), "\"stamp\"".to_string()));
718        wb
719    }
720
721    #[test]
722    fn a_model_is_read_far_enough_and_no_further() {
723        let m = Model::read(test_model(None), "model.xlsx").unwrap();
724        assert_eq!(m.sheet_names(), ["Model", "Notes"]);
725        assert_eq!(m.max_sheet_id, 4);
726        assert_eq!(m.rel_prefix, "r");
727        assert!(m.rel_ns_declared);
728        assert_eq!((m.fills_count, m.cellxfs_count), (2, 3));
729        let info = m.host_info();
730        assert_eq!(info.style_base, 3);
731        assert_eq!(info.cm, Some(1));
732        assert_eq!(info.model_hex.len(), 64);
733        assert!(m.stripped().is_empty());
734        // Excel's own metadata lends its dynamic-array block's index.
735        let m2 = Model::read(test_model(Some(EXCEL_METADATA)), "m").unwrap();
736        assert_eq!(m2.host_info().cm, Some(2));
737        let no_block = EXCEL_METADATA.replace("XLDAPR", "XLOTHER");
738        let m3 = Model::read(test_model(Some(&no_block)), "m").unwrap();
739        assert_eq!(m3.host_info().cm, None);
740    }
741
742    #[test]
743    fn what_is_not_a_model_is_refused_with_why() {
744        let r = Model::read(b"nope".to_vec(), "x.xlsx").unwrap_err();
745        assert_eq!(r.id, "build-into-not-a-workbook");
746        assert!(r.text.contains("x.xlsx is not a workbook"), "{}", r.text);
747        assert!(r.text.contains("not a zip archive"), "{}", r.text);
748        let no_wb = zip::write_stored(&[("a.txt".to_string(), b"hi".to_vec())]).unwrap();
749        let r = Model::read(no_wb, "x.zip").unwrap_err();
750        assert!(r.text.contains("no xl/workbook.xml"), "{}", r.text);
751        let ole = vec![0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0, 0];
752        let r = Model::read(ole, "old.xls").unwrap_err();
753        assert!(r.text.contains("older .xls or an encrypted"), "{}", r.text);
754    }
755
756    #[test]
757    fn the_merge_edits_four_parts_and_copies_the_rest() {
758        let model_bytes = test_model(None);
759        let model = Model::read(model_bytes.clone(), "model.xlsx").unwrap();
760        let out = write(&model, &ours(), 1).unwrap();
761        let before = zip::entries(&model_bytes).unwrap();
762        let after = zip::entries(&out).unwrap();
763        let names: Vec<&str> = after.iter().map(|e| e.name.as_str()).collect();
764        assert_eq!(
765            names,
766            [
767                "[Content_Types].xml",
768                "_rels/.rels",
769                "xl/workbook.xml",
770                "xl/_rels/workbook.xml.rels",
771                "xl/worksheets/sheet1.xml",
772                "xl/worksheets/sheet2.xml",
773                "xl/styles.xml",
774                "xl/metadata.xml",
775                "xl/worksheets/frazaro_1.xml",
776                "xl/worksheets/frazaro_2.xml",
777            ]
778        );
779        // The untouched parts are byte for byte the model's.
780        for name in [
781            "_rels/.rels",
782            "xl/worksheets/sheet1.xml",
783            "xl/worksheets/sheet2.xml",
784        ] {
785            let a = before.iter().find(|e| e.name == name).unwrap();
786            let b = after.iter().find(|e| e.name == name).unwrap();
787            assert_eq!(
788                zip::raw_data(&model_bytes, a),
789                zip::raw_data(&out, b),
790                "{name}"
791            );
792            assert_eq!(a.crc, b.crc);
793        }
794        let text = |name: &str| {
795            let e = after.iter().find(|e| e.name == name).unwrap();
796            String::from_utf8(zip::stored_data(&out, e).unwrap().to_vec()).unwrap()
797        };
798        let wb = text("xl/workbook.xml");
799        assert!(wb.contains("<sheet name=\"Notes\" sheetId=\"4\" r:id=\"rId2\"/><sheet name=\"Frazaro\" sheetId=\"5\" r:id=\"rIdFrazaro1\"/><sheet name=\"Output\" sheetId=\"6\" r:id=\"rIdFrazaro2\"/></sheets>"), "{wb}");
800        assert!(wb.contains("<definedName name=\"Rate\">0.2</definedName><definedName name=\"Frazaro.Build\">\"stamp\"</definedName></definedNames>"), "{wb}");
801        assert!(
802            wb.contains("<calcPr fullCalcOnLoad=\"1\" calcId=\"191029\"/>"),
803            "{wb}"
804        );
805        let rels = text("xl/_rels/workbook.xml.rels");
806        assert!(
807            rels.contains("Target=\"worksheets/frazaro_1.xml\"/><Relationship Id=\"rIdFrazaro2\"")
808        );
809        assert!(rels.contains("Id=\"rIdFrazaroMetadata\""));
810        let ct = text("[Content_Types].xml");
811        assert!(ct.contains("<Override PartName=\"/xl/worksheets/frazaro_1.xml\""));
812        assert!(ct.contains("<Override PartName=\"/xl/metadata.xml\""));
813        let st = text("xl/styles.xml");
814        assert!(st.contains("<fills count=\"3\">"), "{st}");
815        assert!(st.contains("<fgColor rgb=\"FF010203\"/>"));
816        assert!(st.contains("<cellXfs count=\"4\">"), "{st}");
817        assert!(st.contains("<xf numFmtId=\"49\" fontId=\"0\" fillId=\"2\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\" applyFill=\"1\"/></cellXfs>"), "{st}");
818        // Our sheets name the model's formats and metadata: style 1 is the
819        // model's 3, cm is 1, and no tab is selected twice.
820        let s1 = text("xl/worksheets/frazaro_1.xml");
821        assert!(s1.contains("<c r=\"B1\" s=\"3\" t=\"inlineStr\">"), "{s1}");
822        assert!(!s1.contains("tabSelected"));
823        let s2 = text("xl/worksheets/frazaro_2.xml");
824        assert!(
825            s2.contains(
826                "<c r=\"A1\" s=\"0\" cm=\"1\"><f t=\"array\" ref=\"A1\">_xlfn.IFS(1,2)</f></c>"
827            ),
828            "{s2}"
829        );
830        // Determinism, and the same bytes from the same inputs.
831        assert_eq!(write(&model, &ours(), 1).unwrap(), out);
832    }
833
834    #[test]
835    fn building_into_ones_own_output_replaces_the_earlier_build() {
836        let model = Model::read(test_model(None), "model.xlsx").unwrap();
837        let first = write(&model, &ours(), 1).unwrap();
838        let again = Model::read(first.clone(), "first.xlsx").unwrap();
839        assert_eq!(again.sheet_names(), ["Model", "Notes"]);
840        assert_eq!(
841            again.stripped(),
842            [
843                "xl/worksheets/frazaro_1.xml",
844                "xl/worksheets/frazaro_2.xml",
845                "xl/metadata.xml"
846            ]
847        );
848        assert_eq!(again.host_info().cm, Some(1));
849        assert!(!again.workbook.contains("Frazaro.Build"));
850        assert!(!again.rels.contains("rIdFrazaro"));
851        assert!(!again.content_types.contains("frazaro_"));
852        // The earlier build's formats stay, unreferenced: the base moves up.
853        assert_eq!(again.host_info().style_base, 4);
854        let second = write(&again, &ours(), 1).unwrap();
855        let names: Vec<String> = zip::entries(&second)
856            .unwrap()
857            .into_iter()
858            .map(|e| e.name)
859            .collect();
860        assert_eq!(
861            names
862                .iter()
863                .filter(|n| n.starts_with(OUR_SHEET_PREFIX))
864                .count(),
865            2
866        );
867        assert_eq!(names.iter().filter(|n| *n == METADATA).count(), 1);
868    }
869
870    #[test]
871    fn a_model_with_excel_metadata_lends_its_index_and_gets_no_part() {
872        let model = Model::read(test_model(Some(EXCEL_METADATA)), "m.xlsx").unwrap();
873        let cm = model.host_info().cm.unwrap();
874        let out = write(&model, &ours(), cm).unwrap();
875        let after = zip::entries(&out).unwrap();
876        assert_eq!(after.iter().filter(|e| e.name == METADATA).count(), 1);
877        let e = after.iter().find(|e| e.name == METADATA).unwrap();
878        assert_eq!(e.method, zip::STORED); // the model's own, copied as it was
879        let s2 = after
880            .iter()
881            .find(|e| e.name == "xl/worksheets/frazaro_2.xml")
882            .unwrap();
883        let text = String::from_utf8(zip::stored_data(&out, s2).unwrap().to_vec()).unwrap();
884        assert!(text.contains("cm=\"2\""), "{text}");
885    }
886
887    /// The fixture model, deflated by .NET (tools/build_model_fixture.ps1):
888    /// the inflate decoder on real streams, and the reader on a package of
889    /// Excel's shape with a string table and a calculation chain.
890    #[test]
891    fn the_fixture_model_reads() {
892        const MODEL: &[u8] = include_bytes!("../../../scripts/build/model.xlsx");
893        let m = Model::read(MODEL.to_vec(), "scripts/build/model.xlsx").unwrap();
894        assert_eq!(m.sheet_names(), ["Model", "Notes"]);
895        assert_eq!(m.max_sheet_id, 2);
896        assert_eq!((m.fills_count, m.cellxfs_count), (2, 2));
897        assert_eq!(m.host_info().cm, Some(1));
898        assert_eq!(m.entries.len(), 11);
899        assert!(
900            m.entries.iter().all(|e| e.method == 8),
901            "the fixture is deflated throughout"
902        );
903        assert!(m
904            .workbook
905            .contains("<definedName name=\"Rate\">0.2</definedName>"));
906        assert!(m.workbook.contains("<calcPr calcId=\"191029\"/>"));
907        let out = write(&m, &ours(), 1).unwrap();
908        let after = zip::entries(&out).unwrap();
909        assert_eq!(after.len(), 11 + 3);
910        // The model's deflated parts travel as they were.
911        let before = zip::entries(MODEL).unwrap();
912        for name in [
913            "xl/sharedStrings.xml",
914            "xl/calcChain.xml",
915            "xl/worksheets/sheet1.xml",
916            "docProps/core.xml",
917        ] {
918            let a = before.iter().find(|e| e.name == name).unwrap();
919            let b = after.iter().find(|e| e.name == name).unwrap();
920            assert_eq!(b.method, 8);
921            assert_eq!(zip::raw_data(MODEL, a), zip::raw_data(&out, b), "{name}");
922        }
923        assert_eq!(
924            host_sheets_of(
925                "<sheets><sheet name=\"Model\" sheetId=\"1\" r:id=\"rId1\"/><sheet name=\"Frazaro\" sheetId=\"3\" r:id=\"rIdFrazaro1\"/></sheets>",
926                "<Relationships><Relationship Id=\"rId1\" Type=\"t\" Target=\"worksheets/sheet1.xml\"/><Relationship Id=\"rIdFrazaro1\" Type=\"t\" Target=\"worksheets/frazaro_1.xml\"/></Relationships>"
927            ),
928            ["Model"]
929        );
930    }
931
932    #[test]
933    fn the_text_helpers() {
934        assert_eq!(
935            count_of("<a><fills count=\"7\"><x/></fills></a>", "fills"),
936            Some(7)
937        );
938        assert_eq!(
939            bump_count("<fills count=\"7\">", "fills", 2).unwrap(),
940            "<fills count=\"9\">"
941        );
942        assert_eq!(
943            remove_tag(
944                "<s><sheet name=\"A\" r:id=\"r1\"/>\n<sheet name=\"B\" r:id=\"r2\"/></s>",
945                "sheet",
946                |t| attr_ending(t, ":id").as_deref() == Some("r1")
947            ),
948            "<s><sheet name=\"B\" r:id=\"r2\"/></s>"
949        );
950        assert_eq!(
951            remove_element("<n><definedName name=\"X\">1</definedName><definedName name=\"Y\">2</definedName></n>", "<definedName name=\"X\"", "</definedName>"),
952            "<n><definedName name=\"Y\">2</definedName></n>"
953        );
954        assert_eq!(
955            attr("<sheet name=\"a &amp; b\" sheetId=\"3\"/>", "name").as_deref(),
956            Some("a & b")
957        );
958        assert_eq!(
959            tags("<sheets><sheet a=\"1\"/><sheetView/></sheets>", "sheet").len(),
960            1
961        );
962    }
963}