1use 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
39pub 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#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct HostInfo {
54 pub label: String,
56 pub sheet_names: Vec<String>,
58 pub model_hex: String,
60 pub style_base: u32,
63 pub cm: Option<u32>,
68}
69
70#[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
105fn 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
113fn 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
122fn 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
142fn 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
155fn 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
161fn 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
167fn 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
173fn 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
185fn 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 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
203fn 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
221pub(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
265fn 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 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 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 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 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 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 pub fn sheet_names(&self) -> &[String] {
431 &self.sheet_names
432 }
433
434 pub fn stripped(&self) -> &[String] {
436 &self.stripped
437 }
438}
439
440pub(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
469fn 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(§ion[*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
491pub 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 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 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 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 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#[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 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 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 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 assert_eq!(write(&model, &ours(), 1).unwrap(), out);
886 }
887
888 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 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 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" kg"\"/></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 let own = "<numFmts count=\"2\"><numFmt numFmtId=\"164\" formatCode=\"0.000\"/><numFmt numFmtId=\"170\" formatCode=\"0.00" kg"\"/></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" kg"\"/><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 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 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 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); 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 #[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 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 & 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}