1use 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
37pub 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#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct HostInfo {
52 pub label: String,
54 pub sheet_names: Vec<String>,
56 pub model_hex: String,
58 pub style_base: u32,
61 pub cm: Option<u32>,
66}
67
68#[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
103fn 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
111fn 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
120fn 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
140fn 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
146fn 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
152fn 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
158fn 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
170fn 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 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
188fn 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
206pub(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
250fn 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 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 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 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 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 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 pub fn sheet_names(&self) -> &[String] {
416 &self.sheet_names
417 }
418
419 pub fn stripped(&self) -> &[String] {
421 &self.stripped
422 }
423}
424
425pub(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
454fn 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(§ion[*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
476pub 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 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 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 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 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#[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 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 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 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 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 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); 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 #[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 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 & 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}