Files
vectorcraft/crates/format/src/atomic.rs
T
dotCooCoo 5709e636a5 Fonts: missing fonts are listed when a document opens, with Find Fonts and a folder search
When a document opens with fonts that aren't available, fonts used
only in its symbols included, VectorCraft lists them in a Missing
Fonts dialog after the missing linked file questions, one document at
a time, with Find Fonts and Close. Find Fonts opens Type › Find Font
on the first missing font of the layers' type, to replace it. After
Find Fonts or Close the dialog doesn't open again for that open; when
another dialog replaces it, it opens again once that dialog closes.

Search Fonts Folder (the Fonts folder File › Package writes next to
the document) and Find in Folder… search a folder and its subfolders
for the fonts' files, and Add Fonts copies the files the user chooses
into a Fonts folder next to the preferences. The desktop app and
vectorcraft-cli read that folder with the system's font folders. Type
› Find Font has a Find in Folder… button too when fonts the document
uses are missing. The web app shows no dialog; a status message says
how many fonts aren't available.

- text.missingFonts lists each missing family and style once,
  whatever version the document's type names, and leaves out CSS
  generic families such as sans-serif. text.findFontFiles searches a
  folder on up to 8 threads and returns at once; text.findFontFiles {}
  reports the search, and {stop: true} stops it. A search reads only
  what the font scan reads of font files (their table directories and
  name, fvar and OS/2 tables) and, on macOS, of suitcase fonts, and
  lists a file for a font only when the file makes the font resolve
  exactly. It does not enter app packages, the Program Files and
  ProgramData folders, the system's folders at the root of a volume,
  the Shared and Public folders in Users, a home folder's Library,
  AppData, Applications, snap and dot folders, a Library folder's app
  data folders or the folders the environment names for apps, and it
  skips other hidden folders and does not follow links below the
  picked folder. It ends after 60 s by default, after 10,000,000
  entries listed, with more than 1,000,000 folders waiting, after
  10,000 font files read or 1,000 kept, or once every font has a file;
  Stop and Close end it too.
- text.addFontFiles copies only files the last search found. Of
  those, it copies only regular .ttf, .otf, .ttc, .otc, .woff and
  .woff2 files and suitcase fonts with their resource fork, at most
  256 MB a file and 1 GB in all. It never replaces a file: a file with
  the same contents is kept, and when another file has that name, the
  copy gets a number (Name 2.otf). The fonts are then scanned again,
  and type set in them redraws.
- ui.missingFontsDialog and ui.findFontsInFolder open the dialog for
  agents; docs/control-protocol.md lists its fields.
- File › Package gives a font file whose name has no font extension
  the extension that matches its data (.ttf, .otf or .ttc).
- vectorcraft-text adds set_app_font_dir, which system_font_dirs
  reads, WantedFonts, which reads a font file's names as the font scan
  does, is_font_file and is_suitcase. #762 split its face reader, which
  now takes read budgets: file_face_names_within tries the suitcase
  reader first, and reader_face_names_within applies the budgets to
  each font. vectorcraft-format adds write_new_with.

Docs: docs/mcp.md, docs/control-protocol.md, ROADMAP.
2026-10-10 08:42:32 +07:00

200 lines
9.2 KiB
Rust

