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