diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7..24536ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -871,6 +884,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1782,10 +1817,12 @@ dependencies = [ name = "figcraft-text" version = "0.11.2" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -5982,7 +6019,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 84a80858..5322ca58 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -18,3 +18,13 @@ png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } + +# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font +# folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text +# needs unsafe FFI, which the workspace forbids). 22.0 is the first release whose +# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); +# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older +# versions other crates use. Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +core-text = { version = "=22.0.0", default-features = false, features = ["link"] } +objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 32552837..ced18c81 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -817,9 +817,7 @@ impl FontDb { } self.system_scanned = true; let mut found = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); self.merge_scan(found); } @@ -924,29 +922,45 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { - continue; - } - let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; - for (index, family, style, postscript) in faces_in(&bytes) { - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { - path: path.clone(), - index, - style, - postscript, - }); + index_font_file(&path, &meta, out); + } +} + +fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut BTreeMap)>) { + // Font-like names can also belong to pipes or devices; opening a pipe can block. + // `meta` follows symlinks, so links to regular font files remain supported. + if !meta.is_file() { + return; + } + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + return; + } + // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the + // system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed + // buffer, so only those parts are ever touched. + if meta.len() > MAX_FONT_FILE { + return; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(path, l)) else { return }; + for (index, family, style, postscript) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { + path: path.to_path_buf(), + index, + style, + postscript, + }); + } +} + +fn scan_installed_fonts(out: &mut BTreeMap)>) { + let dirs = system_font_dirs(); + for dir in &dirs { + scan_dir(dir, 0, out); + } + for path in files_outside_dirs(font_manager_files(), &dirs) { + if let Ok(meta) = std::fs::metadata(&path) { + index_font_file(&path, &meta, out); } } } @@ -959,6 +973,28 @@ fn starts_like_a_font(path: &std::path::Path) -> bool { std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") } +fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet { + files.into_iter().filter(|file| file.is_absolute() && !dirs.iter().any(|dir| file.starts_with(dir))).collect() +} + +#[cfg(target_os = "macos")] +fn font_manager_files() -> Vec { + std::panic::catch_unwind(|| { + objc2::rc::autoreleasepool(|_| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "macos"))] +fn font_manager_files() -> Vec { + Vec::new() +} + /// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; @@ -1082,9 +1118,7 @@ pub fn scan_system_fonts() { if !system_fonts_opted_out() { SCANS.fetch_add(1, Ordering::Relaxed); let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); w.system_scanned = true; w.merge_scan(found); @@ -1108,9 +1142,7 @@ pub fn rescan_system_fonts() -> Rescan { return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); if !rescan.changed.is_empty() { crate::clear_layout_cache(); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 72d5eafd..85d1f3a7 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -440,3 +440,54 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { assert!(at(thai).is_some_and(|i| i > latin), "{thai}"); } } + +// The paths are Unix absolute paths (font managers are a macOS thing): on Windows they aren't absolute. +#[cfg(unix)] +#[test] +fn font_manager_files_outside_the_font_folders_are_indexed_once() { + let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; + let library = std::path::PathBuf::from("/Users/a/Fonts.rightfontlibrary/fonts/I/Inter/InterVariable.ttf"); + let files = vec![ + std::path::PathBuf::from("/Library/Fonts/Arial.ttf"), + std::path::PathBuf::from("/Users/a/Library/Fonts/Sub/Brand.otf"), + library.clone(), + library.clone(), + std::path::PathBuf::from("relative/Font.ttf"), + std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), + ]; + let outside: Vec<_> = files_outside_dirs(files, &folders).into_iter().collect(); + assert_eq!(outside, [std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), library]); +} + +#[test] +fn a_font_file_outside_the_font_folders_is_indexed_from_its_path() { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-manager-{}", std::process::id()))); + std::fs::create_dir_all(&tmp.0).unwrap(); + let copy = tmp.0.join("Activated.ttf"); + std::fs::copy(&font, ©).unwrap(); + let mut pending = BTreeMap::new(); + index_font_file(©, &std::fs::metadata(©).unwrap(), &mut pending); + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == copy), "{paths:?}"); + let mut none = BTreeMap::new(); + index_font_file(&tmp.0, &std::fs::metadata(&tmp.0).unwrap(), &mut none); + assert!(none.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn core_text_lists_the_installed_font_files() { + let files = font_manager_files(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_absolute())); +} diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3f8dad86..5a976f97 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1719,6 +1719,121 @@ status = "draft — needs native review" "⌘ click" = "⌘ 클릭" "⌘ scroll" = "⌘ 스크롤" "⌥ drag" = "⌥ 드래그" +"A responsive product page: hero, features, figures, a quote and a footer" = "대표 영역, 기능, 수치, 인용문, 푸터로 구성된 반응형 제품 페이지" +"A title, an agenda, a big figure, a chart and a closing slide" = "제목, 목차, 주요 수치, 차트, 마무리 슬라이드" +"A to Z" = "이름 오름차순" +"Adding to {name}" = "{name}에 추가 중" +"All the fonts this file uses are installed" = "이 파일에 사용된 모든 글꼴이 설치되어 있습니다." +"Alphabetical" = "가나다순" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "수치, 벡터 차트, 표를 격자로 배치한 분석 화면" +"Apps" = "앱" +"Apr" = "4월" +"Aug" = "8월" +"Bottom padding" = "아래쪽 안쪽 여백" +"Bound to {name}. Click to detach the variable." = "{name}에 연결되어 있습니다. 클릭하면 변수 연결을 해제합니다." +"Checking the fonts installed on this computer…" = "이 컴퓨터에 설치된 글꼴 확인 중…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "색상 및 텍스트 스타일, 밝게·어둡게 변수, 버튼, 입력란, 카드" +"Convert to frame" = "프레임으로 변환" +"Convert to section" = "섹션으로 변환" +"Couldn't download the variables" = "변수를 다운로드할 수 없습니다." +"Created" = "만든 날짜" +"Dashboard" = "대시보드" +"Date created" = "만든 날짜" +"Dec" = "12월" +"Design system starter" = "디자인 시스템 시작 키트" +"Draw a frame" = "프레임 그리기" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = ".figcraft, .fig, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop to import" = "놓아서 가져오기" +"Drop to open" = "놓아서 열기" +"Edited {when}" = "{when} 편집" +"Export frames and layers as PNG, JPG, SVG or PDF" = "프레임과 레이어를 PNG, JPG, SVG 또는 PDF로 내보내기" +"Feb" = "2월" +"File not found" = "파일을 찾을 수 없습니다." +"Four screens of a trail app with components and a prototype flow" = "컴포넌트와 프로토타입 흐름이 포함된 트레일 앱 화면 4개" +"Getting started" = "시작하기" +"Greyscale components and two example screens to sketch a flow with" = "흐름을 구상할 수 있는 회색 음영 컴포넌트와 예시 화면 2개" +"Hide these tips (Getting started in the Help menu shows them again)" = "팁 숨기기(도움말 메뉴의 시작하기에서 다시 표시)" +"Hide when publishing" = "게시할 때 숨기기" +"Icon components on a 24 px grid with keylines and a size preview" = "키라인과 크기 미리 보기가 포함된 24px 격자의 아이콘 컴포넌트" +"Icon set starter" = "아이콘 세트 시작 키트" +"Individual padding" = "개별 안쪽 여백" +"Interface size" = "인터페이스 크기" +"Jan" = "1월" +"Jul" = "7월" +"Jun" = "6월" +"Kits" = "키트" +"Landing page" = "랜딩 페이지" +"Last modified" = "마지막 수정" +"Left padding" = "왼쪽 안쪽 여백" +"Link frames on the Prototype tab, then present the flow" = "프로토타입 탭에서 프레임을 연결한 다음 흐름을 실행해 보세요." +"Make a component" = "컴포넌트 만들기" +"Mar" = "3월" +"May" = "5월" +"Missing fonts" = "없는 글꼴" +"Mobile app" = "모바일 앱" +"Newest first" = "최신순" +"No files or templates match your search" = "검색 조건에 맞는 파일이나 템플릿이 없습니다." +"No other pages" = "다른 페이지 없음" +"No templates match your search" = "검색 조건에 맞는 템플릿이 없습니다." +"Nov" = "11월" +"Oct" = "10월" +"Oldest first" = "오래된순" +"One announcement in five common sizes: square, portrait, story, link and banner" = "하나의 공지를 정사각형, 세로형, 스토리, 링크, 배너의 일반적인 5가지 크기로 구성" +"Opening the file…" = "파일 여는 중…" +"Opening {n} files as new files" = { other = "파일 {n}개를 새 파일로 여는 중" } +"Order" = "순서" +"Other" = "기타" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "프레임 도구를 선택한 뒤 캔버스에서 드래그하거나 기기 크기를 고르세요." +"Plugin" = "플러그인" +"Plugins & widgets" = "플러그인 및 위젯" +"Presentation slides" = "프레젠테이션 슬라이드" +"Playground" = "실험 공간" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "시스템 디스플레이 배율을 기준으로 합니다(100%는 시스템 기본 크기). 문서 확대/축소는 별도입니다." +"Remove from recents" = "최근 목록에서 제거" +"Reorder selection" = "선택 항목 순서 변경" +"Right padding" = "오른쪽 안쪽 여백" +"Search templates" = "템플릿 검색" +"See all templates" = "모든 템플릿 보기" +"Select layers and they space themselves as their content changes" = "레이어를 선택하면 내용 변경에 맞춰 간격이 자동으로 조정됩니다." +"Search files and templates" = "파일 및 템플릿 검색" +"Search results" = "검색 결과" +"Search pages" = "페이지 검색" +"Sep" = "9월" +"Share with a code" = "코드로 공유" +"Show all templates" = "모든 템플릿 표시" +"Show in Explorer" = "탐색기에서 보기" +"Show in Finder" = "Finder에서 보기" +"Show in folder" = "폴더에서 보기" +"Social and slides" = "소셜 및 슬라이드" +"Social post sizes" = "소셜 게시물 크기" +"Sort by" = "정렬 기준" +"Sort by {column}" = "{column} 기준 정렬" +"Start from a template" = "템플릿으로 시작" +"Start sharing with the Share button, then pass on the code it gives" = "공유 버튼으로 공유를 시작한 뒤 표시되는 코드를 전달하세요." +"The destination document is unavailable. Nothing was imported." = "대상 문서를 사용할 수 없습니다. 아무것도 가져오지 않았습니다." +"The destination document was closed. Nothing was imported." = "대상 문서가 닫혔습니다. 아무것도 가져오지 않았습니다." +"The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "창이 너무 작아 {size}% 대신 {drawn}%로 표시합니다. {size}%를 사용하려면 창을 키우세요." +"This file is too large to join here (up to {mb} MB)" = "이 파일은 여기에 추가하기에 너무 큽니다(최대 {mb}MB)." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "이 파일은 이 컴퓨터에 없는 글꼴을 사용합니다. 해당 글꼴을 설치할 때까지 대체 글꼴로 표시합니다." +"Top padding" = "위쪽 안쪽 여백" +"Try another search, or browse templates." = "다른 검색어를 입력하거나 템플릿을 둘러보세요." +"Try another word, or look at every kind." = "다른 단어를 검색하거나 모든 종류를 확인하세요." +"Turn a layer into a component, reuse it, and change every copy at once" = "레이어를 컴포넌트로 만들고 재사용하여 모든 사본을 한 번에 변경하세요." +"Use your browser's zoom to change the interface size." = "인터페이스 크기는 브라우저의 확대/축소로 변경하세요." +"Uses variables: each layer shows them in its own mode" = "변수 사용: 각 레이어의 모드에 따라 표시합니다." +"Websites" = "웹사이트" +"What you try here changes this preview and the code below, not the file" = "여기서 변경한 내용은 미리 보기와 아래 코드에만 적용되며 파일은 바뀌지 않습니다." +"Widget" = "위젯" +"Wireframe kit" = "와이어프레임 키트" +"Z to A" = "이름 내림차순" +"just now" = "방금" +"only in a text style or variable" = "텍스트 스타일 또는 변수에서만 사용" +"slot layers without a matching slot property were imported as frames" = "일치하는 슬롯 속성이 없는 슬롯 레이어를 프레임으로 가져왔습니다." +"yesterday" = "어제" +"{month} {day}, {year}" = "{year}년 {month} {day}일" +"{n} text layers" = { other = "텍스트 레이어 {n}개" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "아래쪽 정렬" @@ -2312,6 +2427,24 @@ status = "draft — needs native review" "widget.setText" = "위젯 필드 편집" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" +"arrange.reorder" = "선택 항목 순서 변경" +"component.hideWhenPublishing" = "게시할 때 숨기기" +"object.convertToGroup" = "그룹으로 변환" +"object.convertToFrame" = "프레임으로 변환" +"object.convertToSection" = "섹션으로 변환" +"text.rescanFonts" = "설치된 글꼴 다시 검색" +"text.loadFont" = "글꼴 파일 불러오기" +"view.outlinesHiddenLayers" = "윤곽선에 숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "윤곽선에 개체 경계 포함" +"view.panelWidths" = "패널 너비 초기화" +"pref.uiScale" = "인터페이스 크기" +"help.gettingStarted" = "시작하기" +"style.bind" = "스타일에 변수 연결" +"component.playground" = "컴포넌트 실험실" +"text.missingFonts" = "누락된 글꼴" +"text.refit" = "글꼴에 맞게 텍스트 조정" +"template.list" = "템플릿 목록" +"file.newFromTemplate" = "템플릿에서 새로 만들기" [menu] "bool.exclude" = "제외" @@ -2343,3 +2476,5 @@ status = "draft — needs native review" "view.themeDark" = "다크" "view.themeLight" = "라이트" "view.themeSystem" = "시스템 따르기" +"view.outlinesHiddenLayers" = "숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "개체 경계 포함" diff --git a/docs/cli.md b/docs/cli.md index 1e1e1967..68e37823 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,12 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the +system's downloadable fonts) are read, and so is every other font file Core Text lists as +available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own +library, without copying them into a font folder. Fonts activated while the app runs are found by +`text.rescanFonts`, like newly installed ones. + On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them (`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while they're active there. Files without an extension are read when they start like a font file.