Files
OneCAD-Draft/src/io/font_repo.rs
T

335 lines
12 KiB
Rust

// Community font repository — detect missing SHX fonts and fetch them from
// the project's `fonts/` folder on GitHub.
//
// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …).
// When the file doesn't resolve on disk the renderer substitutes an embedded
// LFF stroke font, which mangles drawings authored with fonts it cannot stand
// in for. This module detects those references and downloads the real files
// from the community folder, where anyone can contribute (see
// `fonts/README.md` in the repository root).
use std::path::{Path, PathBuf};
use codec::CadDocument;
/// The community folder's GitHub contents API (lists name + download URL).
#[cfg(not(target_arch = "wasm32"))]
const REPO_API_URL: &str = "https://api.github.com/repos/HakanSeven12/OpenCADStudio/contents/fonts";
#[cfg(not(target_arch = "wasm32"))]
const MAX_FONT_BYTES: u64 = 16 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
static FONTS_FOLDER: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
/// A folder chosen in Options to hold the fonts instead of the default one,
/// such as a share on the office network.
#[cfg(not(target_arch = "wasm32"))]
pub fn fonts_folder() -> Option<PathBuf> {
FONTS_FOLDER
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_fonts_folder(folder: Option<PathBuf>) {
*FONTS_FOLDER.write().unwrap_or_else(|e| e.into_inner()) = folder;
}
/// Local store for fonts downloaded from the community repository. Text
/// resolution searches here after the drawing folder.
#[cfg(not(target_arch = "wasm32"))]
pub fn fonts_dir() -> Option<PathBuf> {
fonts_folder().or_else(|| crate::config::config_dir().map(|dir| dir.join("fonts")))
}
/// Web builds have no filesystem to cache fonts into.
#[cfg(target_arch = "wasm32")]
pub fn fonts_dir() -> Option<PathBuf> {
None
}
/// Where missing fonts are fetched from. The community repository is the
/// default; a custom source is any HTTP folder - a company file server, a
/// private GitHub repo's raw folder - where each font lives as
/// `{base}/{file_name}`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FontSource {
Community,
Custom(String),
}
/// Stable key for comparing bare font names without path or case differences.
pub fn font_key(name: &str) -> String {
name.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim()
.to_ascii_lowercase()
}
impl FontSource {
/// Empty / whitespace input selects the community repository.
pub fn from_url(url: &str) -> Self {
match url.trim() {
"" => FontSource::Community,
trimmed => FontSource::Custom(trimmed.trim_end_matches('/').to_string()),
}
}
}
/// One file listed in the community folder.
#[derive(Debug, Clone, PartialEq)]
pub struct RepoFont {
pub name: String,
pub download_url: String,
}
/// Referenced-but-unresolvable `.shx` font files across the document's text
/// styles. Names are deduplicated case-insensitively and reduced to the bare
/// file name (`C:\Fonts\VNArial.shx` → `VNArial.shx`).
pub fn missing_shx_fonts(doc: &CadDocument) -> Vec<String> {
let mut missing: Vec<String> = Vec::new();
let base = doc
.source_path
.as_deref()
.map(Path::new)
.and_then(|p| p.parent());
for style in doc.text_styles.iter() {
// The primary font and the big font (Asian double-byte glyphs) are
// both plain .shx files the style depends on.
for file in [style.font_file.trim(), style.big_font_file.trim()] {
if file.is_empty()
|| !file.to_ascii_lowercase().ends_with(".shx")
|| crate::io::linetypes::is_bundled_shape_file(file)
{
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
}
}
}
missing.sort_by_key(|name| name.to_ascii_lowercase());
missing
}
/// A font file previously downloaded into the local fonts directory, matched
/// case-insensitively by file name.
pub fn local_font_file(name: &str) -> Option<PathBuf> {
let file = name.rsplit(['/', '\\']).next()?;
let dir = fonts_dir()?;
let direct = dir.join(file);
if direct.is_file() {
return Some(direct);
}
let lower = file.to_ascii_lowercase();
std::fs::read_dir(&dir)
.ok()?
.filter_map(|entry| entry.ok())
.find(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase() == lower)
.map(|entry| entry.path())
}
/// List the community folder through the GitHub contents API.
#[cfg(not(target_arch = "wasm32"))]
fn fetch_repo_fonts() -> Result<Vec<RepoFont>, String> {
let agent = crate::network::agent(std::time::Duration::from_secs(15));
let mut response = agent
.get(REPO_API_URL)
.header(
"User-Agent",
concat!("OpenCADStudio/", env!("OCS_APP_VERSION")),
)
.header("Accept", "application/vnd.github+json")
.call()
.map_err(|e| format!("Could not reach the font repository: {e}"))?;
let body = response
.body_mut()
.read_to_string()
.map_err(|e| format!("Could not read the font repository listing: {e}"))?;
parse_contents(&body)
}
/// Parse the GitHub contents-API response:
/// `[{"name":"romans.shx","download_url":"https://…"}, …]`.
#[cfg(not(target_arch = "wasm32"))]
fn parse_contents(body: &str) -> Result<Vec<RepoFont>, String> {
let value: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Unexpected font listing: {e}"))?;
let items = value
.as_array()
.ok_or_else(|| "Unexpected font-repository response.".to_string())?;
Ok(items
.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let url = item.get("download_url")?.as_str()?.to_string();
(!name.is_empty() && !url.is_empty()).then_some(RepoFont {
name,
download_url: url,
})
})
.collect())
}
/// Download every `missing` font that exists in the community folder into the
/// local fonts directory. Returns the (requested, saved-path) pairs that were
/// actually fetched; names absent from the repository are skipped.
#[cfg(not(target_arch = "wasm32"))]
pub fn download_fonts(
missing: &[String],
source: &FontSource,
) -> Result<Vec<(String, PathBuf)>, String> {
if missing.is_empty() {
return Ok(Vec::new());
}
let repo = match source {
FontSource::Community => Some(fetch_repo_fonts()?),
FontSource::Custom(_) => None,
};
let dir = fonts_dir().ok_or_else(|| "Could not resolve the fonts folder.".to_string())?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
let mut downloaded = Vec::new();
for want in missing {
let file = want.rsplit(['/', '\\']).next().unwrap_or(want);
let url = match (source, repo.as_ref()) {
// Community: download URLs come from the listing, matched by name.
(FontSource::Community, Some(repo)) => {
let lower = file.to_ascii_lowercase();
repo.iter()
.find(|f| f.name.to_ascii_lowercase() == lower)
.map(|f| f.download_url.clone())
}
// Custom folder: the font is simply `{base}/{file_name}`; a miss
// (404) is skipped without failing the batch.
(FontSource::Custom(base), _) => Some(format!("{base}/{}", percent_encode_path(file))),
(FontSource::Community, None) => None,
};
let Some(url) = url else { continue };
let agent = crate::network::agent(std::time::Duration::from_secs(30));
let mut response = agent
.get(&url)
.header(
"User-Agent",
concat!("OpenCADStudio/", env!("OCS_APP_VERSION")),
)
.call()
.map_err(|e| format!("Could not download {file}: {e}"))?;
if response.status() != 200 {
continue;
}
let bytes = response
.body_mut()
.with_config()
.limit(MAX_FONT_BYTES)
.read_to_vec()
.map_err(|e| format!("Could not download {file}: {e}"))?;
if bytes.is_empty() {
continue;
}
let dest = dir.join(file);
std::fs::write(&dest, &bytes)
.map_err(|e| format!("Could not save {}: {e}", dest.display()))?;
downloaded.push((file.to_string(), dest));
}
Ok(downloaded)
}
/// The web build has no network client, and no fonts folder to save into.
#[cfg(target_arch = "wasm32")]
pub fn download_fonts(
missing: &[String],
_source: &FontSource,
) -> Result<Vec<(String, PathBuf)>, String> {
if missing.is_empty() {
return Ok(Vec::new());
}
Err("Downloading fonts is not available in the web app.".to_string())
}
/// Escape path characters that would break a URL (spaces and non-ASCII file
/// names are common in font sets).
#[cfg(not(target_arch = "wasm32"))]
fn percent_encode_path(file: &str) -> String {
let mut out = String::with_capacity(file.len());
for byte in file.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_github_contents_listing() {
let body = r#"[
{"name":"romans.shx","path":"fonts/romans.shx",
"download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"},
{"name":"subset","path":"fonts/subset","type":"dir"},
{"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"}
]"#;
let fonts = parse_contents(body).unwrap();
assert_eq!(fonts.len(), 1);
assert_eq!(fonts[0].name, "romans.shx");
assert!(fonts[0].download_url.ends_with("/fonts/romans.shx"));
}
#[test]
fn custom_source_urls_are_trimmed_and_encoded() {
assert_eq!(FontSource::from_url(""), FontSource::Community);
assert_eq!(
FontSource::from_url(" https://intra/fonts/ "),
FontSource::Custom("https://intra/fonts".into())
);
assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx");
}
#[test]
fn font_keys_are_bare_trimmed_and_case_insensitive() {
assert_eq!(font_key(r#" C:\Fonts\ROMANS.SHX "#), "romans.shx");
assert_eq!(font_key("folder/simplex.shx"), "simplex.shx");
}
#[test]
fn rejects_a_non_listing_response() {
assert!(parse_contents("{\"message\":\"Not Found\"}").is_err());
}
#[test]
fn missing_fonts_deduplicate_by_name_case_insensitively() {
// Two styles referencing the same missing font with different casing
// and one whose file resolves next to the drawing must yield one hit.
let dir = std::env::temp_dir().join("ocs_font_repo_tests");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("exists.shx"), b"stub").unwrap();
let mut doc = CadDocument::new();
let mk = |name: &str, file: &str| {
let mut style = codec::TextStyle::new(name);
style.font_file = file.into();
style
};
let _ = doc.text_styles.add(mk("A", "MISSING1.shx"));
let _ = doc.text_styles.add(mk("B", "missing1.SHX"));
let _ = doc.text_styles.add(mk("C", "exists.shx"));
doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned());
let missing = missing_shx_fonts(&doc);
assert_eq!(missing, vec!["MISSING1.shx".to_string()]);
let _ = std::fs::remove_file(dir.join("exists.shx"));
}
}