Browse / github.com/storytold/figcraft / patch
M6: persist fonts owned by a document
M6: persist fonts owned by a document Extracts portable font persistence and document scopes from #1334 for the owner's font-policy decision. Native documents, versions and branches retain bounded font bytes and ownership, with validation and warnings for missing or invalid data. No font assets or downloads are added. Font selection is scoped to the document for measurement, canvas rendering (including parallel bands), SVG, PDF and Android XML export. Invalid members consume the shared read budget; the unused global-registration helper is removed. Regression coverage includes ownership, malformed archives, isolation and read bu
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Guillergood (imported from pull request #1441, open)
- Base
bdb7e846060964510dcef1e49d235d0c74dda30b- Size
- +693 −75 in 26 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0dd46dc38d3a9ebf428bd033
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/doc/src/lib.rs crates/engine/src/cmd/file/load.rs crates/engine/src/cmd/file/open.rs crates/engine/src/cmd/file/rest_import_tests.rs crates/engine/src/derived.rs crates/engine/src/measure.rs crates/engine/src/session/exec.rs crates/format/Cargo.toml crates/format/src/large_tests.rs crates/format/src/lib.rs crates/pdf/src/lib.rs crates/render/src/lib.rs crates/svg/src/lib.rs crates/text/src/fontdb.rs crates/text/src/layout.rs crates/text/src/lib.rs crates/ui-egui/src/app.rs crates/ui-egui/src/canvas/comments.rs crates/ui-egui/src/dialogs/branch_ui.rs crates/ui-egui/src/home/home_recents.rs crates/ui-egui/src/panels/dev_compare.rs crates/ui-egui/src/panels/versions.rs crates/ui-egui/src/widgets/style_picker.rs crates/vectordrawable/src/lib.rs docs/file-format-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-27** · M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged.+ - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.@@ -348,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |@@ -182,6 +182,14 @@ pub struct Document { /// Images by content hash; serialized separately by the format crate. #[serde(skip)] pub images: BTreeMap<String, ImageData>,+ /// Font files belonging to this design (SHA-256 -> original OpenType/TrueType bytes).+ /// Serialized as separate `fonts/<sha256>` entries in `.figcraft`, never in JSON.+ #[serde(skip)]+ pub embedded_fonts: BTreeMap<String, Arc<Vec<u8>>>,+ /// Redistribution notices for document-owned fonts, indexed by font SHA-256.+ /// Stored in document JSON so portable fonts retain their license on save/reopen.+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]+ pub embedded_font_licenses: BTreeMap<String, String>, /// The page shown when the file opens. #[serde(default)] pub current_page: Option<NodeId>,@@ -249,6 +257,8 @@ impl Document { styles: Vec::new(), variables: Variables::default(), images: BTreeMap::new(),+ embedded_fonts: BTreeMap::new(),+ embedded_font_licenses: BTreeMap::new(), current_page: None, document_colors: Vec::new(), comments: Vec::new(),@@ -108,6 +108,7 @@ pub(super) fn load_with( if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { loaded.doc.name = file_stem(name_hint); }+ validate_document_fonts(&loaded.doc, &mut loaded.issues); Ok((loaded, false)) } }@@ -335,6 +336,18 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma })) } +/// Font data from documents is registered at runtime only; never written into system+/// font folders. SHA-256 ids avoid re-registering the same file on each reopen. A+/// document keeps the bytes so the font survives save/reopen on a different computer.+fn validate_document_fonts(doc: &Document, warnings: &mut Vec<String>) {+ // Never add portable fonts to the process-global font database: a separate open+ // document can contain different bytes with exactly the same family/style.+ let families = figcraft_text::document_font_families(doc);+ if families.is_empty() && !doc.embedded_fonts.is_empty() {+ warnings.push("Embedded font data is not readable; original family names were preserved".into());+ }+}+ /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) {@@ -155,11 +155,17 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> { None => &figcraft_format::rest::no_images, }; let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };- let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;+ let (mut other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; let summary = import_summary(&other.doc);- let pages = append_pages(s.doc_mut()?, &other.doc)?;+ let pages = append_pages(s.doc_mut()?, &other.doc, &mut other.issues)?;+ if !other.issues.is_empty() {+ s.message = Some(match other.issues.as_slice() {+ [one] => format!("{}: {one}", file_stem(&name)),+ more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),+ });+ } let mut out = json!({"pages": pages, "summary": summary});- if rest {+ if rest || !other.issues.is_empty() { out["warnings"] = json!(other.issues); } Ok(out)@@ -178,7 +184,11 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> { /// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers /// their source and interactions and flows their targets wherever those are. Styles, variables and /// collections whose id the document already has are kept as they are.-pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {+pub(super) fn append_pages(doc: &mut Document, other: &Document, warnings: &mut Vec<String>) -> Result<Vec<NodeId>> {+ let (_, skipped) = figcraft_format::merge_embedded_fonts(doc, other);+ if skipped > 0 {+ warnings.push(format!("{skipped} embedded fonts were skipped (invalid or over the size/count limit)"));+ } for (k, v) in &other.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); }@@ -76,7 +76,7 @@ fn appending_pages_with_cached_library_components_brings_the_components() { for i in 0..8 { doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); }- append_pages(&mut doc, &other).unwrap();+ append_pages(&mut doc, &other, &mut Vec::new()).unwrap(); assert!(doc.validate().is_empty(), "{:?}", doc.validate()); assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();@@ -13,6 +13,7 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> { /// Keep derived state consistent after edits: text boxes resize to their content, groups wrap /// their children, instances follow their main components, auto layout re-flows. pub fn normalize(doc: &mut Document, before: &Document) {+ let _fonts = figcraft_text::enter_document_fonts(doc); // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet<NodeId> = doc.take_dirty(); // A variable or style edit changes no layer directly, but bound and linked layers must follow it.@@ -6,6 +6,7 @@ pub struct TextMeasure; impl figcraft_layout::Measure for TextMeasure { fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64> {+ let _fonts = figcraft_text::enter_document_fonts(doc); let n = doc.node(id)?; let NodeKind::Text(t) = &n.kind else { return None }; let mut t = t.clone();@@ -15,6 +16,7 @@ impl figcraft_layout::Measure for TextMeasure { Some(figcraft_text::layout(&t, Some(width), None).height.max(1.0)) } fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> {+ let _fonts = figcraft_text::enter_document_fonts(doc); let n = doc.node(id)?; let NodeKind::Text(t) = &n.kind else { return None }; if t.auto_resize == TextAutoResize::WidthAndHeight {@@ -27,6 +29,7 @@ impl figcraft_layout::Measure for TextMeasure { Some((l.width.ceil().max(1.0), l.height.max(1.0))) } fn text_baseline(&self, doc: &Document, id: NodeId) -> Option<f64> {+ let _fonts = figcraft_text::enter_document_fonts(doc); let n = doc.node(id)?; let (l, dy) = figcraft_text::layout_node(n)?; l.lines.first().map(|line| line.baseline + dy).filter(|b| b.is_finite())@@ -44,6 +44,8 @@ impl Session { let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid)); // Cut and copy write the clipboard before a later check can refuse the command. let clipboard_before = self.clipboard.clone();+ // The command and its normalisation must measure text using only its own fonts.+ let _fonts = before.as_ref().map(|(doc, _, _, _)| figcraft_text::enter_document_fonts(doc)); let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v))@@ -19,3 +19,6 @@ ruzstd = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } image = { workspace = true }++[dev-dependencies]+skrifa = { workspace = true }@@ -198,9 +198,18 @@ fn the_header_is_read_on_its_own() { for junk in ["[]", "[2]", "3", "null", "\"doc\"", "{", ""] { assert!(matches!(from_json(junk), Err(FormatError::Corrupt(_))), "{junk}"); }- // The image index is read from the header too.- let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();- assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));+ // The image and font indexes come from the single header pass.+ let (_, _, images, fonts) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();+ assert_eq!(images, Some(["ab".to_string()].into_iter().collect()));+ assert_eq!(fonts, Some(BTreeSet::new()));+ let with_fonts = d.replacen("\"fontIndex\":[]", "\"fontIndex\":[\"font-a\",42,\"font-b\"]", 1);+ let (_, _, _, fonts) = parse_json(&with_fonts).unwrap();+ assert_eq!(fonts, Some(["font-a".to_string(), "font-b".to_string()].into_iter().collect()));+ // Older documents have no fontIndex, which preserves the legacy load path.+ let mut legacy: Value = serde_json::from_str(&d).unwrap();+ legacy.as_object_mut().unwrap().remove("fontIndex");+ let (_, _, _, fonts) = parse_json(&legacy.to_string()).unwrap();+ assert_eq!(fonts, None); } #[test]@@ -2,7 +2,8 @@ //! //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version` //! header; layer fields at their default values are left out and read back as those defaults),-//! `images/<sha256>` for every image the document (or a saved version) uses, an optional+//! `images/<sha256>` and `fonts/<sha256>` for binary assets the document, versions or branches+//! use (deduplicated and indexed per snapshot), an optional //! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id, //! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per //! version (a `document.json` of its own). Branches are kept the same way: `branches/index.json`@@ -64,6 +65,92 @@ const BRANCH_INDEX: &str = "branches/index.json"; pub const MAX_BRANCHES: usize = 100; /// Deflate level of JSON members (see [`json_options`]). const JSON_LEVEL: i64 = 3;+/// Limits on font binaries carried with a document (not the system's font registry).+const MAX_FONT_MEMBER: u64 = 32 * 1024 * 1024;+const MAX_FONT_TOTAL: u64 = 128 * 1024 * 1024;+const MAX_FONT_COUNT: usize = 128;+/// Limit missing-font issue messages even when a damaged archive lists many hashes.+const MAX_FONT_ISSUES: usize = 20;++/// Keep diagnostics bounded even for untrusted font indexes with very large hash lists.+#[derive(Default)]+struct MissingFontIssues {+ first: BTreeSet<String>,+ additional_references: usize,+}++impl MissingFontIssues {+ fn note(&mut self, hash: &str) {+ let display = clip(hash, 80);+ if self.first.contains(&display) {+ return;+ }+ if self.first.len() < MAX_FONT_ISSUES {+ self.first.insert(display);+ } else {+ self.additional_references = self.additional_references.saturating_add(1);+ }+ }++ fn report(self, issues: &mut Vec<String>) {+ issues.extend(+ self.first+ .into_iter()+ .map(|hash| format!("Embedded font {hash} is listed in the document or its history but its data is missing; saving again drops it")),+ );+ if self.additional_references > 0 {+ issues.push(format!("{} additional references to missing embedded fonts", self.additional_references));+ }+ }+}++fn valid_font_hash(hash: &str) -> bool {+ hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit())+}++/// Merge portable fonts while respecting the same asset limits as `.figcraft` loading.+/// Existing fonts take priority. Returns the number of new font/license records and the number skipped.+pub fn merge_embedded_fonts(target: &mut Document, source: &Document) -> (usize, usize) {+ let mut used: u64 = target.embedded_fonts.values().map(|b| b.len() as u64).sum();+ let mut accepted = 0;+ let mut skipped = 0;+ for (hash, bytes) in &source.embedded_fonts {+ if target.embedded_fonts.contains_key(hash) {+ continue;+ }+ let len = bytes.len() as u64;+ if !valid_font_hash(hash)+ || !is_sfnt_font(bytes)+ || image_hash(bytes) != *hash+ || len > MAX_FONT_MEMBER+ || used.saturating_add(len) > MAX_FONT_TOTAL+ || target.embedded_fonts.len() >= MAX_FONT_COUNT+ {+ skipped += 1;+ continue;+ }+ used += len;+ target.embedded_fonts.insert(hash.clone(), bytes.clone());+ accepted += 1;+ }+ for (hash, license) in &source.embedded_font_licenses {+ if target.embedded_fonts.contains_key(hash) && !target.embedded_font_licenses.contains_key(hash) {+ target.embedded_font_licenses.insert(hash.clone(), license.clone());+ accepted += 1;+ }+ }+ (accepted, skipped)+}++/// Recognise OpenType/TrueType files, never pass arbitrary ZIP bytes to a font parser.+fn is_sfnt_font(bytes: &[u8]) -> bool {+ bytes.len() >= 12+ && (bytes.starts_with(b"OTTO")+ || bytes.starts_with(b"true")+ || bytes.starts_with(b"typ1")+ || bytes.starts_with(&[0, 1, 0, 0])+ || bytes.starts_with(b"ttcf"))+} #[derive(Debug)] pub enum FormatError {@@ -103,8 +190,8 @@ enum Snapshot { /// Saved in this session (or already read). Doc(Arc<Document>), /// As read from a file: its `document.json`-style JSON, parsed on first use, and the images- /// it uses.- Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData> },+ /// it uses (both kinds of binary assets are shared with the active file).+ Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData>, fonts: BTreeMap<String, Arc<Vec<u8>>> }, } impl Version {@@ -125,10 +212,11 @@ impl Version { pub fn document(&self) -> Result<Arc<Document>, FormatError> { match &self.snapshot { Snapshot::Doc(d) => Ok(d.clone()),- Snapshot::Stored { json, images } => {+ Snapshot::Stored { json, images, fonts } => { let text = std::str::from_utf8(json).map_err(|e| FormatError::Corrupt(e.to_string()))?; let (mut doc, _) = from_json(text)?; doc.images = images.clone();+ doc.embedded_fonts = fonts.clone(); Ok(Arc::new(doc)) } }@@ -141,6 +229,14 @@ impl Version { Snapshot::Stored { images, .. } => images, } }++ /// Portable fonts owned by this exact snapshot, not by the current document.+ pub fn fonts(&self) -> &BTreeMap<String, Arc<Vec<u8>>> {+ match &self.snapshot {+ Snapshot::Doc(d) => &d.embedded_fonts,+ Snapshot::Stored { fonts, .. } => fonts,+ }+ } } /// Where a branch is in its life.@@ -233,13 +329,15 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap<String, ImageData>,+ #[serde(rename = "fontIndex")]+ fonts: Vec<&'a String>, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self {- Self { format: FORMAT, version: VERSION, images: &doc.images, doc }+ Self { format: FORMAT, version: VERSION, images: &doc.images, fonts: doc.embedded_fonts.keys().collect(), doc } } } @@ -445,18 +543,23 @@ pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> { /// Parse `document.json` content. pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {- parse_json(text).map(|(doc, issues, _)| (doc, issues))+ parse_json(text).map(|(doc, issues, _, _)| (doc, issues)) } /// The image hashes a `document.json` lists in its `imageIndex` (`None` without one). type ImageIndex = Option<BTreeSet<String>>;+/// The font hashes a `document.json` lists in its `fontIndex` (`None` without one).+type FontIndex = Option<BTreeSet<String>>;+/// A parsed document, its validation issues, and the image and font indexes.+type ParsedJson = (Document, Vec<String>, ImageIndex, FontIndex); -/// The header of a `document.json` (its format version and image index), read in a pass that-/// skips over everything else without building it.+/// The header of a `document.json` (format version, image and font indexes), read in a pass+/// that skips over everything else without building it. #[derive(Default)] struct Header { version: Option<u64>, images: ImageIndex,+ fonts: FontIndex, } impl<'de> Deserialize<'de> for Header {@@ -472,6 +575,12 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::<String>()? { match key.as_str() { "version" => h.version = map.next_value::<Value>()?.as_u64(),+ "fontIndex" => {+ h.fonts = map+ .next_value::<Value>()?+ .as_array()+ .map(|values| values.iter().filter_map(Value::as_str).map(str::to_owned).collect());+ } "imageIndex" => { h.images = map .next_value::<Value>()?@@ -490,10 +599,10 @@ impl<'de> Deserialize<'de> for Header { } } -/// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first-/// (a newer file is refused before its document is read), then the document is deserialized+/// Parse `document.json` content; also returns its image and font indexes. The header is checked+/// first (a newer file is refused before its document is read), then the document is deserialized /// straight from the text.-fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {+fn parse_json(text: &str) -> Result<ParsedJson, FormatError> { let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string()); let header: Header = serde_json::from_str(text).map_err(corrupt)?; if let Some(ver) = header.version@@ -506,7 +615,7 @@ fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatE if !issues.is_empty() { doc.repair(); }- Ok((doc, issues, header.images))+ Ok((doc, issues, header.images, header.fonts)) } pub(crate) fn io_err(e: impl std::fmt::Display) -> FormatError {@@ -583,6 +692,32 @@ pub fn save_bytes_with_history( put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?; } }+ // A snapshot/branch can use a font no longer present in the current file.+ // Store every distinct binary once, and retain ownership in each index.+ let branch_fonts =+ branches.iter().flat_map(|b| [&b.base.embedded_fonts, &b.head.embedded_fonts].into_iter().chain(b.versions.iter().map(Version::fonts)));+ let mut written_fonts = BTreeSet::new();+ let mut font_bytes = 0u64;+ for (hash, bytes) in std::iter::once(&doc.embedded_fonts).chain(versions.iter().map(Version::fonts)).chain(branch_fonts).flatten() {+ if written_fonts.contains(hash) {+ continue;+ }+ let len = bytes.len() as u64;+ if !valid_font_hash(hash)+ || !is_sfnt_font(bytes)+ || image_hash(bytes) != *hash+ || len > MAX_FONT_MEMBER+ || font_bytes.saturating_add(len) > MAX_FONT_TOTAL+ || written_fonts.len() >= MAX_FONT_COUNT+ {+ // Keep the file saveable. The indexed hash is retained so loading the file+ // reports that its binary is missing, including for versions and branches.+ continue;+ }+ written_fonts.insert(hash);+ font_bytes += len;+ put(&mut z, &format!("fonts/{hash}"), stored, bytes)?;+ } if let Some(t) = thumbnail_png { put(&mut z, "thumbnail.png", stored, t)?; }@@ -598,8 +733,10 @@ pub fn save_bytes_with_history( put_json(&mut z, &format!("branches/{}/head.json", b.id), &b.head)?; put_versions(&mut z, &format!("branches/{}/versions", b.id), &b.versions)?; let images = |d: &Document| d.images.keys().cloned().collect::<Vec<String>>();+ let fonts = |d: &Document| d.embedded_fonts.keys().cloned().collect::<Vec<String>>(); index.push(json!({"id": b.id, "name": b.name, "time": b.time, "client": b.client, "state": b.state,- "baseImages": images(&b.base), "headImages": images(&b.head)}));+ "baseImages": images(&b.base), "headImages": images(&b.head),+ "baseFonts": fonts(&b.base), "headFonts": fonts(&b.head)})); } put(&mut z, BRANCH_INDEX, json_options(), &serde_json::to_vec(&json!({"branches": index})).map_err(io_err)?)?; }@@ -629,7 +766,8 @@ fn put_versions<W: Write + Seek>(z: &mut zip::ZipWriter<W>, dir: &str, versions: Snapshot::Stored { json, .. } => put(z, &name, json_options(), json)?, } let images: Vec<&String> = v.images().keys().collect();- let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});+ let fonts: Vec<&String> = v.fonts().keys().collect();+ let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images, "fonts": fonts}); if v.auto { entry["auto"] = json!(true); }@@ -688,7 +826,7 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0); let limit = document_limit(packed, bytes.len() as u64); let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;- let (mut doc, mut issues, own_images) = parse_json(&text)?;+ let (mut doc, mut issues, own_images, own_fonts) = parse_json(&text)?; let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget); report_lost_images(&mut issues, &unread, &past_budget, own_images.as_ref(), &images); let images_past_budget = past_budget.len();@@ -696,9 +834,11 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F Ok(lib) => doc.published = lib.map(Arc::new), Err(e) => issues.push(format!("the published library was skipped: {e}")), }+ let all_fonts = read_fonts(&mut z, &mut issues, MAX_FONT_TOTAL);+ let mut missing_fonts = MissingFontIssues::default(); let mut budget = MAX_VERSION_BYTES;- let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget);- let branches = read_branches(&mut z, &images, &mut issues);+ let versions = read_versions(&mut z, "versions", &images, &all_fonts, &mut missing_fonts, &mut issues, &mut budget);+ let branches = read_branches(&mut z, &images, &all_fonts, &mut missing_fonts, &mut issues); // Images only the version history or branches use stay with them. let branch_images = branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys())));@@ -709,9 +849,66 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F .filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h))) .collect(); doc.images = images.into_iter().filter(|(h, _)| !history_only.contains(h)).collect();+ doc.embedded_fonts = match own_fonts {+ Some(owned) => select_fonts(&all_fonts, owned.iter().map(String::as_str), &mut missing_fonts),+ None => all_fonts,+ };+ missing_fonts.report(&mut issues); Ok(Loaded { doc, issues, versions, branches, images_past_budget }) } +/// Load font binaries saved by FigCraft, without extracting anything to disk. Damaged+/// fonts are reported instead of silently changing the document's typography.+fn read_fonts<R: Read + Seek>(z: &mut zip::ZipArchive<R>, issues: &mut Vec<String>, mut left: u64) -> BTreeMap<String, Arc<Vec<u8>>> {+ let mut fonts = BTreeMap::new();+ let mut names: Vec<String> = z.file_names().filter(|n| n.starts_with("fonts/")).map(str::to_owned).collect();+ names.sort();+ if names.len() > MAX_FONT_COUNT {+ issues.push(format!("{} fonts exceed the embedded-font limit; extras were skipped", names.len() - MAX_FONT_COUNT));+ }+ for name in names.into_iter().take(MAX_FONT_COUNT) {+ let Some(hash) = name.strip_prefix("fonts/").filter(|s| valid_font_hash(s)) else {+ issues.push("An embedded font has an invalid filename and was skipped".into());+ continue;+ };+ let limit = left.min(MAX_FONT_MEMBER);+ match read_member(z, &name, limit) {+ Ok(bytes) => {+ left -= bytes.len() as u64;+ if is_sfnt_font(&bytes) && image_hash(&bytes) == hash {+ fonts.insert(hash.to_owned(), Arc::new(bytes));+ } else {+ issues.push(format!("Embedded font {hash} failed validation; original font name stays in the text"));+ }+ }+ Err(err) => {+ // A failed read can have inflated its whole allowance before failing its CRC.+ left = left.saturating_sub(limit);+ issues.push(format!("Embedded font {hash} could not be read: {err}"));+ }+ }+ }+ fonts+}+++/// Fonts listed by one snapshot, sharing their verified bytes with the ZIP reader.+fn select_fonts<'a>(+ all: &BTreeMap<String, Arc<Vec<u8>>>,+ hashes: impl Iterator<Item = &'a str>,+ missing: &mut MissingFontIssues,+) -> BTreeMap<String, Arc<Vec<u8>>> {+ hashes+ .filter_map(|h| match all.get(h) {+ Some(bytes) => Some((h.to_owned(), bytes.clone())),+ None => {+ missing.note(h);+ None+ }+ })+ .collect()+}+ /// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read /// with why. The document's own images (`own`) are read first, so a file past the budget loses /// the images only its history uses before its own.@@ -805,6 +1002,8 @@ fn read_versions<R: Read + Seek>( z: &mut zip::ZipArchive<R>, dir: &str, images: &BTreeMap<String, ImageData>,+ fonts: &BTreeMap<String, Arc<Vec<u8>>>,+ missing_fonts: &mut MissingFontIssues, issues: &mut Vec<String>, budget: &mut u64, ) -> Vec<Version> {@@ -850,13 +1049,14 @@ fn read_versions<R: Read + Seek>( .filter_map(Value::as_str) .filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))) .collect();+ let owned_fonts = e.get("fonts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); out.push(Version { id: id.to_string(), name: text("name", MAX_VERSION_NAME), description: text("description", MAX_VERSION_DESCRIPTION), time: e.get("time").and_then(Value::as_u64).unwrap_or(0), auto: e.get("auto").and_then(Value::as_bool).unwrap_or(false),- snapshot: Snapshot::Stored { json: Arc::new(json), images },+ snapshot: Snapshot::Stored { json: Arc::new(json), images, fonts: select_fonts(fonts, owned_fonts, missing_fonts) }, }); } out@@ -864,7 +1064,13 @@ fn read_versions<R: Read + Seek>( /// The branches of an archive (none when it has no index), parsed. Damaged entries are skipped and /// reported in `issues`; nothing here fails the load.-fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Branch> {+fn read_branches<R: Read + Seek>(+ z: &mut zip::ZipArchive<R>,+ images: &BTreeMap<String, ImageData>,+ fonts: &BTreeMap<String, Arc<Vec<u8>>>,+ missing_fonts: &mut MissingFontIssues,+ issues: &mut Vec<String>,+) -> Vec<Branch> { if z.index_for_name(BRANCH_INDEX).is_none() { return Vec::new(); }@@ -896,6 +1102,9 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S let (mut doc, _) = from_json(std::str::from_utf8(&json).map_err(|e| FormatError::Corrupt(e.to_string()))?)?; let hashes = e.get(list).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); doc.images = hashes.filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))).collect();+ let font_key = if part == "base" { "baseFonts" } else { "headFonts" };+ let owned = e.get(font_key).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);+ doc.embedded_fonts = select_fonts(fonts, owned, missing_fonts); Ok(Arc::new(doc)) }; let (base, head) = match (snapshot("base", "baseImages"), snapshot("head", "headImages")) {@@ -911,7 +1120,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S time: e.get("time").and_then(Value::as_u64).unwrap_or(0), client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0), state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),- versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),+ versions: read_versions(z, &format!("branches/{id}/versions"), images, fonts, missing_fonts, issues, &mut budget), base, head, });@@ -1058,3 +1267,208 @@ mod tests { assert!(add_image(&mut Document::new("x"), b"zzz".to_vec()).is_err()); } }++#[cfg(test)]+mod portable_font_tests {+ use super::*;+ use std::io::Write;++ #[test]+ fn portable_font_survives_save_and_reopen() {+ // Minimal SFNT signature is sufficient for archive persistence; rendering+ // validates font tables in figcraft-text, which can reject this stub.+ let font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];+ let hash = format!("{:x}", sha2::Sha256::digest(&font));+ let mut doc = Document::new("Embedded-font persistence");+ doc.embedded_fonts.insert(hash.clone(), Arc::new(font.clone()));+ doc.embedded_font_licenses.insert(hash.clone(), "SIL OPEN FONT LICENSE Version 1.1".into());+ let bytes = save_bytes(&doc, None).unwrap();+ let (loaded, issues) = load_bytes(&bytes).unwrap();+ assert!(issues.is_empty(), "{issues:?}");+ assert_eq!(loaded.embedded_fonts.get(&hash).map(|a| a.as_slice()), Some(font.as_slice()));+ assert_eq!(loaded.embedded_font_licenses.get(&hash).map(String::as_str), Some("SIL OPEN FONT LICENSE Version 1.1"));+ }++ // Regression: versions and branch heads may own faces that the active file no longer uses.+ // They must survive multiple save/reopen cycles without leaking into the active design.+ #[test]+ fn historical_fonts_survive_save_and_reopen() {+ fn font(seed: u8) -> (String, Arc<Vec<u8>>) {+ let bytes = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, seed];+ (format!("{:x}", sha2::Sha256::digest(&bytes)), Arc::new(bytes))+ }+ let current = Document::new("Current has no fonts");+ let mut historic = Document::new("Old typeface");+ let (version_hash, version_font) = font(1);+ historic.embedded_fonts.insert(version_hash.clone(), version_font.clone());+ historic.embedded_font_licenses.insert(version_hash.clone(), "OFL notice".into());+ let version = Version::new("font-v1", "Typeface", "", 1, Arc::new(historic));++ let mut head = current.clone();+ let (branch_hash, branch_font) = font(2);+ head.embedded_fonts.insert(branch_hash.clone(), branch_font.clone());+ let branch = Branch {+ id: "font-branch".into(),+ name: "Branch".into(),+ time: 2,+ client: 3,+ state: BranchState::Active,+ base: Arc::new(current.clone()),+ head: Arc::new(head),+ versions: vec![version.clone()],+ };+ let bytes = save_bytes_with_history(¤t, None, &[version], &[branch]).unwrap();+ let zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();+ let binary_members = zip.file_names().filter(|n| n.starts_with("fonts/")).count();+ assert_eq!(binary_members, 2, "shared fonts are written once");+ for archive in [bytes.clone(), {+ let loaded = load_bytes_with_versions(&bytes).unwrap();+ save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap()+ }] {+ let loaded = load_bytes_with_versions(&archive).unwrap();+ assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);+ assert!(loaded.doc.embedded_fonts.is_empty(), "history fonts must not leak into the active design");+ let version_doc = loaded.versions[0].document().unwrap();+ assert_eq!(version_doc.embedded_fonts.get(&version_hash), Some(&version_font));+ assert_eq!(version_doc.embedded_font_licenses[&version_hash], "OFL notice");+ assert!(loaded.branches[0].base.embedded_fonts.is_empty());+ assert_eq!(loaded.branches[0].head.embedded_fonts.get(&branch_hash), Some(&branch_font));+ assert_eq!(loaded.branches[0].versions[0].document().unwrap().embedded_fonts.get(&version_hash), Some(&version_font));+ }+ }++ #[test]+ fn skipped_fonts_are_reported_for_active_version_and_branch_owners() {+ let broken = |seed: u8| {+ let bytes = vec![seed; 12]; // Not an SFNT; save deliberately omits it.+ let hash = format!("{:x}", sha2::Sha256::digest(&bytes));+ (hash, Arc::new(bytes))+ };+ let mut doc = Document::new("Active invalid font");+ let (active_hash, active_bytes) = broken(1);+ doc.embedded_fonts.insert(active_hash.clone(), active_bytes);+ let mut previous = Document::new("Version invalid font");+ let (version_hash, version_bytes) = broken(2);+ previous.embedded_fonts.insert(version_hash.clone(), version_bytes);+ let version = Version::new("v1", "Earlier", "", 1, Arc::new(previous));+ let mut head = Document::new("Branch invalid font");+ let (branch_hash, branch_bytes) = broken(3);+ head.embedded_fonts.insert(branch_hash.clone(), branch_bytes);+ let mut branch_previous = Document::new("Branch version invalid font");+ let (branch_version_hash, branch_version_bytes) = broken(4);+ branch_previous.embedded_fonts.insert(branch_version_hash.clone(), branch_version_bytes);+ let branch = Branch {+ id: "branch-1".into(),+ name: "Bad font branch".into(),+ time: 2,+ client: 3,+ state: BranchState::Active,+ base: Arc::new(Document::new("Empty")),+ head: Arc::new(head),+ versions: vec![Version::new("branch-version", "Old branch", "", 1, Arc::new(branch_previous))],+ };+ let bytes = save_bytes_with_history(&doc, None, &[version], &[branch]).unwrap();+ let loaded = load_bytes_with_versions(&bytes).unwrap();+ assert!(loaded.doc.embedded_fonts.is_empty());+ assert_eq!(loaded.versions.len(), 1);+ assert_eq!(loaded.branches.len(), 1);+ for hash in [active_hash, version_hash, branch_hash, branch_version_hash] {+ assert!(loaded.issues.iter().any(|issue| issue.contains(&hash) && issue.contains("missing")), "no issue for {hash}: {:?}", loaded.issues);+ }+ }++ #[test]+ fn missing_font_diagnostics_have_a_fixed_size_budget() {+ let mut doc = Document::new("Many invalid font binaries");+ for seed in 0..(MAX_FONT_ISSUES + 50) {+ let bytes = vec![seed as u8; 12];+ let hash = format!("{:x}", sha2::Sha256::digest(&bytes));+ doc.embedded_fonts.insert(hash, Arc::new(bytes));+ }+ let archive = save_bytes(&doc, None).unwrap();+ let (_, issues) = load_bytes(&archive).unwrap();+ assert_eq!(issues.len(), MAX_FONT_ISSUES + 1, "{issues:?}");+ assert!(issues.last().is_some_and(|message| message.contains("additional references")));+ }++ #[test]+ fn merged_portable_fonts_obey_limits_and_keep_licenses() {+ let mut source = Document::new("Downloaded faces");+ for seed in 0..130u16 {+ let mut font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];+ font.extend(seed.to_le_bytes());+ let hash = image_hash(&font);+ source.embedded_fonts.insert(hash.clone(), Arc::new(font));+ source.embedded_font_licenses.insert(hash, "OFL notice".into());+ }+ let mut target = Document::new("Existing project");+ let (added, skipped) = merge_embedded_fonts(&mut target, &source);+ assert_eq!((added, skipped), (256, 2));+ assert_eq!(target.embedded_fonts.len(), 128);+ assert_eq!(target.embedded_font_licenses.len(), 128);+ // Merging twice never duplicates fonts or license records.+ let (added, skipped) = merge_embedded_fonts(&mut target, &source);+ assert_eq!((added, skipped), (0, 2));+ let bytes = save_bytes(&target, None).unwrap();+ let (loaded, issues) = load_bytes(&bytes).unwrap();+ assert!(issues.is_empty(), "{issues:?}");+ assert_eq!(loaded.embedded_fonts.len(), 128);+ }++ #[test]+ fn invalid_fonts_do_not_prevent_saving_other_assets() {+ let mut doc = Document::new("Invalid font bytes");+ doc.embedded_fonts.insert("not-a-sha256".into(), Arc::new(vec![0, 1, 2, 3]));+ let bytes = save_bytes(&doc, None).unwrap();+ let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();+ assert_eq!(archive.file_names().filter(|n| n.starts_with("fonts/")).count(), 0);+ let (opened, issues) = load_bytes(&bytes).unwrap();+ assert!(opened.embedded_fonts.is_empty());+ assert!(issues.iter().any(|issue| issue.contains("not-a-sha256") && issue.contains("missing")), "{issues:?}");+ }++ #[test]+ fn tampered_embedded_font_is_reported_not_used() {+ let font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];+ let hash = format!("{:x}", sha2::Sha256::digest(&font));+ let mut doc = Document::new("Font integrity");+ doc.embedded_fonts.insert(hash.clone(), Arc::new(font));+ let archive = save_bytes(&doc, None).unwrap();+ let mut source = zip::ZipArchive::new(std::io::Cursor::new(&archive)).unwrap();+ let mut output = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));+ for i in 0..source.len() {+ let mut entry = source.by_index(i).unwrap();+ let name = entry.name().to_owned();+ output.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();+ if name.starts_with("fonts/") {+ output.write_all(b"NOT A VALID FONT").unwrap();+ } else {+ std::io::copy(&mut entry, &mut output).unwrap();+ }+ }+ let tampered = output.finish().unwrap().into_inner();+ let (loaded, issues) = load_bytes(&tampered).unwrap();+ assert!(loaded.embedded_fonts.is_empty());+ assert!(issues.iter().any(|e| e.contains("Embedded font")), "{issues:?}");+ }+}++#[cfg(test)]+mod font_read_budget_tests {+ use super::*;++ #[test]+ fn rejected_font_bytes_still_consume_the_shared_budget() {+ let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));+ for i in 0..4 {+ writer.start_file(format!("fonts/{i:064x}"), zip::write::SimpleFileOptions::default()).unwrap();+ writer.write_all(b"invalid").unwrap();+ }+ let bytes = writer.finish().unwrap().into_inner();+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();+ let mut issues = Vec::new();+ assert!(read_fonts(&mut archive, &mut issues, 14).is_empty());+ assert_eq!(issues.iter().filter(|s| s.contains("failed validation")).count(), 2, "{issues:?}");+ assert_eq!(issues.iter().filter(|s| s.contains("could not be read")).count(), 2, "{issues:?}");+ }+}@@ -95,6 +95,7 @@ pub fn export_nodes_pdf(doc: &Document, ids: &[NodeId]) -> Result<Vec<u8>, PdfEr /// One page per group of ids. For print, a page of one frame is trimmed to the frame's box (its /// shadows and outside strokes go into the bleed). pub fn export_groups(doc: &Document, groups: &[Vec<NodeId>], opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {+ let _fonts = figcraft_text::enter_document_fonts(doc); // All pages are checked before bounds, raster fallbacks or PDF writing start. for group in groups { figcraft_render::check_export_depth(doc, group)?;@@ -188,6 +188,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti /// [`render_view_single`] as part of a render already started (one of its bands). fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {+ let _fonts = figcraft_text::enter_document_fonts(doc); let mut pix = new_pixmap(view.width, view.height)?; let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) { Some(NodeKind::Page { background, .. }) => Some(*background),@@ -235,6 +236,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut /// [`render_nodes_into`] `time` seconds into animated GIFs. fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) { let bounds = draw::Bounds::default();+ let _fonts = figcraft_text::enter_document_fonts(doc); let ctx = draw::Ctx { doc, scale: view.scale,@@ -1028,6 +1028,7 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S /// Like [`export_svg`], with an actionable error when the visible subtree is too deep. No partial /// SVG is returned; the depth limit is relative to each exported root, not the document's page. pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Result<String, SvgError> {+ let _fonts = figcraft_text::enter_document_fonts(doc); check_export_depth(doc, ids)?; let bounds: Rect = match opts.bounds { Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,@@ -1,4 +1,5 @@-//! The font database: embedded craft-fonts faces plus (on desktop) the system's fonts.+//! The global database contains craft-fonts and system faces. Portable document faces are+//! selected only within a scoped operation, never registered globally. use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};@@ -1090,6 +1091,9 @@ pub fn postscript_face(ps: &str) -> Option<(String, String)> { } pub fn find_face(family: &str, style: &str) -> Option<Arc<Face>> {+ if let Some(face) = scoped_document_face(family, style) {+ return Some(face);+ } let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); db.find(family, style).or_else(|| db.default_face()) }@@ -1216,6 +1220,150 @@ pub fn face_with_char(c: char, families: &[&str]) -> Option<Arc<Face>> { families.iter().filter_map(|f| db.find_one(f, "Regular")).find(|f| f.has_char(c)) } +/// Fonts carried by a design are NOT registered in the process-wide system database.+/// Their selection is scoped to one rendering or editing operation, and restored on drop.+/// Concurrent page-rendering threads and other open tabs have independent scopes.+struct DocumentFontScope {+ signature: u64,+ faces: BTreeMap<String, Vec<Arc<Face>>>,+}++std::thread_local! {+ static DOCUMENT_FONTS: std::cell::RefCell<Option<DocumentFontScope>> = const { std::cell::RefCell::new(None) };+}++/// Guard restoring the previous scope even if an operation panics.+pub struct DocumentFontGuard(Option<DocumentFontScope>);++impl Drop for DocumentFontGuard {+ fn drop(&mut self) {+ DOCUMENT_FONTS.with(|cell| {+ *cell.borrow_mut() = self.0.take();+ });+ }+}++/// The complete font selection signature used by the layout cache.+pub(crate) fn document_font_signature() -> u64 {+ DOCUMENT_FONTS.with(|cell| cell.borrow().as_ref().map_or(0, |s| s.signature))+}++/// Find a face carried by the current document, never a face from another tab.+pub(crate) fn scoped_document_face(family: &str, style: &str) -> Option<Arc<Face>> {+ DOCUMENT_FONTS.with(|cell| {+ let scope = cell.borrow();+ let faces = scope.as_ref()?.faces.get(&family.to_lowercase())?;+ let want = norm_style(style);+ if let Some(face) = faces.iter().find(|face| norm_style(&face.style) == want || face.instance(style).is_some()) {+ return Some(face.clone());+ }+ let weight = i32::from(figcraft_doc::style_weight(style));+ let italic = style.to_lowercase().contains("italic");+ faces+ .iter()+ .min_by_key(|face| {+ let w = face.axis("wght").map(|a| a.clamp(f64::from(weight)).round() as i32).unwrap_or(i32::from(face.weight));+ (w - weight).abs() + if face.italic == italic { 0 } else { 1000 }+ })+ .cloned()+ })+}++/// Document-specific family names, for the font inspector and import warnings.+pub fn document_font_families(doc: &figcraft_doc::Document) -> Vec<String> {+ let _scope = enter_document_fonts(doc);+ DOCUMENT_FONTS.with(|cell| {+ cell.borrow().as_ref().map(|s| s.faces.values().flat_map(|fs| fs.first().map(|f| f.family.clone())).collect()).unwrap_or_default()+ })+}++/// Select the portable faces for `doc` for the lifetime of the returned guard.+/// Parsing is cached by binary hash, but the choice of face is never global.+pub fn enter_document_fonts(doc: &figcraft_doc::Document) -> DocumentFontGuard {+ use std::hash::{Hash, Hasher};+ // Most recently used timestamp is a monotonic counter; evict a single least-used face.+ type ParsedCache = (u64, BTreeMap<String, (Vec<Arc<Face>>, u64)>);+ static PARSED: OnceLock<Mutex<ParsedCache>> = OnceLock::new();+ let parsed = PARSED.get_or_init(|| Mutex::new((0, BTreeMap::new())));+ let mut hasher = std::collections::hash_map::DefaultHasher::new();+ let mut faces: BTreeMap<String, Vec<Arc<Face>>> = BTreeMap::new();+ for (hash, bytes) in doc.embedded_fonts.iter().take(128) {+ hash.hash(&mut hasher);+ let own = {+ let mut cached = parsed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+ cached.0 = cached.0.wrapping_add(1);+ let stamp = cached.0;+ if let Some((faces, last_used)) = cached.1.get_mut(hash) {+ *last_used = stamp;+ faces.clone()+ } else {+ let count = match FileRef::new(bytes) {+ Ok(FileRef::Collection(collection)) if collection.len() <= 64 => collection.len(),+ Ok(FileRef::Font(_)) => 1,+ _ => 0,+ };+ let loaded: Vec<Arc<Face>> =+ (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(bytes.clone()), i, None, None).map(Arc::new)).collect();+ // The current scope retains its own Arc<Face>s when a cached entry is evicted.+ if cached.1.len() >= 128+ && let Some(oldest) = cached.1.iter().min_by_key(|(_, (_, used))| *used).map(|(h, _)| h.clone())+ {+ cached.1.remove(&oldest);+ }+ cached.1.insert(hash.clone(), (loaded.clone(), stamp));+ loaded+ }+ };+ for face in own {+ faces.entry(face.family.to_lowercase()).or_default().push(face);+ }+ }+ let signature = if doc.embedded_fonts.is_empty() { 0 } else { hasher.finish() };+ let previous = DOCUMENT_FONTS.with(|cell| cell.replace(Some(DocumentFontScope { signature, faces })));+ DocumentFontGuard(previous)+}++#[cfg(test)]+mod isolated_document_tests {+ use super::*;+ use figcraft_doc::Document;++ #[test]+ fn two_documents_can_use_different_bytes_for_the_same_named_face() {+ scan_system_fonts();+ let original = db().write().unwrap().default_face();+ let Some(original) = original else {+ return;+ }; // Minimal CI can have no font installed.+ let raw = original.bytes.data().to_vec();+ let mut changed = raw.clone();+ // A trailing non-table byte changes the binary identity without changing its+ // OpenType family/style name or glyph metrics.+ changed.push(0x67);+ let mut doc_a = Document::new("A");+ let mut doc_b = Document::new("B");+ doc_a.embedded_fonts.insert("a".repeat(64), Arc::new(raw));+ doc_b.embedded_fonts.insert("b".repeat(64), Arc::new(changed));+ let family = original.family.clone();+ let style = original.style.clone();+ {+ let _a = enter_document_fonts(&doc_a);+ let a = scoped_document_face(&family, &style).unwrap();+ assert_eq!(a.bytes.data(), original.bytes.data());+ {+ let _b = enter_document_fonts(&doc_b);+ let b = scoped_document_face(&family, &style).unwrap();+ assert!(b.bytes.data().ends_with(&[0x67]));+ assert_ne!(a.id, b.id);+ }+ assert_eq!(scoped_document_face(&family, &style).unwrap().id, a.id);+ }+ assert!(scoped_document_face(&family, &style).is_none());+ // Loading neither font changed the process-wide fallback selection.+ assert_eq!(db().write().unwrap().find(&family, &style).unwrap().id, original.id);+ }+}+ #[cfg(test)] #[path = "fontdb_tests.rs"] mod tests;@@ -344,7 +344,11 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len()); let mut style_fallbacks: Vec<Vec<Arc<Face>>> = Vec::with_capacity(styles.len()); for s in &styles {- faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));+ faces.push(+ crate::fontdb::scoped_document_face(&s.font_family, &s.font_style)+ .or_else(|| dbw.find(&s.font_family, &s.font_style))+ .or_else(|| dbw.default_face()),+ ); style_fallbacks.push(dbw.fallbacks_for(&s.font_style)); } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only@@ -16,9 +16,9 @@ mod onpath; pub use breaks::{backspace_step, grapheme_step}; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{- Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,- has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts,- scan_system_fonts_on_demand, system_fonts_scanned,+ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, document_font_families, enter_document_fonts, face_with_char,+ family_names, family_styles, find_face, has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts,+ rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath;@@ -31,6 +31,7 @@ struct Cached { w: Option<u64>, h: Option<u64>, layout: std::sync::Arc<TextLayout>,+ font_scope: u64, } /// Cached layouts by a cheap fingerprint; entries are confirmed with `TextData`'s full equality.@@ -81,8 +82,9 @@ fn fingerprint(t: &figcraft_doc::TextData, w: Option<u64>, h: Option<u64>) -> u6 /// Layout with a process-wide cache keyed by the text content/style and box size. pub fn layout_cached(t: &figcraft_doc::TextData, w: Option<f64>, h: Option<f64>) -> std::sync::Arc<TextLayout> { let (wb, hb) = (w.map(f64::to_bits), h.map(f64::to_bits));- let key = fingerprint(t, wb, hb);- let hit = |c: &Cached| c.w == wb && c.h == hb && c.text == *t;+ let font_scope = fontdb::document_font_signature();+ let key = fingerprint(t, wb, hb) ^ font_scope.rotate_left(17);+ let hit = |c: &Cached| c.w == wb && c.h == hb && c.font_scope == font_scope && c.text == *t; if let Some(l) = layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).buckets.get(&key).and_then(|b| b.iter().find(|c| hit(c))) {@@ -104,7 +106,7 @@ pub fn layout_cached(t: &figcraft_doc::TextData, w: Option<f64>, h: Option<f64>) } else { 0 };- bucket.push(Cached { text: t.clone(), w: wb, h: hb, layout: l.clone() });+ bucket.push(Cached { text: t.clone(), w: wb, h: hb, layout: l.clone(), font_scope }); c.len = c.len + 1 - dropped; l }@@ -219,6 +219,8 @@ impl FigApp { if self.ui.icon_gallery { icons::gallery(&ctx); }+ // Font previews and measurement controls see the same portable faces as the canvas.+ let _fonts = self.session.doc().ok().map(figcraft_text::enter_document_fonts); theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); self.sync_dev_mode();@@ -939,10 +939,8 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun } } // It closes: an open thread reads itself again.- Some(Act::MarkUnread) => {- if app.run("comment.markRead", json!({"id": id, "read": false})).is_some() {- app.ui.comments.close();- }+ Some(Act::MarkUnread) if app.run("comment.markRead", json!({"id": id, "read": false})).is_some() => {+ app.ui.comments.close(); } Some(Act::Run(cmd, p)) => { let thread_deleted = cmd == "comment.delete" && p.get("reply").is_none();@@ -958,7 +956,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun } } }- None => {}+ Some(Act::MarkUnread) | None => {} } } @@ -194,20 +194,16 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { Some(Action::Pick(key, side)) => { view.picks.insert(key, side); }- Some(Action::Update) => {- if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() {- app.toast(tr("Branch updated from main"));- view.picks.clear();- }+ Some(Action::Update) if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() => {+ app.toast(tr("Branch updated from main"));+ view.picks.clear(); }- Some(Action::Merge) => {- if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() {- app.toast(tr("Branch merged"));- open = false;- }+ Some(Action::Merge) if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() => {+ app.toast(tr("Branch merged"));+ open = false; } Some(Action::Close) => open = false,- None => {}+ Some(Action::Update | Action::Merge) | None => {} } if open { app.ui.branch_review = Some(view);@@ -611,10 +611,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize }; action = action.or(chosen); match action {- Some(Action::Open(path)) => {- if app.run_user("file.open", json!({"path": path})).is_some() {- app.ui.home = false;- }+ Some(Action::Open(ref path)) if app.run_user("file.open", json!({"path": path})).is_some() => {+ app.ui.home = false; } Some(Action::Reveal(path)) => app.services.reveal_path(Path::new(&path)), Some(Action::Remove(path)) => {@@ -629,7 +627,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize view.store(&mut app.session.prefs); app.services.save_prefs(&app.session.prefs); }- None => {}+ Some(Action::Open(_)) | None => {} } files.len() }@@ -214,15 +214,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { Some(Action::Select(id)) => { app.run("select.set", json!({"ids": [id]})); }- Some(Action::Resolve) => {- if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() {- app.toast(tr("Change resolved"));- open = false;- }+ Some(Action::Resolve) if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() => {+ app.toast(tr("Change resolved"));+ open = false; } Some(Action::Close) => open = false, Some(Action::Against(v)) => view.version = v,- None => {}+ Some(Action::Resolve) | None => {} } if open { app.ui.dev.compare = Some(view);@@ -541,15 +541,13 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { action }); match action {- Some(true) => {- if app.run("version.restore", json!({"id": id})).is_some() {- app.toast(trf("Restored “{name}”", &[("name", &name)]));- }+ Some(true) if app.run("version.restore", json!({"id": id})).is_some() => {+ app.toast(trf("Restored “{name}”", &[("name", &name)])); } Some(false) => { app.run("version.exitPreview", json!({})); }- None => {}+ Some(true) | None => {} } } @@ -1117,13 +1117,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { app.run("style.delete", json!({"style": id})); p.edit = None; }- Some(Act::ToggleFolder(f)) => {- if !app.ui.style_folds.remove(&f) {- app.ui.style_folds.insert(f);- }+ Some(Act::ToggleFolder(f)) if !app.ui.style_folds.remove(&f) => {+ app.ui.style_folds.insert(f); } Some(Act::ToggleView) => app.ui.style_grid = !app.ui.style_grid,- None => {}+ Some(Act::ToggleFolder(_)) | None => {} } if close { if let Some(e) = &p.edit {@@ -81,6 +81,7 @@ pub enum VdError { /// The layers `ids` (bottom to top) as an Android Vector Drawable. Colours are written as they /// are: convert a Display P3 document to sRGB first (`Document::in_srgb`). pub fn try_export(doc: &Document, ids: &[NodeId], opts: &VdOptions) -> Result<VdExport, VdError> {+ let _fonts = figcraft_text::enter_document_fonts(doc); check_export_depth(doc, ids)?; let area = match opts.bounds { Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,@@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required; Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's@@ -170,6 +170,8 @@ each. ## Fonts +Native documents may carry bounded font binaries with SHA-256 identities and license metadata. Saves preserve document, version and branch ownership; unreadable or missing binaries produce warnings. Rendering, measurement and SVG/PDF export select fonts within the current document. No font is downloaded or registered globally. This model change is pending the owner's font-policy decision.+ | | Figma | FigCraft | |---|---|---| | Local fonts | yes (font helper / desktop app) | yes (system scan) |@@ -181,6 +183,7 @@ each. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged. | | 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |