Add Typst Desktop app

This commit is contained in:
2026-07-18 15:00:22 -04:00
commit 8853c614d7
72 changed files with 15548 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use tauri::AppHandle;
use crate::db::Store;
use crate::workspace::workspace_root;
pub const ASSETS_DIR: &str = ".assets";
const FONT_EXTENSIONS: [&str; 4] = ["ttf", "otf", "ttc", "otc"];
const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
fn extension_of(name: &str) -> String {
Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase()
}
pub fn is_font(name: &str) -> bool {
FONT_EXTENSIONS.contains(&extension_of(name).as_str())
}
pub fn is_image(name: &str) -> bool {
IMAGE_EXTENSIONS.contains(&extension_of(name).as_str())
}
pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
let dir = workspace_root(app, store)?.join(ASSETS_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Cannot create assets folder: {}", e))?;
Ok(dir)
}
fn families_in(data: &[u8]) -> Vec<String> {
let mut families = BTreeSet::new();
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
families.insert(font.info().family.clone());
}
families.into_iter().collect()
}
#[derive(Serialize)]
pub struct Asset {
pub name: String,
pub kind: String,
pub size: u64,
pub font_families: Vec<String>,
}
pub fn list_assets(app: &AppHandle, store: &Store) -> Result<Vec<Asset>, String> {
let dir = assets_dir(app, store)?;
let mut assets = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.path().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let font_families = if is_font(&name) {
std::fs::read(entry.path())
.map(|data| families_in(&data))
.unwrap_or_default()
} else {
Vec::new()
};
assets.push(Asset {
kind: if is_font(&name) {
"font"
} else if is_image(&name) {
"image"
} else {
"file"
}
.to_string(),
name,
size,
font_families,
});
}
assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(assets)
}
pub fn font_families(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
let mut families = BTreeSet::new();
for data in typst_assets::fonts() {
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) {
families.insert(font.info().family.clone());
}
}
for (name, data) in files {
if is_font(name) {
for family in families_in(data) {
families.insert(family);
}
}
}
families.into_iter().collect()
}
fn unique_destination(dir: &Path, name: &str) -> PathBuf {
let candidate = dir.join(name);
if !candidate.exists() {
return candidate;
}
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_string();
let extension = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e))
.unwrap_or_default();
for index in 2..1000 {
let candidate = dir.join(format!("{}-{}{}", stem, index, extension));
if !candidate.exists() {
return candidate;
}
}
dir.join(name)
}
fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
let from = entry.path();
let to = destination.join(&name);
if from.is_dir() {
copy_tree(&from, &to)?;
} else {
std::fs::copy(&from, &to).map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub fn import_paths(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
let mut imported = Vec::new();
for source in sources {
let source_path = Path::new(source);
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.ok_or_else(|| format!("'{}' has no name", source))?;
if source_path.is_dir() {
let target = unique_destination(destination, &name);
copy_tree(source_path, &target)?;
} else if source_path.is_file() {
let target = unique_destination(destination, &name);
std::fs::copy(source_path, &target)
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
} else {
return Err(format!("'{}' could not be read", source));
}
imported.push(name);
}
Ok(imported)
}
pub fn import_files(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
let mut imported = Vec::new();
for source in sources {
let source_path = Path::new(source);
if !source_path.is_file() {
return Err(format!("'{}' is not a file", source));
}
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.ok_or_else(|| format!("'{}' has no file name", source))?;
let target = unique_destination(destination, &name);
std::fs::copy(source_path, &target)
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
imported.push(
target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(name),
);
}
Ok(imported)
}
pub fn delete_asset(app: &AppHandle, store: &Store, name: &str) -> Result<(), String> {
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("Invalid asset name".to_string());
}
let path = assets_dir(app, store)?.join(name);
std::fs::remove_file(path).map_err(|e| e.to_string())
}
pub fn asset_files(app: &AppHandle, store: &Store) -> HashMap<String, Vec<u8>> {
let Ok(dir) = assets_dir(app, store) else {
return HashMap::new();
};
let mut files = HashMap::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return files;
};
for entry in entries.filter_map(|entry| entry.ok()) {
if !entry.path().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if let Ok(data) = std::fs::read(entry.path()) {
files.insert(name, data);
}
}
files
}