//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
//! [`write_new_with`] creates a file the same way without ever replacing one.
use std::fs::{File, OpenOptions};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Write `bytes` to `path` atomically (see the module docs).
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
}
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
/// untouched.
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_link(path)?;
let tmp = temp_path(&target)?;
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
let filled = fill(&mut f).and_then(|()| f.sync_all());
drop(f);
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
// Best effort: the temporary file is all there is to clean up.
let _ = std::fs::remove_file(&tmp);
})
}
/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The
/// content goes to a temporary file beside it, which is then linked in under `path`
/// (`std::fs::hard_link`, which fails with `AlreadyExists` when the name is taken, also when it was
/// taken meanwhile). The file appears whole or not at all. Where hard links fail for another
/// reason, `path` is created with `create_new` and the content copied in; a failed copy removes
/// it. The temporary file is always removed.
pub fn write_new_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
write_new_linking(path, fill, |tmp, path| std::fs::hard_link(tmp, path))
}
/// [`write_new_with`], with `link` linking the temporary file in under `path`.
fn write_new_linking(
path: &Path,
fill: impl FnOnce(&mut File) -> io::Result<()>,
link: impl FnOnce(&Path, &Path) -> io::Result<()>,
) -> io::Result<()> {
let tmp = temp_path(path)?;
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
let filled = fill(&mut f).and_then(|()| f.sync_all());
drop(f);
let placed = filled.and_then(|()| match link(&tmp, path) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path),
linked => linked,
});
// Best effort: the temporary file is all there is to clean up.
let _ = std::fs::remove_file(&tmp);
placed
}
/// Create `path` (never replacing anything) with the content of the file `from`; a failed copy
/// removes it again.
fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
let mut to = OpenOptions::new().write(true).create_new(true).open(path)?;
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all());
drop(to);
if copied.is_err() {
// Best effort: the part copied is all there is to clean up.
let _ = std::fs::remove_file(path);
}
copied
}
/// A symbolic link keeps pointing at its file: write the file it names.
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
match std::fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path),
_ => Ok(path.to_path_buf()),
}
}
/// A fresh hidden name beside `target` (the same folder, so the rename stays on one volume).
fn temp_path(target: &Path) -> io::Result<PathBuf> {
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", target.display())))?;
let n = NEXT.fetch_add(1, Ordering::Relaxed);
Ok(target.with_file_name(format!(".{}.{}-{n}.tmp", name.to_string_lossy(), std::process::id())))
}
/// The replacement keeps the replaced file's permissions (Unix; Windows files have none to keep
/// beyond read-only, which makes the save fail as writing in place would).
#[cfg(unix)]
fn keep_permissions(target: &Path, tmp: &Path) -> io::Result<()> {
match std::fs::metadata(target) {
Ok(m) => std::fs::set_permissions(tmp, m.permissions()),
// A new file: the default permissions.
Err(_) => Ok(()),
}
}
#[cfg(not(unix))]
fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The other files in `d` besides `keep`.
fn leftovers(d: &Path, keep: &str) -> Vec<String> {
std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).filter(|n| n != keep).collect()
}
#[test]
fn the_original_file_survives_a_failed_write() {
let d = dir("fail");
let path = d.join("doc.vectorcraft");
std::fs::write(&path, b"original").unwrap();
let r = write_atomic_with(&path, |f| {
f.write_all(b"half a fi")?;
Err(io::Error::other("disk full"))
});
assert!(r.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert!(leftovers(&d, "doc.vectorcraft").is_empty(), "the temporary file is removed");
write_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert!(leftovers(&d, "doc.vectorcraft").is_empty());
// A folder that doesn't exist: an error, nothing written.
assert!(write_atomic(&d.join("missing").join("x.vectorcraft"), b"x").is_err());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn write_new_never_replaces_what_is_there() {
let d = dir("new");
let path = d.join("a.otf");
write_new_with(&path, |f| f.write_all(b"first")).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
assert!(leftovers(&d, "a.otf").is_empty(), "the temporary file is removed");
let r = write_new_with(&path, |f| f.write_all(b"second"));
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(std::fs::read(&path).unwrap(), b"first");
assert!(leftovers(&d, "a.otf").is_empty());
// A failed write leaves nothing.
let failed = d.join("b.otf");
assert!(write_new_with(&failed, |f| f.write_all(b"half").and(Err(io::Error::other("disk full")))).is_err());
assert!(!failed.exists() && leftovers(&d, "a.otf").is_empty());
let _ = std::fs::remove_dir_all(&d);
}
/// Where hard links fail (a file system without them), the content is copied in, and a taken
/// name stays as it was. A link that fails because the name is taken copies nothing.
#[test]
fn write_new_copies_where_hard_links_fail() {
let d = dir("new-copy");
let unsupported = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::Unsupported));
let path = d.join("c.otf");
write_new_linking(&path, |f| f.write_all(b"copied"), unsupported).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"copied");
assert!(leftovers(&d, "c.otf").is_empty(), "the temporary file is removed");
let r = write_new_linking(&path, |f| f.write_all(b"second"), unsupported);
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(std::fs::read(&path).unwrap(), b"copied");
let taken = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::AlreadyExists));
let other = d.join("d.otf");
let r = write_new_linking(&other, |f| f.write_all(b"other"), taken);
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert!(!other.exists() && leftovers(&d, "c.otf").is_empty());
let _ = std::fs::remove_dir_all(&d);
}
/// A link of that name, even one to nothing, is never replaced or written through.
#[cfg(unix)]
#[test]
fn write_new_leaves_links_alone() {
let d = dir("new-links");
let target = d.join("target.otf");
std::fs::write(&target, b"target").unwrap();
std::os::unix::fs::symlink(&target, d.join("link.otf")).unwrap();
std::os::unix::fs::symlink(d.join("nothing.otf"), d.join("dangling.otf")).unwrap();
for name in ["link.otf", "dangling.otf"] {
let r = write_new_with(&d.join(name), |f| f.write_all(b"new"));
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists), "{name}");
}
assert_eq!(std::fs::read(&target).unwrap(), b"target");
assert!(!d.join("nothing.otf").exists());
let mut names: Vec<String> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
names.sort();
assert_eq!(names, ["dangling.otf", "link.otf", "target.otf"], "no temporary file is left");
let _ = std::fs::remove_dir_all(&d);
}
}