Files
vectorcraft/crates/engine/src/cmd/fileio/formats.rs
T
dotCooCoo 421189893f Plug-ins: composite fonts stay out of importer filters; base64 size is checked first
File › Open imports .vccomposite files as composite fonts, so an importer
listing that extension no longer shows it under its own filter or in
document.formats. File › Open gets a Composite fonts filter.

document.open refuses dataBase64 that decodes past the importer input
limit before decoding it. A file whose start shows a built-in format
still opens as that format, as it does by path.
2026-11-08 15:18:31 -08:00

965 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::super::unique_name;
use super::{
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported,
};
/// One option a format's encoder reads from the export params.
#[derive(Clone, Copy, Debug)]
pub struct FormatOption {
pub name: &'static str,
/// JSON type: `number`, `integer`, `boolean`, `string`, `array` or `object`.
pub ty: &'static str,
/// The default as a JSON literal (`"1"`, `"false"`, `"null"`).
pub default: &'static str,
pub description: &'static str,
}
/// One file format: how `document.open` recognises it and what `document.export` writes.
#[derive(Clone, Copy, Debug)]
pub struct Format {
/// The `format` param value (`svg`, `png`, …).
pub id: &'static str,
/// Open/save dialog filter name.
pub label: &'static str,
/// Lower-case extensions without the dot; the first is the one exports use.
pub extensions: &'static [&'static str],
pub mime: &'static str,
pub read: bool,
pub write: bool,
/// Pixels (export `scale` applies) rather than vectors.
pub raster: bool,
/// Options the encoder reads (writable formats).
pub options: &'static [FormatOption],
}
impl Format {
pub fn to_json(&self) -> Value {
let options: serde_json::Map<String, Value> = self
.options
.iter()
.map(|o| {
let default: Value = serde_json::from_str(o.default).unwrap_or(Value::Null);
(o.name.to_string(), json!({"type": o.ty, "default": default, "description": o.description}))
})
.collect();
json!({
"id": self.id,
"label": self.label,
"extensions": self.extensions,
"mime": self.mime,
"read": self.read,
"write": self.write,
"raster": self.raster,
"options": options,
})
}
}
pub(super) const ARTBOARD: FormatOption = FormatOption {
name: "artboard",
ty: "integer",
default: "0",
description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)",
};
pub(super) const ARTBOARDS: FormatOption = FormatOption {
name: "artboards",
ty: "array",
default: "null",
description: "0-based artboards (PDF and AI: one page each, default all; SVG, and raster formats with useArtboards: one file each)",
};
pub(super) const RANGE: FormatOption = FormatOption {
name: "range",
ty: "string",
default: "null",
description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)",
};
const SCALE: FormatOption = FormatOption {
name: "scale",
ty: "number",
default: "null",
description: "pixels per point (0.01–64); without ppi or scale, uses the document's raster effects resolution",
};
const WIDTH: FormatOption = FormatOption {
name: "width",
ty: "number",
default: "null",
description: "the width of each file in pixels: each artboard, rect or art bounds gets its own scale (0.01–64 pixels per point, stored in the file except in GIF, WebP and Targa); wins over height, ppi and scale",
};
const HEIGHT: FormatOption = FormatOption {
name: "height",
ty: "number",
default: "null",
description: "the height of each file in pixels, as width; wins over ppi and scale",
};
const QUALITY: FormatOption =
FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" };
pub(super) const USE_ARTBOARDS: FormatOption = FormatOption {
name: "useArtboards",
ty: "boolean",
default: "null",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const RECT: FormatOption = FormatOption {
name: "rect",
ty: "array",
default: "null",
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
ty: "number",
default: "null",
description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file except in GIF, WebP and Targa; wins over scale; without either, uses the document's raster effects resolution",
};
const BACKGROUND: FormatOption = FormatOption {
name: "background",
ty: "string",
default: "\"transparent\"",
description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)",
};
const ANTI_ALIAS: FormatOption = FormatOption {
name: "antiAlias",
ty: "string",
default: "\"art\"",
description: "none (hard pixel edges), art (smooth edges) or type (smooth, text snapped to whole pixels)",
};
const INTERLACED: FormatOption =
FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" };
const COLOR_MODEL: FormatOption = FormatOption {
name: "colorModel",
ty: "string",
default: "\"rgb\"",
description: "rgb, cmyk (ink amounts in the working CMYK space: CMYK colours keep their inks) or gray",
};
const METHOD: FormatOption = FormatOption {
name: "method",
ty: "string",
default: "\"baseline\"",
description: "baseline (standard), optimized (smaller Huffman tables) or progressive (builds up in scans)",
};
const SCANS: FormatOption = FormatOption { name: "scans", ty: "integer", default: "3", description: "progressive scans, 3–5" };
const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
ty: "string",
default: "\"none\"",
description: "none, client (an HTML page with <map>, <stem>.html) or server (an NCSA <stem>.map): the areas of objects with a URL and an Image Map shape (attributes.set)",
};
const COLORS: FormatOption =
FormatOption { name: "colors", ty: "integer", default: "256", description: "most palette entries, 2–256 (the transparent one included)" };
const REDUCTION: FormatOption = FormatOption {
name: "reduction",
ty: "string",
default: "\"selective\"",
description: "the palette: perceptual, selective (also keeps rarer colours, snaps near web colours), adaptive (most used), web (web-safe), blackWhite or gray; art with that many colours or fewer keeps them exactly",
};
const DITHER: FormatOption = FormatOption {
name: "dither",
ty: "string",
default: "\"diffusion\"",
description: "none, diffusion (Floyd–Steinberg), pattern (8×8 ordered) or noise",
};
const DITHER_AMOUNT: FormatOption = FormatOption { name: "ditherAmount", ty: "integer", default: "100", description: "dither strength 0–100" };
const TRANSPARENCY: FormatOption = FormatOption {
name: "transparency",
ty: "boolean",
default: "true",
description: "pixels under half opacity become one transparent entry (false: everything is blended over the matte)",
};
const MATTE: FormatOption = FormatOption {
name: "matte",
ty: "string",
default: "\"white\"",
description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)",
};
const LOSSLESS: FormatOption = FormatOption {
name: "lossless",
ty: "boolean",
default: "true",
description: "lossless WebP; false asks for lossy, which isn't available yet (written lossless, with a warning)",
};
const WEBP_QUALITY: FormatOption =
FormatOption { name: "quality", ty: "integer", default: "90", description: "lossy quality 0–100 (for when lossy WebP is available)" };
/// The options of the palette formats (PNG-8, GIF).
const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
WIDTH,
HEIGHT,
BACKGROUND,
ANTI_ALIAS,
INTERLACED,
COLORS,
REDUCTION,
DITHER,
DITHER_AMOUNT,
TRANSPARENCY,
MATTE,
];
const LZW: FormatOption = FormatOption { name: "lzw", ty: "boolean", default: "true", description: "LZW compression (lossless, smaller files)" };
const BYTE_ORDER: FormatOption = FormatOption {
name: "byteOrder",
ty: "string",
default: "\"little\"",
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
name: "depth",
ty: "integer",
default: "24",
description: "bits per pixel: 1 (black and white), 4 or 8 (a palette by reduction and dither), 16, 24, or 32 (keeps transparency); the others are flattened on white",
};
const FILE_FORMAT: FormatOption = FormatOption {
name: "fileFormat",
ty: "string",
default: "\"windows\"",
description: "windows, or os2 (1, 4, 8 or 24 bits, uncompressed, rows bottom-up)",
};
const RLE: FormatOption =
FormatOption { name: "rle", ty: "boolean", default: "false", description: "run-length encode a 4- or 8-bit windows bitmap (RLE4, RLE8)" };
const FLIP_ROWS: FormatOption = FormatOption {
name: "flipRows",
ty: "boolean",
default: "false",
description: "rows top-down (a negative height) instead of bottom-up; not with rle or os2",
};
const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
WIDTH,
HEIGHT,
BACKGROUND,
ANTI_ALIAS,
BMP_COLOR_MODEL,
BMP_DEPTH,
FILE_FORMAT,
RLE,
FLIP_ROWS,
REDUCTION,
DITHER,
DITHER_AMOUNT,
];
const TGA_DEPTH: FormatOption = FormatOption {
name: "depth",
ty: "integer",
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
default: "true",
description: "write layers: each top-level layer a pixel layer with its opacity and blend mode (a background colour is a Background layer); false: one flat image, on white where transparent",
};
const MAX_EDITABILITY: FormatOption = FormatOption {
name: "maxEditability",
ty: "boolean",
default: "false",
description: "with layers: layers and sublayers become groups and every object a layer of its own, named as the Layers panel names it (text by its text); clipping, masked or knockout layers stay one layer",
};
const PSD_HIDDEN_LAYERS: FormatOption = FormatOption {
name: "hiddenLayers",
ty: "boolean",
default: "false",
description: "with layers: hidden layers (and hidden objects) are written as hidden layers instead of left out",
};
const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
WIDTH,
HEIGHT,
BACKGROUND,
ANTI_ALIAS,
COLOR_MODEL,
PSD_LAYERS,
MAX_EDITABILITY,
PSD_HIDDEN_LAYERS,
EMBED_ICC,
];
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
}
/// Every format VectorCraft reads or writes. Append-only: new formats go at the end.
pub const FORMATS: &[Format] = &[
Format {
id: "vectorcraft",
label: "VectorCraft",
extensions: &[vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION],
mime: "application/json",
read: true,
write: true,
raster: false,
options: native::OPTIONS,
},
Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS },
Format {
id: "svgz",
label: "SVG Compressed",
extensions: &["svgz"],
mime: "image/svg+xml",
read: true,
write: true,
raster: false,
options: svg::OPTIONS,
},
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
Format {
id: "ai",
label: "PDF-compatible .ai",
extensions: &["ai"],
mime: "application/pdf",
read: true,
// Save As writes it; exports write the same file.
write: true,
raster: false,
options: native::AI_OPTIONS,
},
Format {
id: "ait",
label: "PDF-compatible .ait template",
extensions: &["ait"],
mime: "application/pdf",
read: true,
// Save as Template writes it, with the .ai file's options; exports don't.
write: false,
raster: false,
options: native::AI_OPTIONS,
},
Format {
id: "png",
label: "PNG",
extensions: &["png"],
mime: "image/png",
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
},
Format {
id: "jpg",
label: "JPEG",
extensions: &["jpg", "jpeg"],
mime: "image/jpeg",
read: true,
write: true,
raster: true,
options: &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
WIDTH,
HEIGHT,
BACKGROUND,
ANTI_ALIAS,
QUALITY,
COLOR_MODEL,
METHOD,
SCANS,
EMBED_ICC,
IMAGE_MAP,
],
},
Format { id: "gif", label: "GIF", extensions: &["gif"], mime: "image/gif", read: true, write: true, raster: true, options: PALETTE_OPTIONS },
Format {
id: "webp",
label: "WebP",
extensions: &["webp"],
mime: "image/webp",
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
Format {
id: "tiff",
label: "TIFF",
extensions: &["tif", "tiff"],
mime: "image/tiff",
read: true,
write: true,
raster: true,
options: TIFF_OPTIONS,
},
Format { id: "bmp", label: "BMP", extensions: &["bmp"], mime: "image/bmp", read: true, write: true, raster: true, options: BMP_OPTIONS },
Format {
id: "template",
label: "VectorCraft Template",
extensions: &["vctemplate"],
mime: "application/json",
read: true,
write: true,
raster: false,
options: native::OPTIONS,
},
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: true, write: true, raster: false, options: dxf::OPTIONS },
Format {
id: "eps",
label: "EPS",
extensions: &["eps", "epsf"],
mime: "application/postscript",
read: true,
write: true,
raster: false,
options: eps::OPTIONS,
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format {
id: "tga",
label: "Targa",
extensions: &["tga", "vda", "icb", "vst"],
mime: "image/x-tga",
read: true,
write: true,
raster: true,
options: TGA_OPTIONS,
},
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
reader("ps", "PostScript", &["ps"], "application/postscript", false),
// ZSoft PCX images, read by `pcxread` and stored as PNG; exports don't write them.
reader("pcx", "PCX", &["pcx"], "image/x-pcx", true),
// Pixar picture files, read by `pxrread` and stored as PNG; exports don't write them.
reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true),
// JP2 and raw codestreams are decoded to PNG; JPX composition is not supported.
reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true),
// HEVC-coded HEIF still images, decoded to PNG; image sequences are not read.
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
pub const OPEN_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
"svg",
"svgz",
"pdf",
"ai",
"ait",
"png",
"pns",
"jpg",
"jpeg",
"gif",
"webp",
"tif",
"tiff",
"bmp",
"dib",
"rle",
"tga",
"vda",
"icb",
"vst",
"pcx",
"pxr",
"jpf",
"jpx",
"jp2",
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
"vctemplate",
"dxf",
"emf",
"wmf",
"eps",
"epsf",
"ps",
"af",
"afdesign",
"afpub",
];
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
pub fn export_extensions() -> Vec<&'static str> {
let mut v: Vec<&'static str> = Vec::new();
let added = plugin_formats();
for e in FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write).filter_map(|f| f.extensions.first()) {
if !v.contains(e) {
v.push(e);
}
}
v
}
/// Text files: File → Place sets them as area type (Text Import Options).
pub const TEXT_EXTS: &[&str] = &["txt"];
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Word 97-2003 documents: File → Place sets them as area type with their formatting.
pub const DOC_EXTS: &[&str] = &["doc"];
/// The Place dialog's Word filter: Word documents and Word 97-2003 documents.
const WORD_EXTS: &[&str] = &["docx", "doc"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`],
/// [`DOCX_EXTS`] and [`DOC_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
"svg",
"svgz",
"pdf",
"ai",
"ait",
"png",
"pns",
"jpg",
"jpeg",
"gif",
"webp",
"tif",
"tiff",
"bmp",
"dib",
"rle",
"tga",
"vda",
"icb",
"vst",
"pcx",
"pxr",
"jpf",
"jpx",
"jp2",
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
"vctemplate",
"dxf",
"emf",
"wmf",
"eps",
"epsf",
"ps",
"af",
"afdesign",
"afpub",
"txt",
"rtf",
"docx",
"doc",
];
/// The extensions File › Open and Place read PNG, BMP and Photoshop files by: the format's own,
/// then the other names its files have (`.pns`, `.dib` and `.rle`, `.pdd`). Exports write only
/// the format's own extension.
const READ_ALIASES: &[(&str, &[&str])] = &[("png", &["png", "pns"]), ("bmp", &["bmp", "dib", "rle"]), ("psd", &["psd", "pdd"])];
/// The extensions File › Open and Place read format `f` by: its own, and for PNG, BMP and PSD
/// those of [`READ_ALIASES`].
pub fn read_extensions(f: &Format) -> &'static [&'static str] {
READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts)
}
/// The built-in readable format of extension `ext` (any case, leading dot allowed): [`format`]'s
/// when it reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). Importer plug-ins are
/// not looked at ([`importer_formats`]).
pub fn readable_format(ext: &str) -> Option<&'static Format> {
let k = ext.trim_start_matches('.').to_ascii_lowercase();
builtin(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| builtin(id)))
}
/// The readable format a file name's extension names ([`readable_format`]).
pub fn readable_format_for_name(name: &str) -> Option<&'static Format> {
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| readable_format(&e))
}
/// One dialog filter per readable format.
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, read_extensions(f)))
}
/// Whether a file named `name` can only be read by an importer plug-in: an installed importer lists
/// its extension and File › Place does not read that extension.
pub fn opens_with_importer(name: &str) -> bool {
let ext = extension(name);
!ext.is_empty() && !PLACE_EXTS.contains(&ext.as_str()) && !vectorcraft_plugins::import::for_extension(&ext).is_empty()
}
/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a
/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print,
/// perspective grid or export presets or composite fonts (imported), a Libraries panel library, or a
/// swatch or graphic style library.
pub fn opens_as_other(ext: &str) -> bool {
[
crate::cmd::plugin::EXTS,
crate::cmd::shortcutset::EXTS,
crate::cmd::scripts::EXTS,
crate::cmd::flatten::PRESET_EXTS,
crate::cmd::pdfcmds::PRESET_EXTS,
crate::cmd::printpresets::PRESET_EXTS,
crate::cmd::perspgrid::PRESET_EXTS,
screens::PRESET_FILE_EXTS,
crate::cmd::compositefonts::EXTS,
crate::cmd::library::LIBRARY_EXTS,
crate::cmd::swatchlib::LIBRARY_EXTS,
std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT),
]
.iter()
.any(|exts| exts.contains(&ext))
}
/// File › Open's extensions and filters for the installed importer plug-ins.
type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>);
/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files"
/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer
/// that has extensions left, under its name. An importer's extensions leave out those a built-in
/// format reads, which File › Open reads with that format, and those File › Open opens as something
/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as
/// the dialogs that hold it.
fn importer_open_part() -> ImporterPart {
use vectorcraft_plugins::registry;
static MADE: std::sync::Mutex<Option<(u64, ImporterPart)>> = std::sync::Mutex::new(None);
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
let revision = registry::revision();
if let Some((r, part)) = made.as_ref()
&& *r == revision
{
return part.clone();
}
let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec();
let mut filters = Vec::new();
for f in importer_formats() {
let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect();
if own.is_empty() {
continue;
}
for &e in &own {
if !exts.contains(&e) {
exts.push(e);
}
}
let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) };
filters.push((f.label, own));
}
let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) };
let part = (exts, filters);
*made = Some((revision, part.clone()));
part
}
/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the
/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open
/// as something else ([`opens_as_other`]), in plug-in id order.
pub fn open_extensions() -> &'static [&'static str] {
importer_open_part().0
}
/// File › Open's filters: "All readable files" first, then one per readable format, then one per
/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then
/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF,
/// print and export presets (imported), scripts (checked without running them) and plug-ins
/// (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
let (all, importers) = importer_open_part();
std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters())
}
/// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For
/// open dialogs whose files the built-in readers read (New from Template, Load Styles, a document
/// picked for its swatches, symbols, brushes or graphic styles).
pub fn builtin_open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS)).chain(format_filters()).chain(other_open_filters())
}
/// The open-dialog filters after the document formats: swatch libraries, Libraries panel
/// libraries, presets, composite fonts, shortcut sets, scripts and plug-ins.
fn other_open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))
.chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", crate::cmd::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Export presets", screens::PRESET_FILE_EXTS)))
.chain(std::iter::once(("Composite fonts", crate::cmd::compositefonts::EXTS)))
.chain(std::iter::once(("Shortcut sets", crate::cmd::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", crate::cmd::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", crate::cmd::plugin::EXTS)))
}
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
/// match the default filter) still list `.ase` and the other library extensions; then the same
/// filters as [`builtin_open_filters`] so a document can be picked for its swatches or graphic
/// styles.
pub fn library_open_filters(
library_label: &'static str,
library_exts: &'static [&'static str],
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once((library_label, library_exts)).chain(builtin_open_filters())
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word (`.docx` and `.doc`).
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", WORD_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
/// one, else one a plug-in adds ([`plugin_formats`]), an exporter's before an importer's for an
/// extension both list. A built-in format keeps its extension, so an exporter or importer that
/// lists it too is found by its format id.
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
builtin(&k).or_else(|| {
let added = plugin_formats();
let by_ext = |f: &&&Format| f.extensions.contains(&k.as_str());
added
.iter()
.find(|f| f.id.eq_ignore_ascii_case(&k))
.or_else(|| added.iter().filter(|f| f.write).find(by_ext))
.or_else(|| added.iter().find(by_ext))
.copied()
})
}
/// A built-in format by id or extension (lower case, without the dot).
fn builtin(k: &str) -> Option<&'static Format> {
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k)))
}
/// The formats the installed exporter and importer plug-ins add ([`vectorcraft_plugins::export`],
/// [`vectorcraft_plugins::import`]), by plug-in id: an exporter's write only and an importer's read
/// only, with the plug-in's name as the label and its extensions. Each is made once for each
/// installation of its plug-in, its strings interned, so formats live as long as the catalogues
/// that hold them.
pub fn plugin_formats() -> Vec<&'static Format> {
use vectorcraft_plugins::{Kind, export, registry};
static MADE: std::sync::Mutex<Vec<(u64, &'static Format)>> = std::sync::Mutex::new(Vec::new());
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
registry::list()
.iter()
.filter(|p| matches!(p.manifest().kind, Kind::Exporter | Kind::Importer))
.map(|p| {
if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) {
return *f;
}
let m = p.manifest();
let extensions: Vec<&'static str> = m.extensions.iter().map(|e| registry::intern(e)).collect();
let f: &'static Format = Box::leak(Box::new(Format {
id: registry::intern(&export::format_id(&m.id)),
label: registry::intern(&m.name),
extensions: Box::leak(extensions.into_boxed_slice()),
mime: "application/octet-stream",
read: m.kind == Kind::Importer,
write: m.kind == Kind::Exporter,
raster: false,
options: &[],
}));
made.push((p.serial(), f));
f
})
.collect()
}
/// The formats the installed importer plug-ins add ([`plugin_formats`] that read), by plug-in id.
pub fn importer_formats() -> Vec<&'static Format> {
plugin_formats().into_iter().filter(|f| f.read).collect()
}
/// The lower-case extension of a file name or path (empty when it has none).
pub fn extension(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// A file name or path without its folder and extension (`/a/Poster.svg` → `Poster`).
pub fn file_stem(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
}
/// The name an imported preset `base` takes: `base` if `taken` doesn't claim it, else for a name
/// that ends in a space and a number ("Logo Package 2") the first free name with a higher number
/// ("Logo Package 3"), and for any other name the first free "base 2", "base 3"… ([`unique_name`]).
pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_string();
}
let numbered = base
.rsplit_once(' ')
.filter(|(stem, n)| !stem.trim().is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
.and_then(|(stem, n)| Some((stem, n.parse::<u64>().ok()?)));
match numbered {
Some((stem, n)) => {
(n.saturating_add(1)..=u64::MAX).map(|i| format!("{stem} {i}")).find(|c| !taken(c)).unwrap_or_else(|| unique_name(base, &taken))
}
None => unique_name(base, taken),
}
}
/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
Some(d)
}
/// The format a file name's extension names.
pub fn format_for_name(name: &str) -> Option<&'static Format> {
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
}
/// The format to write: `format` (an id or extension), else the path's extension, else PNG. For a
/// `.ait` template it returns an error that names `file.saveAsTemplate`, which writes it.
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
// Formats that can't be written say what to use instead.
let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string());
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
(Some(f), _) => format(f).ok_or_else(|| unknown(format!("unknown format `{f}` (see document.formats)"), f))?,
(None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?,
(None, None) => format("png").ok_or("no PNG encoder")?,
};
if f.write {
Ok(f)
} else if TEMPLATE_FORMATS.contains(&f.id) {
Err(format!("{} files are written by file.saveAsTemplate", f.label))
} else {
Err(format!("{} files can be opened but not written (see document.formats)", f.label))
}
}
/// [`writable_format`] for command `cmd`.
pub(super) fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result<&'static Format> {
writable_format(format_param, path).map_err(|e| bad(cmd, e))
}
pub(super) fn formats(_: &mut Session, _: &Value) -> Result<Value> {
let added = plugin_formats();
let all = || FORMATS.iter().chain(added.iter().copied());
let ids = |pick: fn(&Format) -> bool| all().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
let listed = |f: &Format| {
let mut v = f.to_json();
if let Some(id) = vectorcraft_plugins::export::plugin_id(f.id) {
v["plugin"] = json!(id);
}
v
};
Ok(json!({
"formats": all().map(listed).collect::<Vec<_>>(),
"readable": ids(|f| f.read),
"writable": ids(|f| f.write),
"openExtensions": open_extensions(),
"unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::<Vec<_>>(),
}))
}