Merge origin/main into #777

package.rs: the collector takes main's font lookup (fonts found by any of their names, in the
version the type names, a missing style shown in its closest one, files named by packaged_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-10 09:23:25 +07:00
co-authored by Claude Opus 5.5
161 changed files with 10705 additions and 624 deletions
+37 -16
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_text::embed::Embedding;
use super::*;
@@ -77,13 +78,17 @@ pub struct Section {
pub rows: Vec<(String, String)>,
}
/// What a font's `fsType` lets a copy of it do.
/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the
/// most permissive usage bit wins, and bitmap-only fonts may not be embedded.
pub fn embedding_label(fs_type: u16) -> &'static str {
match fs_type & 0x000f {
0x0002 => "embedding not allowed",
0x0004 => "embedding for preview and print",
0x0008 => "embedding for editing",
_ => "embedding allowed",
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
"embedding not allowed"
} else if fs_type & 0x0008 != 0 {
"embedding for editing"
} else if fs_type & 0x0004 != 0 {
"embedding for preview and print"
} else {
"embedding allowed"
}
}
@@ -92,7 +97,7 @@ type ImageRow = (String, u32, u32, Option<String>);
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
};
@@ -130,22 +135,32 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
"patterns" => names("patternNames"),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
"fontDetails" => {
let db = vectorcraft_text::FontDb::global();
fonts
.iter()
.map(|(family, style)| {
let detail = match db.face(family, style) {
Some(f) if f.family.eq_ignore_ascii_case(family) => {
.map(|used| {
let (family, style, version) = used;
// Found by any of its names (in the version the type names); a style the
// family lacks shows in another.
let resolved =
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
let detail = match resolved {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
let file =
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
format!("{file}; {}", embedding_label(f.fs_type()))
let found = format!("{file}; {}", embedding_label(f.fs_type()));
if m == vectorcraft_text::FontMatch::Style {
format!("substituted: shown in {} {} ({found})", f.family, f.style)
} else {
found
}
}
Some(f) => format!("missing: shown in {} {}", f.family, f.style),
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
None => "missing".into(),
};
(format!("{family} {style}"), detail)
(super::fonts::font_label(used), detail)
})
.collect()
}
@@ -232,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone()));
}
Some("textObjects")
}
@@ -289,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
});
}
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
let mut out = json!({
"document": document_summary(d),
"objects": counts,
@@ -396,6 +411,12 @@ mod tests {
assert_eq!(embedding_label(0x0002), "embedding not allowed");
assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print");
assert_eq!(embedding_label(0x0008), "embedding for editing");
// As exports read it: bitmap embedding only, the restricted bit with reserved bit 0, the
// most permissive of several usage bits.
assert_eq!(embedding_label(0x0200), "embedding not allowed");
assert_eq!(embedding_label(0x0003), "embedding not allowed");
assert_eq!(embedding_label(0x0006), "embedding for preview and print");
assert_eq!(embedding_label(0x000c), "embedding for editing");
}
#[test]
+22 -12
View File
@@ -1,6 +1,6 @@
//! File (document-level) and Edit commands, plus document queries.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -250,29 +250,39 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &json!({}), true);
}
// Copy promotes a compound member to its compound. Cut must remove the same objects it
// copied, while Clear on its own deletes exactly the selected members.
let ids = selected_roots(s)?;
copy(s, p)?;
clear(s, &json!({}))
if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) }
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// Direct-selected anchors: delete those anchors instead of whole objects.
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
// Selected ruler guides (selected on their own).
if ids_param(p, "ids").is_none() && s.doc()?.selection.is_empty() {
if explicit.is_none() && s.doc()?.selection.is_empty() {
return super::docmenu::guide_remove(s, &json!({}));
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let st = s.doc()?;
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
// Keep the selected ids themselves, without promoting compound members. Delete a selected
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
let targets: BTreeSet<_> = targets.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())).collect();
let ids: Vec<_> = targets
.iter()
.copied()
.filter(|id| !st.doc.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor)))
.collect();
if ids.is_empty() {
return ok();
}
s.edit("Clear", |d, sel| {
for id in &ids {
if d.node(*id).is_some_and(|n| n.is_layer()) {
continue;
}
let _ = d.remove(*id);
d.remove(*id)?;
}
sel.clear();
Ok(())
+7 -6
View File
@@ -137,11 +137,12 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings);
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
// The note that the art outside the artboard is lost is the reason the layers weren't read.
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
return (doc, warnings);
@@ -151,10 +152,10 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
}
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers are
/// off), an error when it can't be read.
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers or the
/// editing data are off), an error when it can't be read.
fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(Document, Vec<String>)>> {
if opts.pages.is_some() || !opts.layers {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return None;
}
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
@@ -178,7 +179,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
restore_or_import(editing, || {
let r = vectorcraft_eps::import(bytes).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
Ok((r.document, r.warnings))
})?
}
+22 -1
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@@ -725,6 +725,22 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never
/// replaced. When the write fails part way, the file it started is removed.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
drop(f);
written.map_err(|e| {
// Best effort: the partly written file is all there is to clean up.
let _ = std::fs::remove_file(path);
err(e)
})
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path`.
#[cfg(not(target_arch = "wasm32"))]
@@ -786,6 +802,11 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
+12 -2
View File
@@ -32,6 +32,10 @@ pub struct LoadOptions {
pub layers: bool,
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
pub dxf: vectorcraft_cad::ImportOptions,
/// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its
/// layers, hidden objects and the art outside its artboards); else only what its page or PDF
/// part prints comes in.
pub editing_data: bool,
}
impl Default for LoadOptions {
@@ -45,6 +49,7 @@ impl Default for LoadOptions {
text_as: TextAs::default(),
layers: true,
dxf: Default::default(),
editing_data: true,
}
}
}
@@ -52,7 +57,8 @@ impl Default for LoadOptions {
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false) and `dxf`
/// (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -75,8 +81,12 @@ impl LoadOptions {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
let editing_data = match p.get("editingData").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true or false"))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+12
View File
@@ -396,3 +396,15 @@ fn oversized_raster_exports_are_refused() {
assert!(e.contains("too large"), "{e}");
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
}
#[test]
fn new_files_never_replace_a_file() {
let d = tmp_dir("new-file");
let path = d.join("Brand.ase").to_string_lossy().to_string();
let _ = std::fs::remove_file(&path);
write_new_file(&path, b"first").unwrap();
let e = write_new_file(&path, b"second").unwrap_err().to_string();
assert!(e.starts_with(&format!("{path}: ")), "{e}");
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let _ = std::fs::remove_dir_all(d);
}
@@ -108,3 +108,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();
for (name, file) in [("art.eps", ai::eps(&ai::sample_data(), ai::page_ps())), ("art.ai", ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()))]
{
let r = open(&mut s, name, &file, json!({"editingData": false})).unwrap();
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "{name}: {r}");
assert_ne!(names(layers), ["Art"], "{name}: the page's layer");
}
let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string();
assert!(e.contains("editingData must be true or false"), "{e}");
// A file that is only its editing data can't open without it.
let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() {
// A shown square; a hidden one and a long guide far from it, which the page doesn't draw.
let art =
format!("0 g\n{}f\n1 Xw\n{}f\n0 Xw\n-500 20 m\n700 20 L\n(N) *\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(150.0, 60.0, 190.0, 95.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let mut s = Session::new();
let r = open(&mut s, "fit.eps", &ai::eps(data.as_bytes(), "0 setgray 10 10 40 40 rectfill\n"), json!({})).unwrap();
let layer = &s.doc().unwrap().doc.layers[0];
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 3, "{r}");
assert!(!kids[1].visible && matches!(kids[2].kind, NodeKind::Path { guide: true, .. }));
s.execute("artboard.fitToArt", &json!({})).unwrap();
// The square, 50 to 90 down the 100-point page.
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 50.0, 50.0, 90.0));
}
#[test]
fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part() {
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{text}", ai::rect(10.0, 10.0, 50.0, 50.0))));
let file = ai::ai(&compressed(data.as_bytes()), "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET");
let mut s = Session::new();
// As type, the page's type goes into the text object of the layers.
let r = open(&mut s, "type.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
// As outlines, only the PDF part has them.
let r = open(&mut s, "type.ai", &file, json!({"textAs": "outlines"})).unwrap();
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
+17
View File
@@ -0,0 +1,17 @@
//! Looking through a folder the user picks, and its subfolders, for files of some kind, on separate
//! threads: up to 8 walker threads share one stack of folders and list each folder once. They
//! follow no links and don't enter other apps' or the system's folders, hidden folders or folders
//! whose contents aren't on the disk ([`Rules`]); the time, the entries listed, the folders queued,
//! the depth and the files read are capped ([`Limits`]). [`start`] returns at once,
//! [`Search::progress`] reports where a search stands and [`Search::stop`] ends it. A search reads
//! the files [`Visitor::wants`] accepts by their names and [`Visitor::worth_reading`] by the files
//! themselves, and keeps those [`Visitor::items`] gives wanted items for.
//! The web has no file system: [`start`] fails there.
mod rules;
#[cfg(test)]
mod tests;
mod walk;
pub use rules::{Rules, plain};
pub use walk::{End, Limits, Progress, Search, Visitor, start, threads};
+409
View File
@@ -0,0 +1,409 @@
//! Where a folder search goes: not into other apps' or the system's folders. They are told by
//! their names and places (an app package, a home folder's `Library` or `AppData`, the system
//! folders at the root of a volume), never by the name of an app. Names and paths compare in
//! lowercase.
use std::path::{Component, Path, PathBuf};
/// Extensions of the packages apps and media libraries keep their files in.
const PACKAGE_EXTENSIONS: &[&str] = &[
"app",
"appex",
"bundle",
"component",
"fcpbundle",
"framework",
"imovielibrary",
"kext",
"musiclibrary",
"photolibrary",
"photoslibrary",
"plugin",
"tvlibrary",
"xpc",
];
/// The folders Windows installs programs and keeps their data and the system's in, wherever they
/// are.
const INSTALL_FOLDERS: &[&str] =
&["program files", "program files (x86)", "program files (arm)", "programdata", "$recycle.bin", "system volume information"];
/// The folders a home folder keeps apps and their data in (besides its hidden ones).
const HOME_APP_FOLDERS: &[&str] = &["library", "appdata", "applications", "snap"];
/// The folders a `Library` folder keeps apps' data in (macOS).
const LIBRARY_APP_FOLDERS: &[&str] = &["application support", "containers", "group containers", "caches", "preferences"];
/// The folders beside the home folders that apps share (macOS `Shared`, Windows `Public`).
const SHARED_FOLDERS: &[&str] = &["shared", "public"];
/// The system's folders at the root of a macOS volume (or a copy of one).
const MAC_SYSTEM: &[&str] =
&["applications", "bin", "cores", "dev", "etc", "home", "library", "net", "network", "opt", "private", "sbin", "system", "tmp", "usr", "var"];
/// The system's folders at the root of a Linux or BSD system (or a copy of one).
const UNIX_SYSTEM: &[&str] = &[
"bin",
"boot",
"dev",
"efi",
"etc",
"gnu",
"lib",
"lib32",
"lib64",
"libx32",
"lost+found",
"nix",
"opt",
"proc",
"root",
"run",
"sbin",
"snap",
"sys",
"usr",
"var",
];
/// The system's folders at the root of a Windows drive (or a copy of one), besides the install
/// folders.
const WINDOWS_SYSTEM: &[&str] =
&["windows", "windows.old", "recovery", "perflogs", "boot", "config.msi", "msocache", "$windows.~bt", "$windows.~ws", "$winreagent", "$sysreset"];
/// The most entries of a folder read to tell what kind of folder it is.
const MAX_SIBLINGS: usize = 100_000;
/// The names of the folders that show a folder is the root of a system ([`system_folders`]).
const SYSTEM_SIGNS: &[&str] = &["users", "system", "library", "applications", "windows", "program files", "etc", "usr", "bin", "sbin"];
/// Which of [`SYSTEM_SIGNS`] are among the names of a folder's folders and links.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Siblings(u16);
impl Siblings {
/// The folders and links named `names` (in any case).
#[cfg(test)]
pub(crate) fn of(names: &[&str]) -> Self {
let mut s = Self::default();
names.iter().for_each(|n| s.note(n));
s
}
/// Note a folder or link named `name` (in any case).
pub(crate) fn note(&mut self, name: &str) {
if let Some(i) = SYSTEM_SIGNS.iter().position(|s| s.eq_ignore_ascii_case(name)) {
self.0 |= 1 << i;
}
}
/// Whether a folder or link named `name`, one of [`SYSTEM_SIGNS`], was noted.
fn has(self, name: &str) -> bool {
SYSTEM_SIGNS.iter().position(|s| *s == name).is_some_and(|i| self.0 & (1 << i) != 0)
}
}
/// Why a search may not start in a folder ([`Rules::refusal`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
/// The folder is in other apps' or the system's folders, or in an app package.
Apps,
/// The folder is in the Shared or Public folder of a `Users` folder.
Shared,
}
/// A folder a walk lists, as the rules see it ([`Rules::listed`]).
pub(crate) struct Listed {
/// The folder, in lowercase.
dir: PathBuf,
/// Its name, in lowercase.
name: String,
/// The system folders in it, when it is the root of a system.
system: Option<&'static [&'static str]>,
/// It is a home folder.
home: bool,
}
/// Where a search may go.
#[derive(Clone, Debug, Default)]
pub struct Rules {
/// The home folder (canonical).
pub home: Option<PathBuf>,
/// Folders searched when picked, whatever holds them (canonical): the temporary folders, and
/// tests' folders.
pub user_content: Vec<PathBuf>,
/// Other apps' and the system's folders the environment names (canonical): never entered, and a
/// search of a folder inside one fails.
pub refused: Vec<PathBuf>,
}
/// `path` in lowercase and, on Windows, without its `\\?\` prefix ([`unprefixed`]), for comparing
/// paths.
fn lower(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
let plain = if cfg!(windows) { unprefixed(&text) } else { None };
PathBuf::from(plain.as_deref().unwrap_or(&text).to_lowercase())
}
/// The system's folders a volume's root holds, when its folders (`siblings`) show it is the root of
/// an operating system.
fn system_folders(siblings: Siblings) -> Option<&'static [&'static str]> {
let has = |name: &str| siblings.has(name);
if has("users") && (has("system") || has("library") || has("applications")) {
Some(MAC_SYSTEM)
} else if has("windows") && (has("users") || has("program files")) {
Some(WINDOWS_SYSTEM)
} else if has("etc") && has("usr") && (has("bin") || has("sbin")) {
Some(UNIX_SYSTEM)
} else {
None
}
}
/// What the names of the folders and links in `dir` show ([`Siblings`]).
fn folder_siblings(dir: &Path) -> Siblings {
let mut siblings = Siblings::default();
for e in std::fs::read_dir(dir).into_iter().flatten().take(MAX_SIBLINGS).flatten() {
let name = e.file_name();
if let Some(name) = name.to_str()
&& e.file_type().is_ok_and(|t| t.is_dir() || t.is_symlink())
{
siblings.note(name);
}
}
siblings
}
/// The first `n` components of `path`.
fn prefix(path: &Path, n: usize) -> PathBuf {
path.components().take(n).collect()
}
/// `path` without the `\\?\` prefix that `std::fs::canonicalize` gives paths on Windows: `C:\…`
/// for `\\?\C:\…` and `\\server\share\…` for `\\?\UNC\server\share\…`, when every name in it can
/// be written without the prefix ([`without_verbatim`]). Other paths, and paths on other systems,
/// are returned as they are.
pub fn plain(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
match path.to_str().and_then(without_verbatim) {
Some(p) => PathBuf::from(p),
None => path,
}
}
/// The Windows path `p` without its `\\?\` prefix, when it has one for a drive or a share: `C:\…`
/// for `\\?\C:\…` and `\\server\…` for `\\?\UNC\server\…`.
pub(crate) fn unprefixed(p: &str) -> Option<String> {
if let Some(unc) = p.strip_prefix(r"\\?\UNC\") {
return Some(format!(r"\\{unc}"));
}
let disk = p.strip_prefix(r"\\?\")?;
matches!(disk.as_bytes(), [d, b':', b'\\', ..] if d.is_ascii_alphabetic()).then(|| disk.to_string())
}
/// [`unprefixed`], when each name in the path can be written without the prefix ([`portable`]):
/// a share's server and share names, and the names below a drive's root or a share.
pub(crate) fn without_verbatim(p: &str) -> Option<String> {
let plain = unprefixed(p)?;
let (names, at_least) = match plain.strip_prefix(r"\\") {
Some(unc) => (unc, 2),
None => (plain.get(3..)?, 0),
};
let mut names: Vec<&str> = if names.is_empty() { vec![] } else { names.split('\\').collect() };
// A trailing separator.
if names.len() > at_least && names.last() == Some(&"") {
names.pop();
}
let ok = names.len() >= at_least && names.iter().all(|n| portable(n));
ok.then_some(plain)
}
/// Whether `name` can be a name in a Windows path without the `\\?\` prefix: not `.` or `..`, not
/// ending in a dot or a space, without `<>:"/\|?*` or control characters, and not a device name
/// (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, these with a superscript digit,
/// `CONIN$`, `CONOUT$`), with an extension or not.
fn portable(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name).trim_end().to_ascii_lowercase();
let numbered = |device: &str| {
stem.strip_prefix(device).is_some_and(|n| {
let mut n = n.chars();
n.next().is_some_and(|c| c.is_ascii_digit() || "¹²³".contains(c)) && n.next().is_none()
})
};
let device = ["con", "prn", "aux", "nul", "conin$", "conout$"].contains(&stem.as_str()) || numbered("com") || numbered("lpt");
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.ends_with(['.', ' '])
&& !name.chars().any(|c| c < ' ' || "<>:\"/\\|?*".contains(c))
&& !device
}
impl Rules {
/// This computer's: the home folder, the temporary folders, and the app and system folders the
/// environment names.
pub fn system() -> Self {
let canonical = |p: PathBuf| p.is_absolute().then(|| std::fs::canonicalize(&p).ok()).flatten();
let var = |name: &str| std::env::var_os(name).map(PathBuf::from).and_then(canonical);
let home = var(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
let user_content = [std::env::temp_dir(), PathBuf::from("/tmp")].into_iter().filter_map(canonical).collect();
let names: &[&str] = if cfg!(windows) {
&["APPDATA", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "SystemRoot"]
} else {
&[
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
"HOST_XDG_CONFIG_HOME",
"HOST_XDG_DATA_HOME",
"HOST_XDG_CACHE_HOME",
"HOST_XDG_STATE_HOME",
]
};
let refused = names.iter().filter_map(|n| var(n)).collect();
Rules { home, user_content, refused }
}
/// Whether `dir` is a home folder: the home folder, a folder in a `Users` or `home` folder, or
/// a folder the Flatpak document portal exports (`/run/user/<uid>/doc/<id>/<name>`).
fn is_home(&self, dir: &Path) -> bool {
if self.home.as_ref().is_some_and(|h| lower(h) == lower(dir)) {
return true;
}
let above = dir.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().to_lowercase());
if matches!(above.as_deref(), Some("users" | "home")) {
return true;
}
let parts: Vec<String> = dir.components().map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect();
matches!(parts.as_slice(), [_, run, user, uid, doc, _, _] if run == "run" && user == "user" && is_number(uid) && doc == "doc")
}
/// The folder `dir`, whose folders show `siblings`, as the rules see it when a walk lists it:
/// told once for all the folders in it.
pub(crate) fn listed(&self, dir: &Path, siblings: Siblings) -> Listed {
Listed {
dir: lower(dir),
name: dir.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(),
system: system_folders(siblings),
home: self.is_home(dir),
}
}
/// Whether a search never enters the folder `name` in the listed folder `parent`: an app's
/// package, a Windows install folder, a system folder at the root of a volume, the folders apps
/// share beside the home folders, a `Library` folder's app data, or a home folder's hidden
/// folders and app folders.
pub(crate) fn never_in(&self, parent: &Listed, name: &str) -> bool {
let name = name.to_lowercase();
let name = name.as_str();
if Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(|e| PACKAGE_EXTENSIONS.contains(&e)) || INSTALL_FOLDERS.contains(&name) {
return true;
}
if parent.system.is_some_and(|system| system.contains(&name)) {
return true;
}
match parent.name.as_str() {
"users" if SHARED_FOLDERS.contains(&name) => return true,
"library" if LIBRARY_APP_FOLDERS.contains(&name) => return true,
_ => {}
}
parent.home && (name.starts_with('.') || HOME_APP_FOLDERS.contains(&name))
}
/// [`Self::never_in`] the folder `parent`, whose folders show `siblings`.
#[cfg(test)]
pub(crate) fn never(&self, parent: &Path, siblings: Siblings, name: &str) -> bool {
self.never_in(&self.listed(parent, siblings), name)
}
/// The home folder, when the listed folder `parent` is the root of a system whose rules skip
/// the folder the home folder is in (`/usr/home/me` on FreeBSD, `/var/home/me`): a walk of the
/// root reaches it this way.
pub(crate) fn home_below(&self, parent: &Listed) -> Option<PathBuf> {
let home = self.home.as_ref()?;
let system = parent.system?;
let below = lower(home).strip_prefix(&parent.dir).ok()?.components().next()?.as_os_str().to_string_lossy().into_owned();
system.contains(&below.as_str()).then(|| home.clone())
}
/// Whether `path` is one of the refused folders.
pub(crate) fn is_refused(&self, path: &Path) -> bool {
let path = lower(path);
self.refused.iter().any(|r| lower(r) == path)
}
/// The folders that hold user content whatever holds them, as far as they hold `folder`: the
/// home folder, [`Self::user_content`], the cloud storage folders in the home folder's
/// `Library` (an app's own iCloud `Documents` folder too), removable media and a user's runtime
/// folder on Linux (network shares, the document portal).
fn bases(&self, folder: &Path) -> Vec<PathBuf> {
let mut v = self.user_content.clone();
let lowered = lower(folder);
if let Some(home) = &self.home {
v.push(home.clone());
let library = home.join("Library");
v.push(library.join("CloudStorage"));
let mobile = library.join("Mobile Documents");
v.push(mobile.join("com~apple~CloudDocs"));
let depth = mobile.components().count();
if lowered.starts_with(lower(&mobile)) && lowered.components().nth(depth + 1).is_some_and(|c| c.as_os_str() == "documents") {
v.push(prefix(folder, depth + 2));
}
}
v.push(PathBuf::from("/run/media"));
let parts: Vec<String> = folder.components().take(4).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect();
if matches!(parts.as_slice(), [_, run, user, uid] if run == "run" && user == "user" && is_number(uid)) {
v.push(prefix(folder, 4));
}
v
}
/// Why a search may not start in `folder` (canonical), or `None` when it may: the deepest base
/// ([`Self::bases`]) or refused folder that holds it is a refused one, or a folder below that
/// base (or below the root, without one) is one a walk doesn't enter.
pub(crate) fn refusal(&self, folder: &Path) -> Option<Refusal> {
self.refusal_with(folder, folder_siblings)
}
/// [`Self::refusal`] without listing a folder: the root of a system, told by the folders it
/// holds, isn't recognized.
pub(crate) fn refusal_unlisted(&self, folder: &Path) -> Option<Refusal> {
self.refusal_with(folder, |_| Siblings::default())
}
/// [`Self::refusal`], with `siblings` telling what a folder's folders show.
fn refusal_with(&self, folder: &Path, siblings: impl Fn(&Path) -> Siblings) -> Option<Refusal> {
let lowered = lower(folder);
let held = |p: &PathBuf| lowered.starts_with(lower(p)).then(|| p.components().count());
let base = self.bases(folder).iter().filter_map(held).max();
let refused = self.refused.iter().filter_map(held).max();
let start = match (base, refused) {
(_, Some(r)) if base.is_none_or(|b| r >= b) => return Some(Refusal::Apps),
(Some(b), _) => b,
// From the root of the folder's volume.
(None, _) => folder.components().take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)).count(),
};
let mut parent = prefix(folder, start);
for c in folder.components().skip(start) {
let child = parent.join(c);
let listed = self.listed(&parent, siblings(&parent));
let name = c.as_os_str().to_string_lossy().to_lowercase();
if listed.name == "users" && SHARED_FOLDERS.contains(&name.as_str()) {
return Some(Refusal::Shared);
}
if self.never_in(&listed, &name) || self.is_refused(&child) {
return Some(Refusal::Apps);
}
parent = child;
}
None
}
}
/// Whether `s` is a number (a user id).
fn is_number(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
+545
View File
@@ -0,0 +1,545 @@
//! The folder search: where it goes and where it doesn't, its limits, its threads.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use super::rules::{Siblings, unprefixed, without_verbatim};
use super::walk::{Pool, start_in};
use super::*;
/// The walker threads of these tests' searches, apart from the searches of other tests.
static TESTS: Pool = Pool::new(32);
/// [`start`] on the threads of [`TESTS`].
fn begin(folder: PathBuf, rules: Rules, limits: Limits, threads: usize, items: usize, visitor: Arc<dyn Visitor>) -> Search {
start_in(&TESTS, folder, Some(rules), limits, threads, items, visitor).unwrap()
}
/// Reads `.ttf` files; each is the item its name is in the list.
struct Names(&'static [&'static str]);
impl Visitor for Names {
fn wants(&self, name: &str) -> bool {
name.ends_with(".ttf")
}
fn items(&self, path: &Path) -> Vec<usize> {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
self.0.iter().position(|n| *n == name).into_iter().collect()
}
}
/// Reads every file, and waits on reading one until its channel sends or closes.
struct Blocked(Mutex<mpsc::Receiver<()>>);
impl Visitor for Blocked {
fn wants(&self, _: &str) -> bool {
true
}
fn items(&self, _: &Path) -> Vec<usize> {
let _ = self.0.lock().unwrap().recv();
vec![]
}
}
/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where
/// they end in `/`.
fn tree(tag: &str, paths: &[&str]) -> PathBuf {
let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
for p in paths {
let path = root.join(p);
if p.ends_with('/') {
std::fs::create_dir_all(&path).unwrap();
} else {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"font").unwrap();
}
}
plain(std::fs::canonicalize(&root).unwrap())
}
/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder.
fn rules(root: &Path) -> Rules {
Rules { home: Some(root.join("Users/me")), user_content: vec![root.to_path_buf()], refused: vec![root.join("refused")] }
}
/// The search's progress once it ended (or after 10 seconds).
fn wait(s: &Search) -> Progress {
let until = Instant::now() + Duration::from_secs(10);
loop {
let p = s.progress();
if p.end.is_some() || Instant::now() > until {
return p;
}
std::thread::sleep(Duration::from_millis(2));
}
}
/// Search `from` in `root` for the files named `names`, to the end.
fn search(root: &Path, from: &str, limits: Limits, threads: usize, names: &'static [&'static str]) -> Progress {
let s = begin(root.join(from), rules(root), limits, threads, names.len(), Arc::new(Names(names)));
wait(&s)
}
/// The files a search found, relative to `root`, sorted.
fn found(root: &Path, p: &Progress) -> Vec<String> {
let mut v: Vec<String> = p.hits.iter().map(|(f, _)| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect();
v.sort();
v
}
#[test]
fn the_walk_stays_out_of_other_apps_and_the_systems_folders() {
let root = tree(
"roles",
&[
"a/b/c/one.ttf",
"Users/me/Documents/two.ttf",
"Disk/Fonts/four.ttf",
"Disk/Users/you/Documents/four.ttf",
// Never entered: each of these folders counts as skipped once.
"Program Files/x/three.ttf",
"Program Files (Arm)/x/three.ttf",
"Users/me/Library/Fonts/three.ttf",
"Users/me/.cache/three.ttf",
"Users/me/AppData/Roaming/three.ttf",
"Users/you/snap/three.ttf",
"Users/Shared/x/three.ttf",
"Pictures/P.photoslibrary/three.ttf",
"Tools/Foo.app/Contents/three.ttf",
"Copy/Library/Application Support/x/three.ttf",
".hidden/three.ttf",
"refused/three.ttf",
"Disk/System/Library/Fonts/three.ttf",
"Disk/Library/Fonts/three.ttf",
"Disk/Applications/three.ttf",
"Disk/Users/you/Library/three.ttf",
],
);
let p = search(&root, "", Limits::default(), 3, &["one.ttf", "two.ttf", "three.ttf", "four.ttf"]);
assert_eq!(p.end, Some(End::Done));
assert_eq!(found(&root, &p), ["Disk/Fonts/four.ttf", "Disk/Users/you/Documents/four.ttf", "Users/me/Documents/two.ttf", "a/b/c/one.ttf"]);
assert_eq!(p.skipped, 16);
assert_eq!((p.read, p.unreadable), (4, 0));
}
#[test]
fn a_picked_folder_inside_other_apps_folders_is_refused() {
let root = tree(
"picks",
&[
"Users/me/Library/Fonts/",
"Users/me/Library/CloudStorage/Box/F/",
"Users/me/Library/Mobile Documents/x~y/Documents/",
"Users/me/Library/Mobile Documents/x~y/Other/",
"Users/me/.config/fonts/",
"Users/Shared/x/",
"Disk/Users/",
"Disk/System/",
"Disk/Library/Fonts/",
"Tools/Foo.app/Contents/",
"refused/sub/",
"Program Files/x/",
"Program Files (Arm)/x/",
"Users/Public/x/",
"a/.b/",
"file.ttf",
],
);
for picked in [
"Users/me/Library/Fonts",
"Users/me/Library/Mobile Documents/x~y/Other",
"Users/me/.config/fonts",
"Disk/Library/Fonts",
"Tools/Foo.app/Contents",
"refused",
"refused/sub",
"Program Files/x",
"Program Files (Arm)/x",
] {
let p = search(&root, picked, Limits::default(), 1, &[]);
assert_eq!(p.state(), "failed", "{picked}");
assert!(p.error().is_some_and(|e| e.contains("doesn't search other apps' or the system's folders")), "{picked}: {p:?}");
}
for picked in ["Users/Shared/x", "Users/Public/x"] {
let p = search(&root, picked, Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.contains("doesn't search the Shared or Public folder in Users")), "{picked}: {p:?}");
}
// Cloud folders, a hidden folder of the user's and another system's root are searched when
// picked.
for picked in ["Users/me/Library/CloudStorage/Box/F", "Users/me/Library/Mobile Documents/x~y/Documents", "a/.b", "Disk", "Disk/Users", ""] {
let p = search(&root, picked, Limits::default(), 1, &[]);
assert_eq!((p.state(), p.error()), ("done", None), "{picked}");
}
let p = search(&root, "file.ttf", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
let p = search(&root, "missing", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
}
#[test]
fn the_search_ends_at_its_limits_and_when_stopped() {
let root = tree("limits", &["one.ttf", "two.ttf", "a/b/c/three.ttf", "x/y.txt"]);
let names = &["one.ttf", "two.ttf", "three.ttf", "nowhere.ttf"];
// The picked folder and the folders in it are listed, not the ones below them.
let p = search(&root, "", Limits { depth: 1, ..Limits::default() }, 2, names);
assert_eq!(p.end, Some(End::Done));
assert_eq!(found(&root, &p), ["one.ttf", "two.ttf"]);
assert_eq!((p.folders, p.skipped), (3, 1));
let p = search(&root, "", Limits { entries: 3, ..Limits::default() }, 1, names);
assert_eq!((p.state(), p.stopped()), ("stopped", Some("limit")));
let p = search(&root, "", Limits { files: 1, ..Limits::default() }, 1, names);
assert_eq!((p.stopped(), p.read), (Some("limit"), 1));
let p = search(&root, "", Limits { hits: 1, ..Limits::default() }, 1, names);
assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 1));
// Each item keeps at most `per_item` files.
let root = tree("per-item", &["a/one.ttf", "b/one.ttf", "c/one.ttf"]);
let p = search(&root, "", Limits { per_item: 2, ..Limits::default() }, 1, &["one.ttf", "nowhere.ttf"]);
assert_eq!((p.end, p.hits.len(), p.read), (Some(End::Done), 2, 3));
}
#[test]
fn the_search_answers_at_its_deadline_while_a_walker_is_blocked() {
let root = tree("blocked", &["f.ttf"]);
let (tx, rx) = mpsc::channel();
let s = begin(root.clone(), rules(&root), Limits { seconds: 0.2, ..Limits::default() }, 1, 1, Arc::new(Blocked(Mutex::new(rx))));
std::thread::sleep(Duration::from_millis(300));
let p = s.progress();
assert_eq!((p.state(), p.stopped()), ("stopped", Some("time")), "{p:?}");
assert!(p.seconds >= 0.2 && p.seconds < 5.0, "{p:?}");
let (tx2, rx2) = mpsc::channel();
let s2 = begin(root.clone(), rules(&root), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx2))));
let until = Instant::now() + Duration::from_secs(10);
while s2.progress().read == 0 && Instant::now() < until {
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(s2.progress().state(), "searching");
s2.stop();
assert_eq!(s2.progress().stopped(), Some("stop"));
drop((tx, tx2));
}
/// Every file is item 0; reading the second file waits until the channel closes.
struct SecondBlocks(Mutex<(usize, mpsc::Receiver<()>)>);
impl Visitor for SecondBlocks {
fn wants(&self, _: &str) -> bool {
true
}
fn items(&self, _: &Path) -> Vec<usize> {
let mut read = self.0.lock().unwrap();
read.0 += 1;
if read.0 == 2 {
let _ = read.1.recv();
}
vec![0]
}
}
/// A file is kept as soon as it is read: progress shows it while the rest of its folder is read,
/// and a search that stops or reaches a limit keeps it.
#[test]
fn files_found_are_kept_as_they_are_read() {
let root = tree("kept", &["a.ttf", "b.ttf", "c.ttf"]);
let (tx, rx) = mpsc::channel();
let s = begin(root.clone(), rules(&root), Limits::default(), 1, 2, Arc::new(SecondBlocks(Mutex::new((0, rx)))));
let until = Instant::now() + Duration::from_secs(10);
while s.progress().read < 2 && Instant::now() < until {
std::thread::sleep(Duration::from_millis(2));
}
let p = s.progress();
assert_eq!((p.state(), p.hits.len()), ("searching", 1), "{p:?}");
s.stop();
drop(tx);
let p = wait(&s);
assert_eq!(p.stopped(), Some("stop"));
assert!(!p.hits.is_empty(), "{p:?}");
let p = search(&root, "", Limits { files: 2, ..Limits::default() }, 1, &["a.ttf", "b.ttf", "c.ttf", "nowhere.ttf"]);
assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 2), "{p:?}");
}
#[test]
fn the_search_ends_once_every_item_has_a_file() {
let root = tree("found", &["a/one.ttf", "b/two.ttf", "c/d/e/f/g/other.ttf"]);
let p = search(&root, "", Limits::default(), 2, &["one.ttf", "two.ttf"]);
assert_eq!((p.end.clone(), found(&root, &p)), (Some(End::Found), vec!["a/one.ttf".to_string(), "b/two.ttf".into()]));
}
#[test]
fn one_walker_and_eight_find_the_same_files_and_every_search_ends() {
let mut paths = vec![];
for a in 0..6 {
for b in 0..4 {
paths.push(format!("d{a}/e{b}/one.ttf"));
paths.push(format!("d{a}/e{b}/f/two.ttf"));
paths.push(format!("d{a}/e{b}/f/g/skip.txt"));
}
}
let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
let root = tree("threads", &paths);
let limits = Limits { per_item: 100, ..Limits::default() };
let one = search(&root, "", limits, 1, &["one.ttf", "two.ttf", "nowhere.ttf"]);
assert_eq!((one.end.clone(), one.hits.len(), one.folders), (Some(End::Done), 48, 1 + 6 + 24 + 24 + 24));
for round in 0..25 {
for threads in 1..=8 {
let p = search(&root, "", limits, threads, &["one.ttf", "two.ttf", "nowhere.ttf"]);
assert_eq!(p.end, Some(End::Done), "round {round}, {threads} threads");
assert_eq!(found(&root, &p), found(&root, &one), "round {round}, {threads} threads");
}
}
}
/// Fails on `boom.ttf` with a panic: a bug in reading one file.
struct Boom;
impl Visitor for Boom {
fn wants(&self, name: &str) -> bool {
name.ends_with(".ttf")
}
fn items(&self, path: &Path) -> Vec<usize> {
match path.file_name().and_then(|n| n.to_str()) {
Some("boom.ttf") => panic!("a bug"),
Some("ok.ttf") => vec![0],
_ => vec![1],
}
}
}
#[test]
fn a_visitor_that_panics_on_one_file_does_not_end_the_search() {
// The file beside the one that trips the bug is found too.
let root = tree("boom", &["a/boom.ttf", "a/ok.ttf", "b/c/fine.ttf"]);
let s = begin(root.clone(), rules(&root), Limits::default(), 2, 3, Arc::new(Boom));
let p = wait(&s);
assert_eq!((p.end.clone(), found(&root, &p), p.unreadable), (Some(End::Done), vec!["a/ok.ttf".to_string(), "b/c/fine.ttf".into()], 1));
}
#[cfg(unix)]
#[test]
fn links_are_not_followed_and_unreadable_folders_count() {
use std::os::unix::fs::{PermissionsExt, symlink};
let root = tree("links", &["outside/x.ttf", "inner/locked/y.ttf"]);
let inner = root.join("inner");
symlink(root.join("outside"), inner.join("folder")).unwrap();
symlink(&inner, inner.join("loop")).unwrap();
symlink(root.join("outside/x.ttf"), inner.join("x.ttf")).unwrap();
let locked = inner.join("locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
// Where permissions don't hold (root), the folder reads as any other.
let enforced = std::fs::read_dir(&locked).is_err();
let p = search(&root, "inner", Limits::default(), 2, &["x.ttf", "y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(p.end, Some(End::Done));
if enforced {
assert!(p.hits.is_empty() && p.unreadable == 1, "{p:?}");
} else {
assert_eq!(found(&root, &p), ["inner/locked/y.ttf"]);
}
// The linked file isn't a file of the folder; y.ttf is one where the locked folder reads.
assert_eq!(p.files, u64::from(!enforced), "{p:?}");
}
#[cfg(unix)]
#[test]
fn a_picked_folder_that_cant_be_listed_fails_the_search() {
use std::os::unix::fs::PermissionsExt;
let root = tree("unlistable", &["locked/y.ttf"]);
let locked = root.join("locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
// Where permissions don't hold (root), the folder reads as any other.
let enforced = std::fs::read_dir(&locked).is_err();
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
if enforced {
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
} else {
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
}
}
#[test]
fn system_roots_and_homes_are_told_by_what_they_hold() {
let r = Rules { home: Some(PathBuf::from("/home/me")), ..Rules::default() };
let never = |parent: &str, siblings: Siblings, name: &str| r.never(Path::new(parent), siblings, name);
let none = Siblings::default();
let mac = Siblings::of(&["Users", "Library", "System", "Applications", "Volumes", "opt"]);
for name in ["Library", "System", "Applications", "opt", "private"] {
assert!(never("/", mac, name), "{name}");
}
for name in ["Users", "Volumes", "Fonts"] {
assert!(!never("/", mac, name), "{name}");
}
let unix = Siblings::of(&["usr", "etc", "bin", "home", "nix", "srv", "data", "mnt"]);
for name in ["usr", "etc", "bin", "nix", "var", "proc"] {
assert!(never("/", unix, name), "{name}");
}
for name in ["home", "srv", "data", "mnt", "media"] {
assert!(!never("/", unix, name), "{name}");
}
let windows = Siblings::of(&["Windows", "Users", "Program Files", "Data"]);
for name in ["Windows", "Program Files", "Recovery"] {
assert!(never("C:\\", windows, name), "{name}");
}
for name in ["Data", "Users"] {
assert!(!never("C:\\", windows, name), "{name}");
}
// A project that holds a bin and an etc folder isn't the root of a system.
let project = Siblings::of(&["bin", "etc", "src"]);
assert!(!never("/work/p", project, "bin") && !never("/work/p", project, "etc"));
// Home folders: the home folder, and every folder in a Users or home folder.
for name in ["Library", ".config", "snap", "AppData", "Applications"] {
assert!(never("/home/me", none, name), "{name}");
assert!(never("/Users/you", none, name), "{name}");
}
assert!(!never("/home/me", none, "Documents") && !never("/home/me/Documents", none, "Library"));
assert!(never("/run/user/1000/doc/a1b2/me", none, "snap"), "a folder the document portal exports");
assert!(never("/Users", none, "Shared") && never("/Users", none, "Public"));
assert!(never("/Volumes/Copy/Library", none, "Application Support") && !never("/Volumes/Copy/Library", none, "Fonts"));
assert!(never("/x", none, "Foo.app") && never("/x", none, "Program Files (x86)") && !never("/x", none, "Fonts.d"));
assert!(never("/x", none, "Program Files (Arm)"));
// A system root's walk reaches a home folder below a folder it skips (FreeBSD's /usr/home).
let bsd = Rules { home: Some(PathBuf::from("/usr/home/me")), ..Rules::default() };
let root = Path::new("/");
assert_eq!(bsd.home_below(&bsd.listed(root, unix)), Some(PathBuf::from("/usr/home/me")));
assert_eq!(r.home_below(&r.listed(root, unix)), None);
assert_eq!(bsd.home_below(&bsd.listed(root, project)), None);
}
#[test]
fn a_search_starts_with_the_threads_left_and_fails_when_none_is() {
static POOL: Pool = Pool::new(3);
let root = tree("pool", &["f.ttf"]);
let blocked = || {
let (tx, rx) = mpsc::channel();
(tx, Arc::new(Blocked(Mutex::new(rx))) as Arc<dyn Visitor>)
};
let (tx_a, a) = blocked();
let (tx_b, b) = blocked();
let a = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 2, 1, a).unwrap();
let b = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 4, 1, b).unwrap();
assert_eq!(POOL.live(), 3);
let (_, c) = blocked();
let c = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 1, 1, c);
assert_eq!(c.err().as_deref(), Some("the search couldn't start: try again in a moment"));
drop((tx_a, tx_b));
assert_eq!((wait(&a).end, wait(&b).end), (Some(End::Done), Some(End::Done)));
let until = Instant::now() + Duration::from_secs(10);
while POOL.live() > 0 && Instant::now() < until {
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(POOL.live(), 0, "every walker thread ended");
let s = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 8, 1, Arc::new(Names(&[]))).unwrap();
assert_eq!(wait(&s).end, Some(End::Done));
}
#[test]
fn a_search_takes_the_cpus_the_renderer_leaves_from_two_to_eight() {
use super::walk::threads_for;
assert_eq!([threads_for(64, 4), threads_for(12, 4), threads_for(4, 4), threads_for(1, 0)], [8, 7, 2, 2]);
assert!((2..=8).contains(&threads()));
}
/// A folder holding more folders than a search queues ends the search while it is being listed.
#[test]
fn a_folder_with_more_folders_than_the_search_queues_ends_it_as_it_is_listed() {
// Folders and files made in turns: whatever order a file system lists them in, about half of a
// folder's first entries are folders.
let names: Vec<String> = (0..2048).flat_map(|i| [format!("d{i}/"), format!("f{i}.txt")]).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let root = tree("many-folders", &names);
let p = search(&root, "", Limits { folders: 10, ..Limits::default() }, 1, &[]);
assert_eq!(p.end, Some(End::Limit));
assert!(p.files < 2048, "the folder was listed to its end: {p:?}");
}
/// Wants every file, counting the files it is asked about.
struct Counted(AtomicUsize);
impl Visitor for Counted {
fn wants(&self, _: &str) -> bool {
self.0.fetch_add(1, Ordering::Relaxed);
true
}
fn items(&self, _: &Path) -> Vec<usize> {
vec![]
}
}
/// A folder's files past those the search reads aren't kept to be read.
#[test]
fn files_past_those_the_search_reads_are_not_kept() {
let names: Vec<String> = (0..50).map(|i| format!("f{i}.ttf")).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let root = tree("many-files", &names);
let counted = Arc::new(Counted(AtomicUsize::new(0)));
let p = wait(&begin(root.clone(), rules(&root), Limits { files: 3, ..Limits::default() }, 1, 1, counted.clone()));
assert_eq!((p.stopped(), p.read, p.files), (Some("limit"), 3, 50), "{p:?}");
assert_eq!(counted.0.load(Ordering::Relaxed), 4, "one more than the files read");
}
/// Wants `.ttf` files by their names, turns down those whose names start with `skip` by the file,
/// and finds `font.ttf`.
struct Picky;
impl Visitor for Picky {
fn wants(&self, name: &str) -> bool {
name.ends_with(".ttf")
}
fn worth_reading(&self, path: &Path) -> bool {
!path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("skip"))
}
fn items(&self, path: &Path) -> Vec<usize> {
if path.ends_with("font.ttf") { vec![0] } else { vec![] }
}
}
/// Files a visitor turns down by the file itself aren't read and don't count among the files
/// read: with room to read one file, the search gets past them to the font in the folder below.
#[test]
fn files_turned_down_by_the_file_are_not_read() {
let root = tree("turned-down", &["skip1.ttf", "skip2.ttf", "skip3.ttf", "sub/font.ttf"]);
let p = wait(&begin(root.clone(), rules(&root), Limits { files: 1, ..Limits::default() }, 1, 1, Arc::new(Picky)));
assert_eq!((p.end.clone(), p.read, found(&root, &p)), (Some(End::Found), 1, vec!["sub/font.ttf".to_string()]), "{p:?}");
}
/// Windows paths lose the `\\?\` prefix `canonicalize` gives them where they read the same
/// without it. Paths compare the same with the prefix and without it.
#[test]
fn windows_paths_lose_the_verbatim_prefix_where_they_read_the_same() {
for (verbatim, without) in [
(r"\\?\C:\Users\me\Fonts", r"C:\Users\me\Fonts"),
(r"\\?\d:\", r"d:\"),
(r"\\?\UNC\server\share\a b\c.otf", r"\\server\share\a b\c.otf"),
(r"\\?\UNC\server\share", r"\\server\share"),
(r"\\?\UNC\server\share\", r"\\server\share\"),
] {
assert_eq!(without_verbatim(verbatim).as_deref(), Some(without), "{verbatim}");
}
// For comparing, any name goes.
assert_eq!(unprefixed(r"\\?\C:\a.\b").as_deref(), Some(r"C:\a.\b"));
assert_eq!(unprefixed(r"\\?\UNC\server").as_deref(), Some(r"\\server"));
assert_eq!((unprefixed(r"\\?\C:"), unprefixed(r"C:\a"), unprefixed("/home/me")), (None, None, None));
for kept in [
r"\\?\C:",
r"\\?\C:\a.\b",
r"\\?\C:\a \b",
r"\\?\C:\CON\b",
r"\\?\C:\a\nul.txt",
r"\\?\C:\a\COM¹",
r"\\?\C:\a/b",
r"\\?\C:\a\..\b",
r"\\?\C:\a\\b",
r"\\?\Volume{00000000-0000-0000-0000-000000000000}\a",
r"\\?\UNC\server",
r"C:\a",
"/home/me",
] {
assert_eq!(without_verbatim(kept), None, "{kept}");
}
if !cfg!(windows) {
assert_eq!(plain(PathBuf::from(r"\\?\C:\a")), PathBuf::from(r"\\?\C:\a"), "other systems' paths stay as they are");
}
}
+666
View File
@@ -0,0 +1,666 @@
//! The walker threads of a folder search and what they share.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use super::rules::{Refusal, Rules, Siblings, plain};
/// The most walker threads one search starts.
const MAX_THREADS: usize = 8;
/// The most walker threads all searches together have running.
const MAX_LIVE: usize = 32;
/// How often a walker with nothing to do looks again (the deadline, the end).
const IDLE: Duration = Duration::from_millis(50);
/// How many entries of a folder a walker lists between looks at the end, the deadline and the
/// folders waiting to be listed.
const CHECK_EVERY: u64 = 1024;
/// How a search ends when every walker thread stopped on a bug.
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
/// What a search looks for.
pub trait Visitor: Send + Sync {
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
/// it has one more of them to read than [`Limits::files`].
fn wants(&self, name: &str) -> bool;
/// Whether the file at `path`, which [`Self::wants`] took by its name, is to be read after all,
/// by what the file itself is. A file turned down here doesn't count among the files read.
fn worth_reading(&self, _path: &Path) -> bool {
true
}
/// The wanted items (indexes below the search's item count) the file at `path` is.
fn items(&self, path: &Path) -> Vec<usize>;
}
/// The caps on a search.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Limits {
/// Seconds before it ends (0 to 3600; 60 when not finite).
pub seconds: f64,
/// Entries listed, in every folder.
pub entries: u64,
/// Folders waiting to be listed.
pub folders: usize,
/// Folders below the picked one.
pub depth: u32,
/// Files read.
pub files: u64,
/// Files kept for each item.
pub per_item: usize,
/// Files kept in all.
pub hits: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { seconds: 60.0, entries: 10_000_000, folders: 1_000_000, depth: 64, files: 10_000, per_item: 10, hits: 1_000 }
}
}
/// How a search ended.
#[derive(Clone, Debug, PartialEq)]
pub enum End {
/// Every folder it could enter was searched.
Done,
/// Every item has a file.
Found,
/// [`Search::stop`].
Stopped,
/// [`Limits::seconds`] passed.
Time,
/// The entries listed, the folders waiting to be listed, the files read or the files kept
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
/// keeps at most [`Limits::per_item`] files, without ending the search.
Limit,
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
Failed(String),
}
/// Where a search stands ([`Search::progress`]).
#[derive(Clone, Debug, Default)]
pub struct Progress {
/// `None` while it runs.
pub end: Option<End>,
/// The folder searched (canonical, through [`plain`]), once it was checked. The walk reads the
/// folder and the files below it by the paths with the `\\?\` prefix that Windows gives them.
pub folder: PathBuf,
/// The files found, each with the items it is, in the order found (through [`plain`]).
pub hits: Vec<(PathBuf, Vec<usize>)>,
/// Folders listed.
pub folders: u64,
/// Files listed.
pub files: u64,
/// Files read.
pub read: u64,
/// Folders not entered (other apps', the system's, hidden ones, ones too deep, ones whose
/// contents aren't on the disk, ones whose names can't be read).
pub skipped: u64,
/// Folders and files that couldn't be read.
pub unreadable: u64,
/// How long it ran, or has been running.
pub seconds: f64,
}
impl Progress {
/// `searching`, `done` ([`End::Done`], [`End::Found`]), `stopped` ([`End::Stopped`],
/// [`End::Time`], [`End::Limit`]) or `failed`.
pub fn state(&self) -> &'static str {
match &self.end {
None => "searching",
Some(End::Done | End::Found) => "done",
Some(End::Stopped | End::Time | End::Limit) => "stopped",
Some(End::Failed(_)) => "failed",
}
}
/// Why a stopped search stopped: `stop`, `time` or `limit`.
pub fn stopped(&self) -> Option<&'static str> {
match &self.end {
Some(End::Stopped) => Some("stop"),
Some(End::Time) => Some("time"),
Some(End::Limit) => Some("limit"),
_ => None,
}
}
/// Why a failed search failed.
pub fn error(&self) -> Option<&str> {
match &self.end {
Some(End::Failed(e)) => Some(e),
_ => None,
}
}
}
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
pub(crate) struct Pool {
live: AtomicUsize,
cap: usize,
}
impl Pool {
pub(crate) const fn new(cap: usize) -> Self {
Self { live: AtomicUsize::new(0), cap }
}
/// Reserve up to `want` threads: as many as are free, none when none is.
fn reserve(&self, want: usize) -> usize {
let mut live = self.live.load(Ordering::SeqCst);
loop {
let n = want.min(self.cap.saturating_sub(live));
if n == 0 {
return 0;
}
match self.live.compare_exchange(live, live + n, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => return n,
Err(now) => live = now,
}
}
}
fn release(&self, n: usize) {
self.live.fetch_sub(n, Ordering::SeqCst);
}
/// The threads running now.
#[cfg(test)]
pub(crate) fn live(&self) -> usize {
self.live.load(Ordering::SeqCst)
}
}
/// The threads of every search.
static POOL: Pool = Pool::new(MAX_LIVE);
/// The walker threads for a search: the logical CPUs less one and less the renderer's threads,
/// from 2 to 8.
pub fn threads() -> usize {
let cpus = std::thread::available_parallelism().map_or(2, std::num::NonZero::get);
threads_for(cpus, usize::from(vectorcraft_render::default_threads()))
}
/// [`threads`] on `cpus` logical CPUs with `render` renderer threads.
pub(crate) fn threads_for(cpus: usize, render: usize) -> usize {
cpus.saturating_sub(1 + render).clamp(2, MAX_THREADS)
}
/// A running (or ended) search. Dropping it stops it.
pub struct Search {
shared: Arc<Shared>,
}
impl Search {
/// End the search ([`End::Stopped`]) unless it has ended. Walkers busy reading a folder or a
/// file stop after it.
pub fn stop(&self) {
let mut w = self.shared.lock();
self.shared.finish(&mut w, End::Stopped);
}
/// Where the search stands. Past its deadline it has ended ([`End::Time`]), even while a walker
/// is still waiting for a slow folder or file.
pub fn progress(&self) -> Progress {
let s = &self.shared;
let mut w = s.lock();
if w.end.is_none() && Instant::now() >= s.deadline {
s.finish(&mut w, End::Time);
}
Progress {
end: w.end.clone(),
folder: plain(w.folder.clone()),
hits: w.hits.iter().map(|(path, items)| (plain(path.clone()), items.clone())).collect(),
folders: w.folders,
files: w.files,
read: s.read.load(Ordering::Relaxed).min(s.limits.files),
skipped: w.skipped,
unreadable: w.unreadable,
seconds: if w.end.is_some() { w.seconds } else { s.started.elapsed().as_secs_f64() },
}
}
}
impl Drop for Search {
fn drop(&mut self) {
self.stop();
}
}
/// Search `folder` and its subfolders on up to `threads` walker threads (at most 8, as many as the
/// process-wide cap leaves) for the `items` a `visitor` looks for, where `rules` allow (this
/// computer's when `None`). Returns at once; the folder is checked on a walker thread.
pub fn start(
folder: PathBuf,
rules: Option<Rules>,
limits: Limits,
threads: usize,
items: usize,
visitor: Arc<dyn Visitor>,
) -> Result<Search, String> {
start_in(&POOL, folder, rules, limits, threads, items, visitor)
}
/// [`start`] with the threads of `pool`.
pub(crate) fn start_in(
pool: &'static Pool,
folder: PathBuf,
rules: Option<Rules>,
limits: Limits,
threads: usize,
items: usize,
visitor: Arc<dyn Visitor>,
) -> Result<Search, String> {
if cfg!(target_arch = "wasm32") {
return Err("searching a folder isn't available on the web".into());
}
let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 };
let n = pool.reserve(threads.clamp(1, MAX_THREADS));
if n == 0 {
return Err("the search couldn't start: try again in a moment".into());
}
let started = Instant::now();
let shared = Arc::new(Shared {
stop: AtomicBool::new(false),
entries: AtomicU64::new(0),
read: AtomicU64::new(0),
started,
deadline: started + Duration::from_secs_f64(seconds),
limits,
items,
given: rules,
rules: OnceLock::new(),
visitor,
walk: Mutex::new(Walk { root: Some(folder), alive: n, found: vec![0; items], ..Walk::default() }),
wake: Condvar::new(),
pool,
});
let mut spawned = 0;
for _ in 0..n {
let s = shared.clone();
let thread = std::thread::Builder::new().name("vectorcraft-find".into()).spawn(move || {
let _alive = Alive(s.clone());
// A bug in the walk ends this walker and the others go on; when every walker ends this
// way, the search fails ([`Alive`]).
let _ = crate::guard::catch_panic(|| walker(&s));
});
if thread.is_ok() {
spawned += 1;
}
}
if spawned < n {
pool.release(n - spawned);
let mut w = shared.lock();
w.alive = w.alive.saturating_sub(n - spawned);
if spawned == 0 {
return Err("the search couldn't start: try again in a moment".into());
}
if w.alive == 0 && w.end.is_none() {
shared.finish(&mut w, End::Failed(INTERNAL.into()));
}
}
Ok(Search { shared })
}
/// What a search's walkers share.
struct Shared {
/// Set once the search ended: walkers stop listing and reading.
stop: AtomicBool,
entries: AtomicU64,
read: AtomicU64,
started: Instant,
deadline: Instant,
limits: Limits,
items: usize,
/// The rules given to [`start`], else this computer's, read by the first walker.
given: Option<Rules>,
rules: OnceLock<Rules>,
visitor: Arc<dyn Visitor>,
walk: Mutex<Walk>,
/// Woken when folders are queued, a walker finishes a folder, or the search ends.
wake: Condvar,
pool: &'static Pool,
}
/// The state walkers change under the lock.
#[derive(Default)]
struct Walk {
/// The picked folder, until a walker takes it to check it.
root: Option<PathBuf>,
/// A walker is checking the picked folder.
preparing: bool,
folder: PathBuf,
/// Folders waiting to be listed, with their depth below the picked one.
stack: Vec<(PathBuf, u32)>,
/// Folders being listed (and their candidates read) now.
listing: usize,
/// Walker threads still running.
alive: usize,
end: Option<End>,
seconds: f64,
hits: Vec<(PathBuf, Vec<usize>)>,
/// Files kept for each item.
found: Vec<usize>,
folders: u64,
files: u64,
skipped: u64,
unreadable: u64,
/// The home folder was queued from a system's root ([`Rules::home_below`]).
home_queued: bool,
}
impl Shared {
fn lock(&self) -> MutexGuard<'_, Walk> {
self.walk.lock().unwrap_or_else(|e| e.into_inner())
}
/// End the search as `end` unless it has ended, and wake every walker to see it.
fn finish(&self, w: &mut Walk, end: End) {
if w.end.is_none() {
w.end = Some(end);
w.seconds = self.started.elapsed().as_secs_f64();
}
self.stop.store(true, Ordering::Relaxed);
self.wake.notify_all();
}
fn end_with(&self, end: End) {
let mut w = self.lock();
self.finish(&mut w, end);
}
/// Whether to stop listing and reading: the search ended or its time is up.
fn should_stop(&self) -> bool {
self.stop.load(Ordering::Relaxed) || Instant::now() >= self.deadline
}
/// Whether the folders waiting to be listed and `more` folders found in the folder being listed
/// come to more than [`Limits::folders`], which ends the search ([`End::Limit`]).
fn too_many_folders(&self, more: usize) -> bool {
let mut w = self.lock();
let over = w.stack.len().saturating_add(more) > self.limits.folders;
if over {
self.finish(&mut w, End::Limit);
}
over
}
/// Check the picked folder, then queue it.
fn prepare(&self, root: &Path) {
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
let path = root.display();
let checked = match std::fs::canonicalize(root) {
Err(e) => Err(format!("{path}: {e}")),
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
Ok(folder) => match rules.refusal(&folder) {
None => Ok(folder),
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
},
};
let mut w = self.lock();
match checked {
Ok(folder) => {
w.folder = folder.clone();
w.stack.push((folder, 0));
}
Err(e) => self.finish(&mut w, End::Failed(e)),
}
}
/// List one folder: queue its subfolders, then read the files [`Visitor::wants`] accepts.
fn list(&self, dir: &Path, depth: u32) {
let Some(rules) = self.rules.get() else { return };
let (mut skipped, mut unreadable, mut files) = (0, 0, 0);
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
let mut w = self.lock();
// The picked folder itself: the search can't run, and says why.
if depth == 0 {
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
} else {
w.unreadable += 1;
w.folders += 1;
}
return;
}
};
let (mut folders, mut siblings, mut candidates) = (vec![], Siblings::default(), vec![]);
for (n, entry) in entries.enumerate() {
if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && (self.should_stop() || self.too_many_folders(folders.len())) {
break;
}
if self.entries.fetch_add(1, Ordering::Relaxed) >= self.limits.entries {
self.end_with(End::Limit);
break;
}
let Ok(entry) = entry else {
unreadable += 1;
break;
};
let Ok(kind) = entry.file_type() else {
unreadable += 1;
continue;
};
let name = entry.file_name();
let Some(name) = name.to_str() else {
// A name that isn't Unicode: its folder is left out, its file can't be wanted.
if kind.is_dir() {
skipped += 1;
}
continue;
};
if kind.is_dir() || kind.is_symlink() {
siblings.note(name);
}
// Links are neither followed nor read.
if kind.is_dir() {
folders.push((entry, name.to_string()));
} else if kind.is_file() {
files += 1;
// At most one file more than the search reads is kept to be read.
if (candidates.len() as u64) <= self.limits.files && self.visitor.wants(name) && self.visitor.worth_reading(&entry.path()) {
if hidden_and_offline(&entry).1 {
// Its contents are in the cloud: counted as unreadable, never read.
unreadable += 1;
} else {
candidates.push(entry);
}
}
}
}
// The folder is told once for all its folders.
let listed = rules.listed(dir, siblings);
let mut queue = vec![];
for (n, (entry, name)) in folders.into_iter().enumerate() {
if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && self.should_stop() {
break;
}
let path = entry.path();
let (hidden, offline) = hidden_and_offline(&entry);
if depth >= self.limits.depth || name.starts_with('.') || hidden || offline || rules.never_in(&listed, &name) || rules.is_refused(&path) {
skipped += 1;
continue;
}
queue.push((path, depth + 1));
}
let home = rules.home_below(&listed);
{
let mut w = self.lock();
w.files += files;
w.skipped += skipped;
w.unreadable += unreadable;
if w.end.is_none() {
if w.stack.len() + queue.len() > self.limits.folders {
self.finish(&mut w, End::Limit);
} else {
w.stack.extend(queue);
if let Some(home) = home.filter(|_| !w.home_queued) {
w.home_queued = true;
w.stack.push((home, 1));
}
self.wake.notify_all();
}
}
}
let mut unreadable = 0;
for entry in candidates {
if self.should_stop() {
break;
}
if self.read.fetch_add(1, Ordering::Relaxed) >= self.limits.files {
self.end_with(End::Limit);
break;
}
let path = entry.path();
match crate::guard::catch_panic(|| self.visitor.items(&path)) {
Ok(items) if !items.is_empty() => self.keep(path, items),
Ok(_) => {}
// A file that trips a bug: as one that can't be read, and the search goes on.
Err(_) => unreadable += 1,
}
}
let mut w = self.lock();
w.folders += 1;
w.unreadable += unreadable;
}
/// Keep a file the visitor gave `items` for, for those of its items that take more files, as
/// soon as it is read: a file read as the search ends is kept too. The search ends at the most
/// files it keeps, and once every item has a file.
fn keep(&self, path: PathBuf, mut items: Vec<usize>) {
let mut w = self.lock();
items.sort_unstable();
items.dedup();
items.retain(|i| w.found.get(*i).is_some_and(|n| *n < self.limits.per_item));
if items.is_empty() || w.hits.len() >= self.limits.hits {
return;
}
for i in &items {
if let Some(n) = w.found.get_mut(*i) {
*n += 1;
}
}
w.hits.push((path, items));
if w.hits.len() >= self.limits.hits {
self.finish(&mut w, End::Limit);
}
if self.items > 0 && w.found.iter().all(|n| *n > 0) {
self.finish(&mut w, End::Found);
}
}
}
/// What a walker does next.
enum Job {
Prepare(PathBuf),
List(PathBuf, u32),
}
/// A walker thread: check the picked folder, or list the next folder, until the search ends.
fn walker(s: &Shared) {
loop {
let job = {
let mut w = s.lock();
loop {
if w.end.is_some() {
return;
}
if Instant::now() >= s.deadline {
s.finish(&mut w, End::Time);
return;
}
if let Some(root) = w.root.take() {
w.preparing = true;
break Job::Prepare(root);
}
if let Some((dir, depth)) = w.stack.pop() {
w.listing += 1;
break Job::List(dir, depth);
}
if !w.preparing && w.listing == 0 {
s.finish(&mut w, End::Done);
return;
}
w = s.wake.wait_timeout(w, IDLE).map_or_else(|e| e.into_inner().0, |(w, _)| w);
}
};
// The guard lowers `preparing` or `listing` when the job ends, on a bug too.
let _busy = Busy(s, matches!(job, Job::Prepare(_)));
let done = crate::guard::catch_panic(|| match &job {
Job::Prepare(root) => s.prepare(root),
Job::List(dir, depth) => s.list(dir, *depth),
});
if done.is_err() {
let mut w = s.lock();
match job {
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
Job::List(..) => w.unreadable += 1,
}
}
}
}
/// A walker's job in progress: preparing (`true`) or listing a folder.
struct Busy<'a>(&'a Shared, bool);
impl Drop for Busy<'_> {
fn drop(&mut self) {
let mut w = self.0.lock();
if self.1 {
w.preparing = false;
} else {
w.listing = w.listing.saturating_sub(1);
}
self.0.wake.notify_all();
}
}
/// A running walker thread, counted out of the search and the pool when it ends.
struct Alive(Arc<Shared>);
impl Drop for Alive {
fn drop(&mut self) {
let mut w = self.0.lock();
w.alive = w.alive.saturating_sub(1);
if w.alive == 0 && w.end.is_none() {
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
}
drop(w);
self.0.pool.release(1);
}
}
/// Whether a folder or file is hidden by its flags (macOS `UF_HIDDEN`; Windows hidden or system),
/// and whether its contents are in the cloud rather than on the disk (macOS `SF_DATALESS`; Windows
/// offline or recalled on access).
#[cfg(target_os = "macos")]
fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) {
use std::os::macos::fs::MetadataExt;
const UF_HIDDEN: u32 = 0x8000;
const SF_DATALESS: u32 = 0x4000_0000;
entry.metadata().map_or((false, false), |m| (m.st_flags() & UF_HIDDEN != 0, m.st_flags() & SF_DATALESS != 0))
}
#[cfg(windows)]
fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) {
use std::os::windows::fs::MetadataExt;
const HIDDEN: u32 = 0x2;
const SYSTEM: u32 = 0x4;
const OFFLINE: u32 = 0x1000;
const RECALL_ON_OPEN: u32 = 0x4_0000;
const RECALL_ON_DATA_ACCESS: u32 = 0x40_0000;
entry.metadata().map_or((false, false), |m| {
let a = m.file_attributes();
(a & (HIDDEN | SYSTEM) != 0, a & (OFFLINE | RECALL_ON_OPEN | RECALL_ON_DATA_ACCESS) != 0)
})
}
#[cfg(not(any(target_os = "macos", windows)))]
fn hidden_and_offline(_: &std::fs::DirEntry) -> (bool, bool) {
(false, false)
}
+517
View File
@@ -0,0 +1,517 @@
//! The fonts a document uses that aren't available (`text.missingFonts`), the files that provide
//! them in a folder the user picks (`text.findFontFiles`, searched on separate threads:
//! [`super::findfiles`]), and copying the files the user chooses into VectorCraft's own Fonts
//! folder, which the font scan reads (`text.addFontFiles`).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_text::{FontDb, FontMatch, WantedFont, WantedFonts};
use super::findfiles::{self, Limits, Visitor};
use super::*;
use crate::DocState;
/// The most fonts listed, or looked for at once.
const MAX_FONTS: usize = 1_000;
/// The longest family or style name listed or given (bytes).
const MAX_NAME: usize = 256;
/// The CSS generic font families (CSS Fonts Module Level 4), which SVG and HTML files name in place
/// of a font and no font file provides; compared in any ASCII case. The list leaves out
/// `fangsong`, which is also the family name of a Chinese font (FangSong).
const GENERIC_FAMILIES: &[&str] = &[
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"math",
"emoji",
];
/// The longest folder path given (bytes).
const MAX_PATH: usize = 4096;
/// The most files one `text.addFontFiles` copies: as many as a search keeps.
const MAX_ADD_FILES: usize = 1_000;
/// The largest font file a search reads and `text.addFontFiles` copies, and the most bytes one
/// `text.addFontFiles` copies.
pub(crate) const MAX_FONT_FILE: u64 = 256 << 20;
#[cfg(not(target_arch = "wasm32"))]
const MAX_ADD_BYTES: u64 = 1 << 30;
/// The highest number a copy's name gets (`Name 99.otf`).
#[cfg(not(target_arch = "wasm32"))]
const MAX_NUMBER: u32 = 99;
/// The extensions of the font files `text.addFontFiles` copies (in any case), besides suitcase
/// fonts.
#[cfg(not(target_arch = "wasm32"))]
const FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
/// Why a file wasn't copied.
const NOT_A_FILE: &str = "not a file";
#[cfg(not(target_arch = "wasm32"))]
const NOT_A_FONT: &str = "not a font file";
#[cfg(not(target_arch = "wasm32"))]
const TOO_LARGE: &str = "larger than 256 MB";
#[cfg(not(target_arch = "wasm32"))]
const TOO_MUCH: &str = "the files come to more than 1 GB: add fewer at a time";
#[cfg(not(target_arch = "wasm32"))]
const NO_NAME: &str = "every numbered name for it is taken in the Fonts folder";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "text.missingFonts",
"Missing Fonts",
[],
None,
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
has_doc,
missing
),
cmd!(
query "text.findFontFiles",
"Find Font Files",
[],
None,
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, 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' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
cmd!(
query "text.addFontFiles",
"Add Font Files",
[],
None,
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf); then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
]
}
/// A font a document names that isn't available as named.
#[derive(Clone, Debug, PartialEq)]
pub struct MissingFont {
pub family: String,
pub style: String,
/// [`FontMatch::Missing`] or [`FontMatch::Style`].
pub status: FontMatch,
/// The family and style of the face shown instead.
pub resolved: Option<(String, String)>,
}
/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named
/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
/// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out.
pub fn missing_fonts(d: &Document) -> Vec<MissingFont> {
let db = FontDb::global();
let generic = |family: &str| GENERIC_FAMILIES.iter().any(|g| g.eq_ignore_ascii_case(family.trim()));
let mut used: Vec<(String, String)> = super::fonts::used_fonts(d).into_iter().map(|(family, style, _)| (family, style)).collect();
// Sorted by family and style: the versions type names of one face are neighbors.
used.dedup();
used.into_iter()
.filter(|(family, style)| !family.trim().is_empty() && !generic(family) && family.len() <= MAX_NAME && style.len() <= MAX_NAME)
.filter_map(|(family, style)| {
let resolved = db.resolve(&family, &style);
let status = resolved.as_ref().map_or(FontMatch::Missing, |(_, m)| *m);
(status != FontMatch::Exact).then(|| MissingFont {
family,
style,
status,
resolved: resolved.map(|(f, _)| (f.family.clone(), f.style.clone())),
})
})
.take(MAX_FONTS)
.collect()
}
/// `text.missingFonts`'s answer for the open document `st`, where searches go by `rules` (this
/// computer's when `None`).
pub fn missing_fonts_of(st: &DocState, rules: Option<&findfiles::Rules>) -> Value {
let fonts: Vec<Value> = missing_fonts(&st.doc)
.into_iter()
.map(|m| {
json!({
"family": m.family, "style": m.style, "status": m.status.as_str(),
"resolved": m.resolved.map(|(family, style)| json!({ "family": family, "style": style })),
})
})
.collect();
let mut out = json!({ "count": fonts.len(), "fonts": fonts });
// File › Package puts the fonts in a `Fonts` folder next to the packaged document. The folder
// is made absolute against the working folder when the document's path is relative. It is
// offered when a search may start there, as far as the rules tell without listing a folder.
if let Some(dir) = st.path.as_deref().and_then(|p| Path::new(p).parent()).map(|d| d.join("Fonts")).filter(|d| d.is_dir()) {
let dir = super::fileio::absolute_path(&dir.to_string_lossy());
let system;
let rules = match rules {
Some(rules) => rules,
None => {
system = findfiles::Rules::system();
&system
}
};
if std::fs::canonicalize(&dir).is_ok_and(|c| rules.refusal_unlisted(&c).is_none()) {
out["fontsNextToDocument"] = json!(dir);
}
}
out
}
fn missing(s: &mut Session, _: &Value) -> Result<Value> {
Ok(missing_fonts_of(s.doc()?, s.search_rules.as_ref()))
}
/// A font a search looks for.
#[derive(Clone, Debug)]
pub(crate) struct Sought {
pub(crate) family: String,
pub(crate) style: String,
pub(crate) status: FontMatch,
}
/// The session's last search for font files (`text.findFontFiles`).
pub(crate) struct FontSearch {
/// The search's number in the session; [`stop`] takes it.
pub(crate) id: u64,
/// The folder as given.
pub(crate) folder: String,
pub(crate) fonts: Vec<Sought>,
pub(crate) search: findfiles::Search,
}
/// The files a search for fonts reads: font files, by their names ([`WantedFonts`]).
struct FontFiles(WantedFonts);
impl Visitor for FontFiles {
fn wants(&self, name: &str) -> bool {
let path = Path::new(name);
// A suitcase font (macOS) has no extension, or `.suit`.
vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none_or(|e| e.eq_ignore_ascii_case("suit"))
}
fn worth_reading(&self, path: &Path) -> bool {
// A search reads font files and suitcase fonts of at most [`MAX_FONT_FILE`] bytes, the files
// `text.addFontFiles` copies.
(vectorcraft_text::is_font_file(path) || vectorcraft_text::is_suitcase(path))
&& std::fs::metadata(path).is_ok_and(|m| m.len() <= MAX_FONT_FILE)
}
fn items(&self, path: &Path) -> Vec<usize> {
self.0.provided_by(path)
}
}
/// `[{family, style?}]`: 1 to 1000 fonts with a family of 1 to 256 bytes and a style of at most 256
/// (Regular when left out).
fn font_list(v: &Value) -> Option<Vec<(String, String)>> {
let list = v.as_array().filter(|a| (1..=MAX_FONTS).contains(&a.len()))?;
list.iter()
.map(|f| {
let family = f.get("family")?.as_str().filter(|s| !s.trim().is_empty() && s.len() <= MAX_NAME)?;
let style = match f.get("style") {
None | Some(Value::Null) => "Regular",
Some(s) => s.as_str().filter(|s| s.len() <= MAX_NAME)?,
};
Some((family.to_string(), style.to_string()))
})
.collect()
}
fn find(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.findFontFiles";
static NEXT: AtomicU64 = AtomicU64::new(1);
if bool_or(p, "stop", false) {
if let Some(f) = &s.font_search {
f.search.stop();
}
return Ok(state(s));
}
let Some(folder) = p.get("folder").filter(|f| !f.is_null()) else { return Ok(state(s)) };
let given = match p.get("fonts") {
None | Some(Value::Null) => None,
Some(v) => Some(font_list(v).ok_or_else(|| bad(C, "each of `fonts` is {family, style?}; 1 to 1000 of them"))?),
};
let seconds = p.get("maxSeconds").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(60.0).clamp(1.0, 600.0);
// Only checked here: the folder is read on a walker thread.
let folder = folder
.as_str()
.filter(|f| !f.is_empty() && f.len() <= MAX_PATH && !f.contains(['\0', '\u{FFFD}']) && Path::new(f).is_absolute())
.ok_or_else(|| bad(C, "`folder` must be an absolute folder path"))?
.to_string();
let fonts = match given {
Some(fonts) => fonts,
None => missing_fonts(&s.active().ok_or_else(|| bad(C, "give `fonts`, or open a document"))?.doc)
.into_iter()
.map(|m| (m.family, m.style))
.collect(),
};
let db = FontDb::global();
let (mut sought, mut wanted) = (vec![], vec![]);
for (family, style) in fonts {
let (status, installed) = match db.resolve(&family, &style) {
Some((_, FontMatch::Exact)) => continue,
Some((f, FontMatch::Style)) => (FontMatch::Style, Some(f.family.clone())),
_ => (FontMatch::Missing, None),
};
wanted.push(WantedFont { family: family.clone(), style: style.clone(), installed });
sought.push(Sought { family, style, status });
}
if sought.is_empty() {
return Err(bad(C, "no fonts to look for: they are all available"));
}
// A new search stops the last one.
s.font_search = None;
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
let limits = Limits { seconds, ..Limits::default() };
let visitor = Arc::new(FontFiles(WantedFonts::new(&wanted)));
let search =
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, sought.len(), visitor).map_err(EngineError::Other)?;
s.font_search = Some(FontSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, fonts: sought, search });
Ok(state(s))
}
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
let p = f.search.progress();
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
for (path, items) in &p.hits {
for i in items {
if let Some(v) = files.get_mut(*i) {
v.push(path.to_string_lossy().into_owned());
}
}
}
let fonts: Vec<Value> = f
.fonts
.iter()
.zip(files)
.map(|(font, mut files)| {
files.sort();
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
out["error"] = json!(e);
}
out
}
/// Stop the session's search when it is the one numbered `id` (`text.findFontFiles`'s `id`): a
/// newer search, started by someone else, goes on.
pub fn stop(s: &mut Session, id: u64) {
if let Some(f) = s.font_search.as_ref().filter(|f| f.id == id) {
f.search.stop();
}
}
/// What copying font files into a folder did: each file copied (from, to), kept because the same
/// file is there already (from, the file there), or skipped (file, why).
#[derive(Debug, Default)]
pub(crate) struct CopyReport {
pub(crate) copied: Vec<(PathBuf, PathBuf)>,
pub(crate) kept: Vec<(PathBuf, PathBuf)>,
pub(crate) skipped: Vec<(PathBuf, String)>,
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.addFontFiles";
let mut files: Vec<PathBuf> = p
.get("files")
.and_then(Value::as_array)
.filter(|a| (1..=MAX_ADD_FILES).contains(&a.len()))
.and_then(|a| a.iter().map(|f| f.as_str().map(PathBuf::from)).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(C, "`files` must be 1 to 1000 paths of font files the last text.findFontFiles search found"))?;
files.sort();
files.dedup();
let dir = vectorcraft_text::app_font_dir()
.ok_or_else(|| EngineError::Other("VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it".into()))?;
let found: Vec<PathBuf> =
s.font_search.as_ref().map(|f| f.search.progress().hits.into_iter().map(|(path, _)| path).collect()).unwrap_or_default();
if let Some(f) = files.iter().find(|f| !found.contains(f)) {
return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display())));
}
super::fileio::create_dir(&dir.to_string_lossy())?;
let report = copy_into(&files, dir);
let pairs = |v: &[(PathBuf, PathBuf)]| {
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
};
let mut out = json!({
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
});
if !report.copied.is_empty() || !report.kept.is_empty() {
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
let r = super::fonts::rescan(s, &Value::Null)?;
out["families"] = r["families"].clone();
out["faces"] = r["faces"].clone();
}
Ok(out)
}
/// Where [`copy_one`] put a file: copied to, or kept as, the file in the folder.
#[cfg(not(target_arch = "wasm32"))]
enum Placed {
Copied(PathBuf),
Kept(PathBuf),
}
/// Copy `files` into `dir`, never replacing a file there: a file with the same contents is kept,
/// and when another file has the name, the copy gets the first free name from `Name 2.otf` to
/// `Name 99.otf`. At most [`MAX_FONT_FILE`] bytes a file and 1 GB in all.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn copy_into(files: &[PathBuf], dir: &Path) -> CopyReport {
let mut report = CopyReport::default();
let mut total = 0;
for from in files {
match copy_one(from, dir, &mut total) {
Ok(Placed::Copied(to)) => report.copied.push((from.clone(), to)),
Ok(Placed::Kept(to)) => report.kept.push((from.clone(), to)),
Err(why) => report.skipped.push((from.clone(), why)),
}
}
report
}
/// The web has no file system: nothing is copied.
#[cfg(target_arch = "wasm32")]
pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport {
CopyReport { skipped: files.iter().map(|f| (f.clone(), NOT_A_FILE.to_string())).collect(), ..CopyReport::default() }
}
#[cfg(not(target_arch = "wasm32"))]
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Placed, String> {
use std::io::ErrorKind;
let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err(NOT_A_FILE.into());
}
let font = from.extension().and_then(|e| e.to_str()).is_some_and(|e| FONT_EXTENSIONS.iter().any(|x| e.eq_ignore_ascii_case(x)));
// A suitcase font's fonts are in its resource fork, which is copied with it, whatever its name.
let fork = match (vectorcraft_text::is_suitcase(from), font) {
(true, _) => Some(read_fork(from)?),
(false, true) => None,
(false, false) => return Err(NOT_A_FONT.into()),
};
let len = fork.as_ref().map_or(meta.len(), |f| f.len() as u64);
if len > MAX_FONT_FILE {
return Err(TOO_LARGE.into());
}
if total.saturating_add(len) > MAX_ADD_BYTES {
return Err(TOO_MUCH.into());
}
let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(NOT_A_FILE.into()) };
let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
for n in 1..=MAX_NUMBER {
let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) };
match std::fs::symlink_metadata(&to) {
Err(e) if e.kind() == ErrorKind::NotFound => match create_copy(from, &to, fork.as_deref()) {
Ok(()) => {
*total += len;
return Ok(Placed::Copied(to));
}
// Taken meanwhile: the next name.
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.to_string()),
},
Ok(m) if m.is_file() && same_file(from, &to, m.len(), len, fork.as_deref()) => return Ok(Placed::Kept(to)),
_ => {}
}
}
Err(NO_NAME.into())
}
/// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose
/// resource fork is `fork`; never in place of a file.
#[cfg(not(target_arch = "wasm32"))]
fn create_copy(from: &Path, to: &Path, fork: Option<&[u8]>) -> std::io::Result<()> {
let Some(fork) = fork else { return vectorcraft_format::write_new_with(to, |f| copy_capped(from, f)) };
vectorcraft_format::write_new_with(to, |_| Ok(()))?;
std::fs::write(fork_path(to), fork).inspect_err(|_| {
// Best effort: the empty file just made is all there is to clean up.
let _ = std::fs::remove_file(to);
})
}
/// Whether the file `to` (`to_len` bytes) holds what the copy of `from` (`len` bytes, or the
/// resource fork `fork` of a suitcase font) would.
#[cfg(not(target_arch = "wasm32"))]
fn same_file(from: &Path, to: &Path, to_len: u64, len: u64, fork: Option<&[u8]>) -> bool {
match fork {
None => to_len == len && same_contents(from, to),
Some(fork) => to_len == 0 && read_fork(to).is_ok_and(|f| f == fork),
}
}
/// The resource fork of the file at `path` (where macOS keeps a suitcase font's fonts), at most
/// [`MAX_FONT_FILE`] bytes.
#[cfg(not(target_arch = "wasm32"))]
fn read_fork(path: &Path) -> std::result::Result<Vec<u8>, String> {
use std::io::Read;
let mut fork = vec![];
std::fs::File::open(fork_path(path)).and_then(|f| f.take(MAX_FONT_FILE + 1).read_to_end(&mut fork)).map_err(|e| e.to_string())?;
if fork.len() as u64 > MAX_FONT_FILE {
return Err(TOO_LARGE.into());
}
Ok(fork)
}
/// Where macOS shows the resource fork of the file at `path`.
#[cfg(not(target_arch = "wasm32"))]
fn fork_path(path: &Path) -> PathBuf {
path.join("..namedfork/rsrc")
}
/// Copy the file `from` into `to`, failing past [`MAX_FONT_FILE`] bytes (a file that grew since
/// it was measured).
#[cfg(not(target_arch = "wasm32"))]
fn copy_capped(from: &Path, to: &mut std::fs::File) -> std::io::Result<()> {
use std::io::Read;
let copied = std::io::copy(&mut std::fs::File::open(from)?.take(MAX_FONT_FILE + 1), to)?;
if copied > MAX_FONT_FILE {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, TOO_LARGE));
}
Ok(())
}
/// Whether the files `a` and `b` hold the same bytes (read 64 KB at a time).
#[cfg(not(target_arch = "wasm32"))]
fn same_contents(a: &Path, b: &Path) -> bool {
let (Ok(mut a), Ok(mut b)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false };
let (mut x, mut y) = (vec![0; 1 << 16], vec![0; 1 << 16]);
loop {
match (fill(&mut a, &mut x), fill(&mut b, &mut y)) {
(Ok(0), Ok(0)) => return true,
(Ok(n), Ok(m)) if n == m && x.get(..n) == y.get(..m) => {}
_ => return false,
}
}
}
/// Read from `f` until `buf` is full or the file ends: the bytes read.
#[cfg(not(target_arch = "wasm32"))]
fn fill(f: &mut std::fs::File, buf: &mut [u8]) -> std::io::Result<usize> {
use std::io::Read;
let mut n = 0;
while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
match f.read(rest)? {
0 => break,
k => n += k,
}
}
Ok(n)
}
+31 -13
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fonts in Document",
[],
None,
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
has_doc,
fonts
),
@@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
Ok(v)
}
/// The fonts (family, style) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
/// A font as type names it: family, style and, when it names one, the installed version.
pub(super) type UsedFont = (String, String, Option<String>);
/// How a font is named in reports: family and style, then the version the type names, if any.
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
match version {
Some(v) => format!("{family} {style} ({v})"),
None => format!("{family} {style}"),
}
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
}
};
d.walk(&mut add);
@@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = used_fonts(d)
.into_iter()
.filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _)| family)
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _, _)| family)
.collect();
// Sorted by family: its styles are neighbours.
// Sorted by family: its styles and versions are neighbours.
missing.dedup();
(!missing.is_empty()).then(|| {
format!(
@@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
let d = &s.doc()?.doc;
let db = vectorcraft_text::FontDb::global();
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
for id in ids {
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
e.0 += 1;
e.2.push_str(&r.text);
if !e.1.contains(&id) {
@@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
let list: Vec<Value> = found
.into_iter()
.map(|((family, style), (runs, objects, chars))| {
let resolved = db.resolve(&family, &style);
.map(|((family, style, version), (runs, objects, chars))| {
// The face the type is set in: the version it names when that one is installed.
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
lacking.sort_unstable();
lacking.dedup();
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
json!({
let mut row = json!({
"family": family, "style": style, "runs": runs, "objects": objects.len(),
"missing": status == vectorcraft_text::FontMatch::Missing,
"status": status.as_str(),
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
"missingGlyphs": lacking.len(),
})
});
if let Some(v) = version {
row["version"] = json!(v);
}
row
})
.collect();
Ok(json!(list))
@@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
// Another font: none of the old one's versions.
r.style.font_version = None;
changed = true;
n += 1;
}
+12 -2
View File
@@ -139,7 +139,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_set
),
cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art),
cmd!(
"artboard.fitToArt",
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
has_doc,
artboard_fit_art
),
cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel),
cmd!(
"layer.clippingMask.toggle",
@@ -810,9 +818,11 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
xf.is_finite().then_some(xf)
}
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
/// layers don't count.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?;
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+18
View File
@@ -22,7 +22,9 @@ mod effectcmd;
pub mod expand;
pub(crate) mod fileinfo;
pub mod fileio;
pub mod findfiles;
pub mod flatten;
pub mod fontfiles;
mod fonts;
pub(crate) mod freeform;
pub(crate) mod gradient;
@@ -223,6 +225,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(inline::specs());
v.extend(textstyles::specs());
v.extend(fonts::specs());
v.extend(fontfiles::specs());
v.extend(help::specs());
v.extend(threads::specs());
v.extend(textwrap::specs());
@@ -304,6 +307,21 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
let values =
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
values.iter().map(|value| checked_id(s, value, command)).collect()
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
if s.doc()?.doc.node(id).is_none() {
return Err(bad(command, format!("no such object id {value}")));
}
Ok(id)
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+27 -1
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
has_selection,
set_live_shape
),
@@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
}
};
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
l.set_sides(n);
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
}
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}
+51 -16
View File
@@ -35,7 +35,7 @@ type Entry = (String, Vec<u8>);
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
/// desktop file systems do); the name is taken.
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
pub(crate) fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
let p = Path::new(name);
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
@@ -53,6 +53,17 @@ fn font_ext(bytes: &[u8]) -> &'static str {
}
}
/// The name a font's file gets in the package's `Fonts` folder: the name of the file at `path`,
/// with the extension the font's `data` calls for ([`font_ext`]) when that name has no font
/// extension; without a file, `family-style` with that extension.
fn packaged_name(path: Option<&Path>, family: &str, style: &str, data: &[u8]) -> String {
match path.and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()) {
Some(name) if vectorcraft_text::is_font_file(Path::new(&name)) => name,
Some(name) => format!("{name}.{}", font_ext(data)),
None => format!("{family}-{style}.{}", font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
}
}
/// The options of a package.
struct Options {
copy_links: bool,
@@ -224,29 +235,35 @@ impl<'a> Collector<'a> {
let db = vectorcraft_text::FontDb::global();
self.lines.push("FONTS".into());
self.lines.push(format!("Document: {source}"));
for (family, style) in super::fonts::used_fonts(doc) {
let font = format!("{family} {style}");
let reason = match db.face(&family, &style) {
Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => {
for used in super::fonts::used_fonts(doc) {
let font = super::fonts::font_label(&used);
let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names).
let resolved = db
.resolve(&family, &style)
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
// A style the family lacks is shown in its closest style, whose file is copied.
let shown = match &resolved {
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
_ => String::new(),
};
let reason = match resolved {
Some((f, _)) if f.embeddable() => {
let data = f.file_data();
let file = f.path().and_then(Path::file_name).map_or_else(
|| {
format!("{}-{}.{}", f.family, f.style, font_ext(data))
.replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), "")
},
|n| n.to_string_lossy().into_owned(),
);
let file = packaged_name(f.path(), &f.family, &f.style, data);
// A collection, or a style shown in another face's file, is copied once.
if self.font_files.insert(file.to_lowercase()) {
self.entries.push((format!("Fonts/{file}"), data.to_vec()));
self.fonts += 1;
}
self.lines.push(format!("{font} → Fonts/{file}"));
self.lines.push(format!("{font}{shown} → Fonts/{file}"));
continue;
}
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
_ => "not available on this computer",
Some(_) => "its licence doesn't allow embedding",
None => "not available on this computer",
};
self.lines.push(format!("{font}: {reason}, not copied"));
self.lines.push(format!("{font}{shown}: {reason}, not copied"));
self.skipped.push(json!({ "font": font, "reason": reason }));
}
self.lines.push(String::new());
@@ -401,3 +418,21 @@ pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A font read from a file without a font extension (one a font manager keeps, for example) is
/// packaged with the extension its data calls for.
#[test]
fn packaged_fonts_have_a_font_extension() {
let (otf, ttc, ttf) = (b"OTTO....".as_slice(), b"ttcf....".as_slice(), b"\0\x01\0\0....".as_slice());
let name = |path: Option<&str>, data: &[u8]| packaged_name(path.map(Path::new), "Some Family", "Bold", data);
assert_eq!(name(Some("/fonts/A8F3C2"), otf), "A8F3C2.otf");
assert_eq!(name(Some("/fonts/Shared Fonts.dat"), ttc), "Shared Fonts.dat.ttc");
assert_eq!(name(Some("/fonts/Kept.TTF"), otf), "Kept.TTF");
assert_eq!(name(Some("/fonts/Kept.otc"), ttc), "Kept.otc");
assert_eq!(name(None, ttf), "SomeFamily-Bold.ttf");
}
}
+28 -5
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{} join two open paths' nearest endpoints, or close a single open path",
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
has_selection,
join
),
@@ -283,8 +283,27 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.join";
let ids = match ids_param(p, "ids") {
Some(ids) => {
let st = s.doc()?;
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
return Err(bad(C, format!("{} isn't a path", id.0)));
}
ids
}
None => selected_paths(s)?,
};
// The ends to join, as (reverse the first path, reverse the second): the first path's end
// must come last, the second's first.
let ends = match p.get("ends") {
None => None,
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
},
};
match ids.as_slice() {
[one] => {
let id = *one;
@@ -305,6 +324,9 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
}
[a, b, ..] => {
let (a, b) = (*a, *b);
if a == b {
return Err(bad(C, "join two different paths"));
}
s.edit("Join", |d, sel| {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
@@ -316,10 +338,11 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
// Pick the closest pair of ends.
// The ends asked for, else the closest pair.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false));
let (rx, ry) =
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
if rx {
x.reverse();
}
+9
View File
@@ -475,6 +475,7 @@ impl Session {
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
@@ -486,6 +487,10 @@ impl Session {
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
}
}
if hyphen_exceptions_changed {
// Preferences › Hyphenation › Exceptions (#394): the layout reads the process-wide list.
vectorcraft_text::set_hyphenation_exceptions(&self.prefs.hyphenation_exceptions);
}
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
@@ -503,6 +508,10 @@ impl Session {
d.grid.subdivisions = self.prefs.grid_subdivisions;
st.revision += 1;
}
// Hyphenation exceptions change every text object's line breaks.
if hyphen_exceptions_changed {
st.revision += 1;
}
}
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
super::plugin::sync_prefs(self);
+36 -10
View File
@@ -25,14 +25,30 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
cmd!(
"select.set",
"Select Objects",
[],
None,
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
set
),
cmd!(
"select.add",
"Add to Selection",
[],
None,
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
add
),
cmd!(
"select.toggle",
"Toggle Selection",
[],
None,
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it",
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it; all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
toggle
),
@@ -225,29 +241,39 @@ fn step(s: &mut Session, dir: i64) -> Result<Value> {
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?;
s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?;
ok()
let ids = checked_ids_param(s, p, "ids", "select.set")?;
s.select(|_, sel| sel.set(ids.iter().copied()))?;
selection_result(s)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?;
let ids = checked_ids_param(s, p, "ids", "select.add")?;
s.select(|_, sel| {
for i in ids {
sel.add(i);
}
})?;
ok()
selection_result(s)
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
let ids = if p.get("ids").is_some() {
checked_ids_param(s, p, "ids", "select.toggle")?
} else {
let value = p.get("id").ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
vec![checked_id(s, value, "select.toggle")?]
};
s.select(|_, sel| {
for id in ids {
sel.toggle(id);
}
})?;
ok()
selection_result(s)
}
fn selection_result(s: &Session) -> Result<Value> {
let selection = &s.doc()?.selection;
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
}
fn key(s: &mut Session, p: &Value) -> Result<Value> {
+3 -3
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
use super::menucmds::squash;
use super::swatch::str_list;
use super::swatchlib::{LibraryFile, LibraryInfo, stem};
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Graphic Style Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
@@ -211,7 +211,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
let mut out = json!({ "count": lib.len() });
s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?;
s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?;
Ok(out)
}
+146 -21
View File
@@ -3,7 +3,9 @@
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
@@ -12,8 +14,9 @@ use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::recolor::Palette;
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::fileio::{create_dir, read_file, write_file};
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
use super::menucmds::squash;
use super::package::free_name;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::*;
@@ -60,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Swatch Library…",
["Window", "Swatches"],
None,
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}",
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
has_doc,
save
),
@@ -69,10 +72,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Swatch Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
cmd!(
"swatch.library.copyToUser",
"Copy to User Defined",
["Window", "Swatch Libraries"],
None,
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
has_user_folder,
copy_to_user
),
]
}
@@ -85,6 +97,9 @@ pub struct Libraries<L = SwatchLibrary> {
/// there are no User Defined libraries.
user_dir: Option<String>,
extra: Vec<Extra<L>>,
/// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the
/// loaded id and the User Defined id [`Libraries::get`] finds for it.
copied: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
@@ -117,6 +132,14 @@ impl LibraryFile for SwatchLibrary {
/// The extensions of library files [`palette_io::read_bytes`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
/// A library file the save commands write ([`Libraries::write`]).
pub(crate) enum FileData {
/// Text (`.vcswatches`, `.gpl`, CSS, `.vcstyles`), returned as `data`.
Text(String),
/// Bytes (`.ase`), returned as `dataBase64`.
Binary(Vec<u8>),
}
impl<L: LibraryFile> Libraries<L> {
pub fn user_dir(&self) -> Option<&str> {
self.user_dir.as_deref()
@@ -145,9 +168,11 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().map(|e| &e.info)
}
/// The User Defined or loaded library `id`.
/// The User Defined or loaded library `id`. The id of a loaded library copied into the user
/// library folder finds the User Defined copy.
pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc<L>)> {
self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()))
let find = |id: &str| self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()));
find(id).or_else(|| self.copied.iter().find(|(loaded, _)| loaded == id).and_then(|(_, user)| find(user)))
}
/// The file library `id` was read from.
@@ -155,36 +180,50 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
}
/// Write library file `text` as the save commands do: into the user library folder as
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
let ext = extension(file);
self.extra
.iter()
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
}
/// Write library file `data` as the save commands do: into the user library folder as
/// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as
/// `data`; the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> {
/// `data` (text) or `dataBase64` (bytes); the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, data: FileData, out: &mut Value, cmd: &str) -> Result<()> {
let bytes = match &data {
FileData::Text(text) => text.as_bytes(),
FileData::Binary(bytes) => bytes.as_slice(),
};
if bool_or(p, "user", false) {
let dir = self
.user_dir()
.ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))?
.to_string();
// A file name from the library's name, without characters file systems reject.
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let file = format!("{}.{ext}", base.trim_matches(['.', ' ']));
let file = library_file_name(name, ext);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
create_dir(&dir)?;
write_file(&path, text.as_bytes())?;
write_file(&path, bytes)?;
self.rescan();
out["path"] = json!(path);
out["library"] = json!(format!("user/{file}"));
} else if let Some(path) = str_param(p, "path") {
write_file(path, text.as_bytes())?;
write_file(path, bytes)?;
out["path"] = json!(path);
} else {
out["data"] = json!(text);
match data {
FileData::Text(text) => out["data"] = json!(text),
FileData::Binary(bytes) => out["dataBase64"] = json!(vectorcraft_format::base64_encode(&bytes)),
}
}
Ok(())
}
/// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is
/// its User Defined library; others are listed as loaded until the app quits.
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder,
/// or a file with the same extension and bytes as one there, is that User Defined library;
/// others are listed as loaded until the app quits.
pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result<L>) -> Result<(LibraryInfo, Arc<L>)> {
let path = str_param(p, "path");
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
@@ -196,7 +235,9 @@ impl<L: LibraryFile> Libraries<L> {
let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
let lib = parse(&bytes, &file)?;
self.rescan();
if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) {
let user =
|p: &str| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p)).or_else(|| self.user_file(p, &bytes));
if let Some(e) = path.and_then(user) {
return Ok((e.info.clone(), e.lib.clone()));
}
let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" };
@@ -205,6 +246,66 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() });
Ok((info, lib))
}
/// Copy loaded library `id` into the user library folder, where it is listed as User Defined in
/// place of the loaded entry; `id` then finds the copy ([`Libraries::get`]). A library read
/// from a library file (an extension of `L::EXTS`) is copied as that file's bytes under the
/// file's name; any other (the swatches of a document, a library loaded from data) is written
/// by `encode` as a `.{ext}` file named after the library. A name the folder already has,
/// compared without case, gets a number ("Brand 2.ase"), and no file is replaced. When a file
/// of the folder with the same extension holds the same bytes, its library is used and nothing
/// is written. → the User Defined library, and whether a file was written.
pub(crate) fn copy_to_user(
&mut self,
id: &str,
ext: &str,
encode: impl FnOnce(&L) -> Result<Vec<u8>>,
cmd: &str,
) -> Result<(LibraryInfo, Arc<L>, bool)> {
let dir = self.user_dir().ok_or_else(|| bad(cmd, "no user library folder here"))?.to_string();
let (source, lib) = self
.extra
.iter()
.find(|e| e.info.id == id && e.info.category == "loaded")
.map(|e| (e.path.clone(), e.lib.clone()))
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
Some(p) => (file_name(&p), read_file(&p)?),
None => (library_file_name(lib.name(), ext), encode(&lib)?),
};
self.rescan();
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
Some(same) => (same, None),
None => {
create_dir(&dir)?;
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
let file = free_name(&file, &mut taken);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
write_new_file(&path, &bytes)?;
self.rescan();
(format!("user/{file}"), Some(path))
}
};
let Some((info, lib)) = self.get(&user_id) else {
if let Some(path) = &written {
// Best effort: the copy that can't be read is all there is to clean up.
let _ = std::fs::remove_file(path);
}
return Err(bad(cmd, format!("the copy `{user_id}` can't be read as a library")));
};
self.extra.retain(|x| x.info.id != id);
self.copied.retain(|(loaded, _)| loaded != id);
self.copied.push((id.to_string(), info.id.clone()));
Ok((info, lib, written.is_some()))
}
}
/// The file name of library `name` with extension `ext`: characters file systems reject become
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
pub fn library_file_name(name: &str, ext: &str) -> String {
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let base = base.trim_matches(['.', ' ']);
format!("{}.{ext}", if base.is_empty() { "Library" } else { base })
}
/// The library files with extensions `exts` in folder `dir`, sorted by name.
@@ -343,15 +444,16 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.save";
let path = str_param(p, "path");
let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?,
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?,
None => PaletteFormat::Native,
};
let st = s.doc()?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
let (count, text) = (lib.len(), palette_io::write(&lib, format));
let mut out = json!({"format": format.id(), "count": count});
s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?;
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
let mut out = json!({"format": format.id(), "count": lib.len()});
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
Ok(out)
}
@@ -383,6 +485,29 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// There is a user library folder to copy libraries into (the desktop app sets one).
fn has_user_folder(s: &Session) -> std::result::Result<(), String> {
match s.swatch_libraries.user_dir() {
Some(_) => Ok(()),
None => Err("no user library folder here".into()),
}
}
fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.copyToUser";
let (info, lib) = library_param(s, p, C)?;
let (info, lib, copied) = match info.category {
"loaded" => {
let native = |lib: &SwatchLibrary| palette_io::write(lib, PaletteFormat::Native).map_err(|e| bad(C, e));
s.swatch_libraries.copy_to_user(&info.id, PaletteFormat::Native.id(), native, C)?
}
"user" => (info, lib, false),
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
};
let path = s.swatch_libraries.path(&info.id);
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all.
fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec<(Option<&'a str>, &'a Swatch)>> {
+7 -1
View File
@@ -316,6 +316,10 @@ impl CharChange {
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
}
@@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
let db = vectorcraft_text::FontDb::global();
let mut faces = std::collections::HashMap::new();
let mut covers = |st: &CharStyle, c: char| {
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
let face = faces
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
+11
View File
@@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
node.appearance = st.appearance();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance();
children.push(Arc::new(node));
}
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
d.remove(tid)?;
if children.is_empty() {
@@ -416,6 +423,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &style {
st.font_style = f.clone();
}
// Another font: none of the old one's versions.
if font.is_some() || style.is_some() {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
}