Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions crates/doc/src/links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,31 @@ pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo
}))
}

/// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]).
const FNV_BASIS: u64 = 0xcbf29ce484222325;

/// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`.
fn fnv1a(h: u64, bytes: &[u8]) -> u64 {
bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
}

/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
pub fn hash_bytes(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
format!("img{h:016x}")
format!("img{:016x}", fnv1a(FNV_BASIS, bytes))
}

/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result<String> {
let mut buf = vec![0; 1 << 16];
let mut h = FNV_BASIS;
loop {
match r.read(&mut buf) {
Ok(0) => return Ok(format!("img{h:016x}")),
Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}

impl ImageBlob {
Expand Down Expand Up @@ -370,6 +391,14 @@ mod tests {
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}

#[test]
fn a_reader_hashes_as_its_bytes_do() {
// Several 64 KB reads, and none.
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
}

#[test]
fn large_images_get_a_small_png_preview() {
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
Expand Down
45 changes: 45 additions & 0 deletions crates/engine/src/cmd/fileio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,51 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
})
}

/// The highest number [`copy_new`] gives a copy's name (`Name 99.png`).
#[cfg(not(target_arch = "wasm32"))]
const MAX_COPY_NUMBER: u32 = 99;

/// Where [`copy_new`] put a file.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) enum Placed {
/// Copied to this new file.
Copied(std::path::PathBuf),
/// Kept as this file, which holds the same contents.
Kept(std::path::PathBuf),
}

/// Put a copy of the file `from` into the folder `dir`, never replacing a file there. It tries
/// `Name.ext`, then `Name 2.ext` up to `Name 99.ext`, and stops at the first name that is free,
/// where `create` makes the copy, or that is a file holding the same contents (`same`, given that
/// file and its metadata), which is kept. When `create` fails with `AlreadyExists` (the name was
/// taken meanwhile), it tries the next name. `None` when no name is free or holds the same
/// contents; a name whose metadata can't be read counts as taken.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn copy_new(
from: &std::path::Path,
dir: &std::path::Path,
create: impl Fn(&std::path::Path) -> std::io::Result<()>,
same: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool,
) -> std::io::Result<Option<Placed>> {
use std::io::ErrorKind;
let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(ErrorKind::InvalidInput.into()) };
let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
for n in 1..=MAX_COPY_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(&to) {
Ok(()) => return Ok(Some(Placed::Copied(to))),
// Taken meanwhile: the next name.
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
},
Ok(m) if m.is_file() && same(&to, &m) => return Ok(Some(Placed::Kept(to))),
_ => {}
}
}
Ok(None)
}

/// The most entries `file.list` returns.
const LIST_MAX: usize = 5000;

Expand Down
51 changes: 17 additions & 34 deletions crates/engine/src/cmd/fontfiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ const MAX_ADD_FILES: usize = 1_000;
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"))]
Expand Down Expand Up @@ -88,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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?}",
"{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: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; 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
),
Expand Down Expand Up @@ -367,18 +364,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
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.
/// Copy `files` into `dir`, never replacing a file there ([`super::fileio::copy_new`]): the names
/// `Name.otf`, `Name 2.otf` up to `Name 99.otf` are tried in turn until one is free, which gets
/// the copy, or is a file with the same contents, which is kept. 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 {
use super::fileio::Placed;
let mut report = CopyReport::default();
let mut total = 0;
for from in files {
Expand All @@ -398,10 +390,10 @@ pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport {
}

#[cfg(not(target_arch = "wasm32"))]
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Placed, String> {
use std::io::ErrorKind;
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<super::fileio::Placed, String> {
use super::fileio::Placed;
let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?;
if !meta.is_file() {
if !meta.is_file() || from.file_name().is_none() {
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)));
Expand All @@ -418,25 +410,16 @@ fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Pla
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)),
_ => {}
let create = |to: &Path| create_copy(from, to, fork.as_deref());
match super::fileio::copy_new(from, dir, create, |to, m| same_file(from, to, m.len(), len, fork.as_deref())) {
Ok(Some(Placed::Copied(to))) => {
*total += len;
Ok(Placed::Copied(to))
}
Ok(Some(kept)) => Ok(kept),
Ok(None) => Err(NO_NAME.into()),
Err(e) => Err(e.to_string()),
}
Err(NO_NAME.into())
}

/// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose
Expand Down
Loading
Loading