1use std::io::{Cursor, Write};
24
25use zip::write::SimpleFileOptions;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Template {
30 Text,
31 Word,
32 Excel,
33 PowerPoint,
34 XMind,
35 Psd,
36 Ai,
37 Sketch,
38}
39
40impl Template {
41 pub const ALL: [Template; 8] = [Template::Text, Template::Word, Template::Excel, Template::PowerPoint, Template::XMind, Template::Psd, Template::Ai, Template::Sketch];
43
44 pub fn ext(self) -> &'static str {
46 match self {
47 Template::Text => "txt",
48 Template::Word => "docx",
49 Template::Excel => "xlsx",
50 Template::PowerPoint => "pptx",
51 Template::XMind => "xmind",
52 Template::Psd => "psd",
53 Template::Ai => "ai",
54 Template::Sketch => "sketch",
55 }
56 }
57
58 pub fn file_name(self) -> String {
60 format!("Untitled.{}", self.ext())
61 }
62
63 pub fn bytes(self) -> Vec<u8> {
65 match self {
66 Template::Text => Vec::new(),
67 Template::Word => zip_of(&docx_parts()),
68 Template::Excel => zip_of(&xlsx_parts()),
69 Template::PowerPoint => zip_of(&pptx_parts()),
70 Template::XMind => zip_of(&xmind_parts()),
71 Template::Psd => psd_bytes(),
72 Template::Ai => ai_bytes(),
73 Template::Sketch => zip_of(&sketch_parts()),
74 }
75 }
76}
77
78fn zip_of<N: AsRef<str>, B: AsRef<[u8]>>(parts: &[(N, B)]) -> Vec<u8> {
79 let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
80 let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
81 for (name, body) in parts {
82 zip.start_file(name.as_ref(), opts).expect("zip entry");
84 zip.write_all(body.as_ref()).expect("zip data");
85 }
86 zip.finish().expect("zip end").into_inner()
87}
88
89const XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
90const NS_REL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
91const NS_CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
92const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
93const REL_DOC: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
94
95fn root_rels(target: &str) -> String {
96 format!(r#"{XML}<Relationships xmlns="{NS_REL}"><Relationship Id="rId1" Type="{REL_DOC}" Target="{target}"/></Relationships>"#)
97}
98
99fn docx_parts() -> Vec<(&'static str, String)> {
100 vec![
101 (
102 "[Content_Types].xml",
103 format!(
104 r#"{XML}<Types xmlns="{NS_CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#
105 ),
106 ),
107 ("_rels/.rels", root_rels("word/document.xml")),
108 (
109 "word/document.xml",
110 format!(
111 r#"{XML}<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p/><w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr></w:body></w:document>"#
112 ),
113 ),
114 ]
115}
116
117fn xlsx_parts() -> Vec<(&'static str, String)> {
118 vec![
119 (
120 "[Content_Types].xml",
121 format!(
122 r#"{XML}<Types xmlns="{NS_CT}"><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"/></Types>"#
123 ),
124 ),
125 ("_rels/.rels", root_rels("xl/workbook.xml")),
126 (
127 "xl/workbook.xml",
128 format!(r#"{XML}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_R}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>"#),
129 ),
130 (
131 "xl/_rels/workbook.xml.rels",
132 format!(r#"{XML}<Relationships xmlns="{NS_REL}"><Relationship Id="rId1" Type="{NS_R}/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#),
133 ),
134 ("xl/worksheets/sheet1.xml", format!(r#"{XML}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#)),
135 ]
136}
137
138const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
139const NS_P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
140
141fn pptx_theme() -> String {
142 let fill = r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#;
143 let line = r#"<a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>"#;
144 let colour = |tag: &str, v: &str| format!(r#"<a:{tag}><a:srgbClr val="{v}"/></a:{tag}>"#);
145 let colours: String = [
146 ("dk2", "44546A"),
147 ("lt2", "E7E6E6"),
148 ("accent1", "4472C4"),
149 ("accent2", "ED7D31"),
150 ("accent3", "A5A5A5"),
151 ("accent4", "FFC000"),
152 ("accent5", "5B9BD5"),
153 ("accent6", "70AD47"),
154 ("hlink", "0563C1"),
155 ("folHlink", "954F72"),
156 ]
157 .iter()
158 .map(|(t, v)| colour(t, v))
159 .collect();
160 format!(
161 r#"{XML}<a:theme xmlns:a="{NS_A}" name="Falcon"><a:themeElements><a:clrScheme name="Falcon"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>{colours}</a:clrScheme><a:fontScheme name="Falcon"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Falcon"><a:fillStyleLst>{fill}{fill}{fill}</a:fillStyleLst><a:lnStyleLst>{line}{line}{line}</a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst>{fill}{fill}{fill}</a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#
162 )
163}
164
165fn pptx_parts() -> Vec<(&'static str, String)> {
166 let ct = "application/vnd.openxmlformats-officedocument.presentationml";
167 let tree = r#"<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>"#;
168 let rel = |id: &str, kind: &str, target: &str| format!(r#"<Relationship Id="{id}" Type="{NS_R}/{kind}" Target="{target}"/>"#);
169 vec![
170 (
171 "[Content_Types].xml",
172 format!(
173 r#"{XML}<Types xmlns="{NS_CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="{ct}.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="{ct}.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="{ct}.slideLayout+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="{ct}.slide+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/></Types>"#
174 ),
175 ),
176 ("_rels/.rels", root_rels("ppt/presentation.xml")),
177 (
178 "ppt/presentation.xml",
179 format!(
180 r#"{XML}<p:presentation xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>"#
181 ),
182 ),
183 (
184 "ppt/_rels/presentation.xml.rels",
185 format!(r#"{XML}<Relationships xmlns="{NS_REL}">{}{}{}</Relationships>"#, rel("rId1", "slideMaster", "slideMasters/slideMaster1.xml"), rel("rId2", "slide", "slides/slide1.xml"), rel("rId3", "theme", "theme/theme1.xml")),
186 ),
187 (
188 "ppt/slideMasters/slideMaster1.xml",
189 format!(
190 r#"{XML}<p:sldMaster xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}">{tree}<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>"#
191 ),
192 ),
193 (
194 "ppt/slideMasters/_rels/slideMaster1.xml.rels",
195 format!(r#"{XML}<Relationships xmlns="{NS_REL}">{}{}</Relationships>"#, rel("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), rel("rId2", "theme", "../theme/theme1.xml")),
196 ),
197 ("ppt/slideLayouts/slideLayout1.xml", format!(r#"{XML}<p:sldLayout xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}" type="blank">{tree}</p:sldLayout>"#)),
198 (
199 "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
200 format!(r#"{XML}<Relationships xmlns="{NS_REL}">{}</Relationships>"#, rel("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")),
201 ),
202 ("ppt/slides/slide1.xml", format!(r#"{XML}<p:sld xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}">{tree}</p:sld>"#)),
203 (
204 "ppt/slides/_rels/slide1.xml.rels",
205 format!(r#"{XML}<Relationships xmlns="{NS_REL}">{}</Relationships>"#, rel("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")),
206 ),
207 ("ppt/theme/theme1.xml", pptx_theme()),
208 ]
209}
210
211fn xmind_parts() -> Vec<(&'static str, String)> {
214 let id = || (0..26).map(|_| char::from(b"0123456789abcdefghijklmnopqrstuv"[fastrand::usize(..32)])).collect::<String>();
215 let content = serde_json::json!([{
216 "id": id(),
217 "revisionId": id(),
218 "class": "sheet",
219 "title": "Map 1",
220 "rootTopic": {
221 "id": id(),
222 "class": "topic",
223 "title": "Central Topic",
224 "structureClass": "org.xmind.ui.map.unbalanced"
225 }
226 }]);
227 let metadata = serde_json::json!({"creator": {"name": "falcon", "version": env!("CARGO_PKG_VERSION")}});
228 let manifest = serde_json::json!({"file-entries": {"content.json": {}, "metadata.json": {}}});
229 vec![("content.json", content.to_string()), ("metadata.json", metadata.to_string()), ("manifest.json", manifest.to_string())]
230}
231
232const PSD_SIZE: (u32, u32) = (1920, 1080);
234const AI_PAGE: (u32, u32) = (1366, 768);
236
237fn white_row_rle(n: usize) -> Vec<u8> {
240 let mut out = Vec::new();
241 let mut left = n;
242 while left > 0 {
243 let run = left.min(128);
244 if run == 1 {
245 out.extend([0, 0xFF]);
246 } else {
247 out.extend([(257 - run) as u8, 0xFF]);
248 }
249 left -= run;
250 }
251 out
252}
253
254fn psd_bytes() -> Vec<u8> {
259 let (w, h) = PSD_SIZE;
260 let mut out = Vec::new();
261 out.extend(b"8BPS");
262 out.extend(1u16.to_be_bytes()); out.extend([0u8; 6]); out.extend(3u16.to_be_bytes()); out.extend(h.to_be_bytes());
266 out.extend(w.to_be_bytes());
267 out.extend(8u16.to_be_bytes()); out.extend(3u16.to_be_bytes()); out.extend(0u32.to_be_bytes()); let mut res = Vec::new();
273 res.extend(b"8BIM");
274 res.extend(0x03EDu16.to_be_bytes());
275 res.extend([0u8, 0]); res.extend(16u32.to_be_bytes());
277 for _ in 0..2 {
278 res.extend(0x0048_0000u32.to_be_bytes());
279 res.extend(1u16.to_be_bytes()); res.extend(1u16.to_be_bytes()); }
282 out.extend((res.len() as u32).to_be_bytes());
283 out.extend(res);
284 out.extend(8u32.to_be_bytes());
286 out.extend(0u32.to_be_bytes());
287 out.extend(0u32.to_be_bytes());
288 out.extend(1u16.to_be_bytes());
291 let row = white_row_rle(w as usize);
292 for _ in 0..3 * h {
293 out.extend((row.len() as u16).to_be_bytes());
294 }
295 for _ in 0..3 * h {
296 out.extend(&row);
297 }
298 out
299}
300
301fn ai_bytes() -> Vec<u8> {
305 let (w, h) = AI_PAGE;
306 let objects = [
307 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
308 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
309 format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << >> /Contents 4 0 R >>"),
310 "<< /Length 0 >>\nstream\n\nendstream".to_string(),
311 ];
312 let mut out = b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n".to_vec();
313 let mut offsets = Vec::new();
314 for (i, body) in objects.iter().enumerate() {
315 offsets.push(out.len());
316 out.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
317 }
318 let xref = out.len();
319 out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
320 for o in offsets {
321 out.extend(format!("{o:010} 00000 n \n").bytes());
322 }
323 out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
324 out
325}
326
327const SKETCH_DOCUMENT_VERSION: u32 = 146;
329const SKETCH_COMPATIBILITY_VERSION: u32 = 99;
331const SKETCH_COMMIT: &str = "falcon";
334const SKETCH_APP_VERSION: &str = "0";
336const SKETCH_BUILD: u32 = 0;
338const SKETCH_PREVIEW_SIDE: u32 = 512;
340
341fn sketch_uuid() -> String {
344 let mut b = [0u8; 16];
345 fastrand::fill(&mut b);
346 b[6] = (b[6] & 0x0F) | 0x40;
347 b[8] = (b[8] & 0x3F) | 0x80;
348 let hex = |r: std::ops::Range<usize>| b[r].iter().map(|x| format!("{x:02X}")).collect::<String>();
349 format!("{}-{}-{}-{}-{}", hex(0..4), hex(4..6), hex(6..8), hex(8..10), hex(10..16))
350}
351
352fn sketch_page(id: &str) -> serde_json::Value {
355 let ruler = serde_json::json!({"_class": "rulerData", "base": 0, "guides": []});
356 serde_json::json!({
357 "_class": "page",
358 "do_objectID": id,
359 "booleanOperation": -1,
360 "exportOptions": {"_class": "exportOptions", "exportFormats": [], "includedLayerIds": [], "layerOptions": 0, "shouldTrim": false},
361 "frame": {"_class": "rect", "constrainProportions": false, "height": 0, "width": 0, "x": 0, "y": 0},
362 "hasClickThrough": true,
363 "horizontalRulerData": ruler,
364 "isFixedToViewport": false,
365 "isFlippedHorizontal": false,
366 "isFlippedVertical": false,
367 "isLocked": false,
368 "isTemplate": false,
369 "isVisible": true,
370 "layerListExpandedType": 0,
371 "layers": [],
372 "name": "Page 1",
373 "nameIsFixed": false,
374 "resizingConstraint": 63,
375 "resizingType": 0,
376 "rotation": 0,
377 "shouldBreakMaskChain": false,
378 "style": {
379 "_class": "style",
380 "do_objectID": sketch_uuid(),
381 "borderOptions": {"_class": "borderOptions", "isEnabled": true, "dashPattern": [], "lineCapStyle": 0, "lineJoinStyle": 0},
382 "startMarkerType": 0,
383 "endMarkerType": 0,
384 "miterLimit": 10,
385 "windingRule": 1,
386 "innerShadows": [],
387 "colorControls": {"_class": "colorControls", "isEnabled": false, "brightness": 0, "contrast": 0, "hue": 0, "saturation": 0}
388 },
389 "verticalRulerData": ruler
390 })
391}
392
393fn sketch_document(page_id: &str) -> serde_json::Value {
396 serde_json::json!({
397 "_class": "document",
398 "do_objectID": sketch_uuid(),
399 "assets": {"_class": "assetCollection", "do_objectID": sketch_uuid(), "colorAssets": [], "gradientAssets": [], "images": [], "colors": [], "gradients": [], "exportPresets": []},
400 "colorSpace": 1,
401 "currentPageIndex": 0,
402 "foreignLayerStyles": [],
403 "foreignSymbols": [],
404 "foreignTextStyles": [],
405 "foreignSwatches": [],
406 "layerStyles": {"_class": "sharedStyleContainer", "objects": []},
407 "layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []},
408 "layerSymbols": {"_class": "symbolContainer", "objects": []},
409 "sharedSwatches": {"_class": "swatchContainer", "objects": []},
410 "fontReferences": [],
411 "perDocumentLibraries": [],
412 "pages": [{"_class": "MSJSONFileReference", "_ref_class": "MSImmutablePage", "_ref": format!("pages/{page_id}")}]
413 })
414}
415
416fn sketch_meta(page_id: &str, page_name: &str) -> serde_json::Value {
417 const APP: &str = "com.bohemiancoding.sketch3";
418 const VARIANT: &str = "NONAPPSTORE";
419 serde_json::json!({
420 "commit": SKETCH_COMMIT,
421 "pagesAndArtboards": {page_id: {"name": page_name, "artboards": {}}},
422 "version": SKETCH_DOCUMENT_VERSION,
423 "compatibilityVersion": SKETCH_COMPATIBILITY_VERSION,
424 "app": APP,
425 "autosaved": 0,
426 "variant": VARIANT,
427 "created": {
428 "commit": SKETCH_COMMIT,
429 "appVersion": SKETCH_APP_VERSION,
430 "build": SKETCH_BUILD,
431 "app": APP,
432 "compatibilityVersion": SKETCH_COMPATIBILITY_VERSION,
433 "version": SKETCH_DOCUMENT_VERSION,
434 "variant": VARIANT
435 },
436 "saveHistory": [],
437 "appVersion": SKETCH_APP_VERSION,
438 "build": SKETCH_BUILD
439 })
440}
441
442fn sketch_user(page_id: &str) -> serde_json::Value {
444 serde_json::json!({
445 "document": {"pageListHeight": 200, "pageListCollapsed": 0},
446 page_id: {"scrollOrigin": "{0, 0}", "zoomValue": 1}
447 })
448}
449
450fn sketch_preview() -> Vec<u8> {
452 let side = SKETCH_PREVIEW_SIDE;
453 let mut out = Cursor::new(Vec::new());
454 image::RgbImage::from_pixel(side, side, image::Rgb([255, 255, 255])).write_to(&mut out, image::ImageFormat::Png).expect("png");
456 out.into_inner()
457}
458
459fn sketch_parts() -> Vec<(String, Vec<u8>)> {
461 let page_id = sketch_uuid();
462 let page = sketch_page(&page_id);
463 let name = page["name"].as_str().unwrap_or_default();
464 let json = |v: &serde_json::Value| v.to_string().into_bytes();
465 vec![
466 ("meta.json".to_string(), json(&sketch_meta(&page_id, name))),
467 ("document.json".to_string(), json(&sketch_document(&page_id))),
468 ("user.json".to_string(), json(&sketch_user(&page_id))),
469 (format!("pages/{page_id}.json"), json(&page)),
470 ("previews/preview.png".to_string(), sketch_preview()),
471 ]
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::io::Read;
478
479 fn open(t: Template) -> zip::ZipArchive<Cursor<Vec<u8>>> {
480 zip::ZipArchive::new(Cursor::new(t.bytes())).expect("a valid zip")
481 }
482
483 fn entry(z: &mut zip::ZipArchive<Cursor<Vec<u8>>>, name: &str) -> String {
484 let mut s = String::new();
485 z.by_name(name).unwrap_or_else(|_| panic!("missing {name}")).read_to_string(&mut s).unwrap();
486 s
487 }
488
489 fn check_package(t: Template, required: &[&str]) {
492 let mut z = open(t);
493 let names: Vec<String> = (0..z.len()).map(|i| z.name_for_index(i).unwrap().unwrap().to_string()).collect();
494 assert_eq!(names[0], "[Content_Types].xml");
495 for r in required {
496 assert!(names.iter().any(|n| n == r), "{t:?} lacks {r}");
497 }
498 for n in &names {
499 let xml = entry(&mut z, n);
500 let doc = roxmltree::Document::parse(&xml).unwrap_or_else(|e| panic!("{n}: {e}"));
501 if let Some(base) = n.strip_suffix(".rels") {
502 let dir = base.rsplit_once("_rels/").map(|(d, _)| d).unwrap_or("");
504 for rel in doc.descendants().filter(|e| e.has_tag_name("Relationship")) {
505 let target = rel.attribute("Target").unwrap();
506 let mut parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
507 for seg in target.split('/') {
508 if seg == ".." {
509 parts.pop();
510 } else {
511 parts.push(seg);
512 }
513 }
514 assert!(names.contains(&parts.join("/")), "{n} points at missing {target}");
515 }
516 }
517 if n == "[Content_Types].xml" {
518 for o in doc.descendants().filter(|e| e.has_tag_name("Override")) {
519 let part = o.attribute("PartName").unwrap().trim_start_matches('/');
520 assert!(names.iter().any(|x| x == part), "override for missing {part}");
521 }
522 }
523 }
524 }
525
526 #[test]
527 fn names_are_untitled_with_the_extension() {
528 let names: Vec<String> = Template::ALL.iter().map(|t| t.file_name()).collect();
529 assert_eq!(names, ["Untitled.txt", "Untitled.docx", "Untitled.xlsx", "Untitled.pptx", "Untitled.xmind", "Untitled.psd", "Untitled.ai", "Untitled.sketch"]);
530 }
531
532 #[test]
533 fn text_is_empty() {
534 assert!(Template::Text.bytes().is_empty());
535 }
536
537 #[test]
538 fn docx_is_a_valid_package() {
539 check_package(Template::Word, &["_rels/.rels", "word/document.xml"]);
540 }
541
542 #[test]
543 fn xlsx_is_a_valid_package() {
544 check_package(Template::Excel, &["_rels/.rels", "xl/workbook.xml", "xl/worksheets/sheet1.xml"]);
545 }
546
547 #[test]
548 fn pptx_is_a_valid_package() {
549 check_package(Template::PowerPoint, &["ppt/presentation.xml", "ppt/slideMasters/slideMaster1.xml", "ppt/slideLayouts/slideLayout1.xml", "ppt/slides/slide1.xml", "ppt/theme/theme1.xml"]);
550 }
551
552 #[test]
553 fn xmind_has_a_sheet_with_a_root_topic() {
554 let mut z = open(Template::XMind);
555 let content: serde_json::Value = serde_json::from_str(&entry(&mut z, "content.json")).unwrap();
556 let sheet = &content[0];
557 assert_eq!(sheet["class"], "sheet");
558 assert_eq!(sheet["rootTopic"]["class"], "topic");
559 assert!(sheet["id"].as_str().is_some_and(|s| !s.is_empty()));
560 let meta: serde_json::Value = serde_json::from_str(&entry(&mut z, "metadata.json")).unwrap();
561 assert_eq!(meta["creator"]["name"], "falcon");
562 let manifest: serde_json::Value = serde_json::from_str(&entry(&mut z, "manifest.json")).unwrap();
563 assert!(manifest["file-entries"]["content.json"].is_object());
564 }
565
566 fn be32(b: &[u8], at: usize) -> usize {
567 u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as usize
568 }
569
570 #[test]
571 fn psd_sections_are_consistent() {
572 let b = Template::Psd.bytes();
573 assert_eq!(&b[..4], b"8BPS");
574 assert_eq!(&b[4..6], [0, 1]);
575 assert_eq!((be32(&b, 14), be32(&b, 18)), (1080, 1920));
576 assert_eq!(&b[22..26], [0, 8, 0, 3]);
577 let mut at = 26;
579 let colour = be32(&b, at);
580 at += 4 + colour;
581 let resources = be32(&b, at);
582 assert!(b[at + 4..at + 4 + resources].starts_with(b"8BIM"));
583 at += 4 + resources;
584 let layers = be32(&b, at);
585 at += 4 + layers;
586 assert_eq!(&b[at..at + 2], [0, 1], "RLE");
587 at += 2;
588 let table = 3 * 1080 * 2;
589 let data: usize = (0..3 * 1080).map(|i| u16::from_be_bytes([b[at + 2 * i], b[at + 2 * i + 1]]) as usize).sum();
590 assert_eq!(at + table + data, b.len());
591 assert!(b.len() < 120_000);
592 }
593
594 #[test]
595 fn psd_decodes_as_white() {
596 let dir = tempfile::tempdir().unwrap();
597 let file = dir.path().join("Untitled.psd");
598 std::fs::write(&file, Template::Psd.bytes()).unwrap();
599 assert_eq!(crate::psd::dimensions(&file), Some((1920, 1080)));
600 let mut c = crate::psd::Composite::open(&file).unwrap();
601 assert_eq!(c.size(), (1920, 1080));
602 let img = c.read_rects(&[[0, 0, 1920, 1080]]).unwrap().remove(0);
603 assert!(img.pixels().all(|p| p.0 == [255, 255, 255, 255]));
604 }
605
606 #[cfg(target_os = "macos")]
608 #[test]
609 fn sips_reads_the_psd() {
610 let dir = tempfile::tempdir().unwrap();
611 let file = dir.path().join("Untitled.psd");
612 std::fs::write(&file, Template::Psd.bytes()).unwrap();
613 let Ok(out) = std::process::Command::new("/usr/bin/sips").args(["-g", "pixelWidth", "-g", "pixelHeight"]).arg(&file).output() else { return };
614 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
615 let text = String::from_utf8_lossy(&out.stdout);
616 assert!(text.contains("pixelWidth: 1920") && text.contains("pixelHeight: 1080"), "{text}");
617 }
618
619 #[test]
620 fn ai_is_a_pdf_with_correct_offsets() {
621 let b = Template::Ai.bytes();
622 assert!(b.starts_with(b"%PDF-"));
623 assert!(crate::import::is_recognized("ai"));
624 let find = |needle: &[u8]| b.windows(needle.len()).rposition(|w| w == needle).unwrap();
625 let tail = String::from_utf8(b[find(b"startxref\n")..].to_vec()).unwrap();
626 let start: usize = tail.lines().nth(1).unwrap().parse().unwrap();
627 assert!(b[start..].starts_with(b"xref\n0 5\n"));
628 let table = String::from_utf8(b[start..find(b"trailer")].to_vec()).unwrap();
629 let entries: Vec<&str> = table.lines().skip(2).collect();
630 assert_eq!(entries.len(), 5);
631 assert!(entries[0].ends_with(" f "));
632 for (i, e) in entries.iter().enumerate().skip(1) {
633 let off: usize = e[..10].parse().unwrap();
634 assert!(e.ends_with(" n "));
635 assert!(b[off..].starts_with(format!("{i} 0 obj\n").as_bytes()), "object {i}");
636 }
637 let text = String::from_utf8_lossy(&b);
638 assert!(text.contains("/MediaBox [0 0 1366 768]") && text.contains("/Count 1"));
639 assert!(text.trim_end().ends_with("%%EOF"));
640 }
641
642 #[cfg(target_os = "macos")]
644 #[test]
645 fn sips_reads_the_ai_as_a_pdf() {
646 let dir = tempfile::tempdir().unwrap();
647 let file = dir.path().join("Untitled.pdf");
648 std::fs::write(&file, Template::Ai.bytes()).unwrap();
649 let Ok(out) = std::process::Command::new("/usr/bin/sips").args(["-g", "pixelWidth", "-g", "pixelHeight"]).arg(&file).output() else { return };
650 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
651 let text = String::from_utf8_lossy(&out.stdout);
652 assert!(text.contains("pixelWidth: 1366") && text.contains("pixelHeight: 768"), "{text}");
653 }
654
655 #[cfg(target_os = "macos")]
657 #[test]
658 fn textutil_reads_the_docx() {
659 let dir = tempfile::tempdir().unwrap();
660 let file = dir.path().join("Untitled.docx");
661 std::fs::write(&file, Template::Word.bytes()).unwrap();
662 let Ok(out) = std::process::Command::new("/usr/bin/textutil").args(["-convert", "txt", "-stdout"]).arg(&file).output() else { return };
663 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
664 }
665
666 fn is_uuid(s: &str) -> bool {
668 let groups: Vec<&str> = s.split('-').collect();
669 groups.iter().map(|g| g.len()).eq([8, 4, 4, 4, 12]) && s.chars().all(|c| c == '-' || c.is_ascii_digit() || ('A'..='F').contains(&c)) && groups[2].starts_with('4') && groups[3].starts_with(['8', '9', 'A', 'B'])
670 }
671
672 fn sketch_json(z: &mut zip::ZipArchive<Cursor<Vec<u8>>>, name: &str) -> serde_json::Value {
673 serde_json::from_str(&entry(z, name)).unwrap_or_else(|e| panic!("{name}: {e}"))
674 }
675
676 fn has_keys(v: &serde_json::Value, keys: &[&str]) {
679 for k in keys {
680 assert!(v.get(k).is_some(), "lacks {k}: {v}");
681 }
682 }
683
684 fn entry_names(z: &zip::ZipArchive<Cursor<Vec<u8>>>) -> Vec<String> {
685 (0..z.len()).map(|i| z.name_for_index(i).unwrap().unwrap().to_string()).collect()
686 }
687
688 fn sketch_page_entry(z: &zip::ZipArchive<Cursor<Vec<u8>>>) -> String {
689 entry_names(z).into_iter().find(|n| n.starts_with("pages/")).unwrap()
690 }
691
692 #[test]
693 fn sketch_uuids_are_uppercase_v4_and_differ() {
694 for _ in 0..200 {
695 assert!(is_uuid(&sketch_uuid()));
696 }
697 assert_ne!(sketch_uuid(), sketch_uuid());
698 assert!(!is_uuid("abcdef00-0000-4000-8000-000000000000"), "lower case is refused by the schema");
699 }
700
701 #[test]
702 fn sketch_has_exactly_the_documented_entries() {
703 let z = open(Template::Sketch);
704 let page = sketch_page_entry(&z);
705 let mut names = entry_names(&z);
706 names.sort_unstable();
707 let mut want = ["document.json", "meta.json", "previews/preview.png", "user.json", page.as_str()];
708 want.sort_unstable();
709 assert_eq!(names, want);
710 assert!(is_uuid(page.trim_start_matches("pages/").trim_end_matches(".json")), "{page}");
711 }
712
713 #[test]
714 fn sketch_document_refers_to_its_page() {
715 let mut z = open(Template::Sketch);
716 let file = sketch_page_entry(&z);
717 let doc = sketch_json(&mut z, "document.json");
718 let pages = doc["pages"].as_array().unwrap();
719 assert_eq!(pages.len(), 1);
720 assert_eq!(pages[0]["_class"], "MSJSONFileReference");
721 assert_eq!(pages[0]["_ref_class"], "MSImmutablePage");
722 assert_eq!(format!("{}.json", pages[0]["_ref"].as_str().unwrap()), file);
724 let page = sketch_json(&mut z, &file);
725 assert_eq!(format!("pages/{}.json", page["do_objectID"].as_str().unwrap()), file);
726 }
727
728 #[test]
729 fn sketch_document_has_the_schemas_required_fields() {
730 let mut z = open(Template::Sketch);
731 let doc = sketch_json(&mut z, "document.json");
732 has_keys(&doc, &["_class", "assets", "colorSpace", "currentPageIndex", "do_objectID", "foreignLayerStyles", "foreignSymbols", "foreignTextStyles", "layerStyles", "layerTextStyles", "pages", "perDocumentLibraries"]);
733 has_keys(&doc["assets"], &["_class", "do_objectID", "colorAssets", "gradientAssets", "images", "colors", "gradients", "exportPresets"]);
734 assert_eq!(doc["_class"], "document");
735 assert_eq!(doc["assets"]["_class"], "assetCollection");
736 assert_eq!(doc["currentPageIndex"], 0);
737 assert!(is_uuid(doc["do_objectID"].as_str().unwrap()));
738 assert!(is_uuid(doc["assets"]["do_objectID"].as_str().unwrap()));
739 }
740
741 #[test]
742 fn sketch_page_is_empty_with_the_schemas_required_fields() {
743 let mut z = open(Template::Sketch);
744 let file = sketch_page_entry(&z);
745 let page = sketch_json(&mut z, &file);
746 has_keys(
747 &page,
748 &[
749 "_class",
750 "booleanOperation",
751 "do_objectID",
752 "exportOptions",
753 "frame",
754 "hasClickThrough",
755 "horizontalRulerData",
756 "isFixedToViewport",
757 "isFlippedHorizontal",
758 "isFlippedVertical",
759 "isLocked",
760 "isTemplate",
761 "isVisible",
762 "layerListExpandedType",
763 "layers",
764 "name",
765 "nameIsFixed",
766 "resizingConstraint",
767 "resizingType",
768 "rotation",
769 "shouldBreakMaskChain",
770 "verticalRulerData",
771 ],
772 );
773 has_keys(&page["frame"], &["_class", "constrainProportions", "height", "width", "x", "y"]);
774 has_keys(&page["style"], &["_class", "do_objectID", "borderOptions", "startMarkerType", "endMarkerType", "miterLimit", "windingRule", "innerShadows", "colorControls"]);
775 assert_eq!(page["_class"], "page");
776 assert_eq!(page["name"], "Page 1");
777 assert_eq!(page["layers"], serde_json::json!([]));
778 }
779
780 #[test]
781 fn sketch_meta_and_user_name_the_page() {
782 let mut z = open(Template::Sketch);
783 let file = sketch_page_entry(&z);
784 let id = file.trim_start_matches("pages/").trim_end_matches(".json").to_string();
785 let meta = sketch_json(&mut z, "meta.json");
786 has_keys(&meta, &["commit", "pagesAndArtboards", "version", "compatibilityVersion", "app", "autosaved", "variant", "created", "saveHistory", "appVersion", "build"]);
787 has_keys(&meta["created"], &["commit", "appVersion", "build", "app", "compatibilityVersion", "version", "variant"]);
788 assert_eq!(meta["version"], 146);
789 assert_eq!(meta["compatibilityVersion"], 99);
790 assert_eq!(meta["pagesAndArtboards"][id.as_str()], serde_json::json!({"name": "Page 1", "artboards": {}}));
791 assert_eq!(meta["pagesAndArtboards"].as_object().unwrap().len(), 1);
792 let user = sketch_json(&mut z, "user.json");
793 has_keys(&user["document"], &["pageListHeight", "pageListCollapsed"]);
794 has_keys(&user[id.as_str()], &["scrollOrigin", "zoomValue"]);
795 }
796
797 #[test]
798 fn each_sketch_document_gets_its_own_ids() {
799 let (mut a, mut b) = (open(Template::Sketch), open(Template::Sketch));
800 assert_ne!(sketch_page_entry(&a), sketch_page_entry(&b));
801 assert_ne!(sketch_json(&mut a, "document.json")["do_objectID"], sketch_json(&mut b, "document.json")["do_objectID"]);
802 }
803
804 #[test]
806 fn sketch_preview_is_read_by_the_zip_preview_reader() {
807 let dir = tempfile::tempdir().unwrap();
808 let file = dir.path().join(Template::Sketch.file_name());
809 std::fs::write(&file, Template::Sketch.bytes()).unwrap();
810 let img = crate::zip_preview::preview(&file, "sketch").expect("a preview").to_rgb8();
811 assert_eq!(img.dimensions(), (SKETCH_PREVIEW_SIDE, SKETCH_PREVIEW_SIDE));
812 assert!(img.pixels().all(|p| p.0 == [255, 255, 255]));
813 }
814
815 #[test]
817 fn every_template_is_recognised_by_import() {
818 for t in Template::ALL {
819 assert!(crate::import::is_recognized(t.ext()), "{}", t.ext());
820 }
821 }
822}