Skip to main content

falcon_library/
templates.rs

1//! Blank documents falcon writes itself, for File ▸ New….
2//!
3//! Each [`Template`] builds the bytes of a minimal, valid empty file in
4//! code: a text file, the Office Open XML formats (Word, Excel, PowerPoint),
5//! an XMind mind map, a Photoshop document (from Adobe's public PSD file
6//! format specification), a PDF-compatible Illustrator document (a plain
7//! PDF with one blank page) and a Sketch document. Nothing is copied from
8//! another application's template files. The caller writes the bytes to a
9//! temporary file named [`Template::file_name`] and imports it like any
10//! other file.
11//!
12//! The Sketch document follows Sketch's public file format reference
13//! (developer.sketch.com/file-format) and targets document version 146, the
14//! latest in the MIT-licensed JSON schemas of
15//! `@sketch-hq/sketch-file-format` 6.5.0. It has one empty page and no
16//! artboards. Those schemas have no `fonts` key in `meta.json`, so it is left
17//! out. `meta.json` names the Sketch that saved a file; no Sketch saved this
18//! one and the public docs give no app version for document version 146, so
19//! `SKETCH_COMMIT`, `SKETCH_APP_VERSION` and `SKETCH_BUILD` say so
20//! rather than claim one, and the save history is empty. The schema allows
21//! only Sketch's own bundle ids for `app`, so the public release's is used.
22
23use std::io::{Cursor, Write};
24
25use zip::write::SimpleFileOptions;
26
27/// A blank document falcon can make.
28#[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    /// Every template.
42    pub const ALL: [Template; 8] = [Template::Text, Template::Word, Template::Excel, Template::PowerPoint, Template::XMind, Template::Psd, Template::Ai, Template::Sketch];
43
44    /// The file extension.
45    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    /// The name the new file is imported under: "Untitled" plus the extension.
59    pub fn file_name(self) -> String {
60        format!("Untitled.{}", self.ext())
61    }
62
63    /// The bytes of the blank document.
64    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        // Writing into memory cannot fail.
83        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
211/// XMind's current format (XMind 8 "Zen" and later): `content.json` is a
212/// list of sheets, each with a root topic.
213fn 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
232/// Size of the blank Photoshop canvas, in pixels.
233const PSD_SIZE: (u32, u32) = (1920, 1080);
234/// Page size of the blank Illustrator document, in points.
235const AI_PAGE: (u32, u32) = (1366, 768);
236
237/// PackBits (the PSD "RLE" scheme) of a row of `n` white bytes: runs of at
238/// most 128 copies, each a count byte `257 - run` and the value.
239fn 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
254/// A white, opaque, 8-bit RGB Photoshop document with no layers, laid out
255/// as the spec's five sections: header, colour mode data (empty), image
256/// resources (the resolution, 72 dpi), layer and mask information (no
257/// layers, no global mask) and the merged image, RLE compressed.
258fn 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()); // version
263    out.extend([0u8; 6]); // reserved
264    out.extend(3u16.to_be_bytes()); // channels
265    out.extend(h.to_be_bytes());
266    out.extend(w.to_be_bytes());
267    out.extend(8u16.to_be_bytes()); // depth
268    out.extend(3u16.to_be_bytes()); // colour mode: RGB
269    out.extend(0u32.to_be_bytes()); // colour mode data
270    // Resolution info (resource 0x03ED), 16 bytes: 72.00 dpi as 16.16 fixed
271    // point, in inches, both ways.
272    let mut res = Vec::new();
273    res.extend(b"8BIM");
274    res.extend(0x03EDu16.to_be_bytes());
275    res.extend([0u8, 0]); // empty pascal name, padded to even
276    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()); // inches
280        res.extend(1u16.to_be_bytes()); // inches
281    }
282    out.extend((res.len() as u32).to_be_bytes());
283    out.extend(res);
284    // Layer and mask information: layer info length 0, global mask length 0.
285    out.extend(8u32.to_be_bytes());
286    out.extend(0u32.to_be_bytes());
287    out.extend(0u32.to_be_bytes());
288    // Image data: RLE, a table of byte counts for every row of every
289    // channel, then the rows channel by channel.
290    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
301/// A one-page PDF 1.5 file: Illustrator opens a PDF saved with an `.ai`
302/// extension, and falcon reads PDF-compatible Illustrator files by their
303/// `%PDF` header.
304fn 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
327/// The Sketch document version the blank is written in.
328const SKETCH_DOCUMENT_VERSION: u32 = 146;
329/// `compatibilityVersion`: the schema allows only this value.
330const SKETCH_COMPATIBILITY_VERSION: u32 = 99;
331/// `commit` in `meta.json`, a free string: names the writer, as no Sketch
332/// build wrote the file.
333const SKETCH_COMMIT: &str = "falcon";
334/// `appVersion` in `meta.json`: no version of Sketch saved the file.
335const SKETCH_APP_VERSION: &str = "0";
336/// `build` in `meta.json`: no build of Sketch saved the file.
337const SKETCH_BUILD: u32 = 0;
338/// Side of the blank's preview picture, in pixels.
339const SKETCH_PREVIEW_SIDE: u32 = 512;
340
341/// A random UUID v4 in the form the Sketch schemas require: uppercase and
342/// hyphenated.
343fn 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
352/// The one empty page: a layer at the origin with no size, untransformed,
353/// with no fill, border or effect.
354fn 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
393/// The document: no shared styles, symbols, swatches or assets, sRGB, and a
394/// reference to the page. A reference names the page's file without `.json`.
395fn 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
442/// The per-user state: the page at the origin at 100%, the page list open.
443fn 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
450/// A white square PNG, the picture of the empty page.
451fn sketch_preview() -> Vec<u8> {
452    let side = SKETCH_PREVIEW_SIDE;
453    let mut out = Cursor::new(Vec::new());
454    // Encoding into memory cannot fail.
455    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
459/// The entries of a Sketch document, each with fresh ids.
460fn 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    /// Every part parses as XML, and every relationship target and content
490    /// type override points at a part that exists.
491    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                // `dir/_rels/part.xml.rels` describes `dir/part.xml`.
503                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        // Each section's length leads to the next one, and the last ends the file.
578        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    /// macOS's image reader accepts the blank Photoshop file.
607    #[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    /// macOS's image reader opens the blank document as a PDF page.
643    #[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    /// macOS's own converter reads the blank Word file.
656    #[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    /// Whether `s` has the form the Sketch schemas give for a UUID, version 4.
667    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    /// Fails unless `v` has every key of `keys`, the `required` lists of the
677    /// public schema.
678    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        // A reference is the file's path without `.json`.
723        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    /// falcon's reader for files QuickLook can't render finds the preview.
805    #[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    /// They import as the item types the extensions give.
816    #[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}