diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 05cf1bc3..202be0d5 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_text::embed::Embedding; use super::*; @@ -77,13 +78,17 @@ pub struct Section { pub rows: Vec<(String, String)>, } -/// What a font's `fsType` lets a copy of it do. +/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the +/// most permissive usage bit wins, and bitmap-only fonts may not be embedded. pub fn embedding_label(fs_type: u16) -> &'static str { - match fs_type & 0x000f { - 0x0002 => "embedding not allowed", - 0x0004 => "embedding for preview and print", - 0x0008 => "embedding for editing", - _ => "embedding allowed", + if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { + "embedding not allowed" + } else if fs_type & 0x0008 != 0 { + "embedding for editing" + } else if fs_type & 0x0004 != 0 { + "embedding for preview and print" + } else { + "embedding allowed" } } @@ -136,13 +141,19 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag fonts .iter() .map(|(family, style)| { - let detail = match db.face(family, style) { - Some(f) if f.family.eq_ignore_ascii_case(family) => { + // Found by any of its names; a style the family lacks shows in another. + let detail = match db.resolve(family, style) { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { let file = f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); - format!("{file}; {}", embedding_label(f.fs_type())) + let found = format!("{file}; {}", embedding_label(f.fs_type())); + if m == vectorcraft_text::FontMatch::Style { + format!("substituted: shown in {} {} ({found})", f.family, f.style) + } else { + found + } } - Some(f) => format!("missing: shown in {} {}", f.family, f.style), + Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), None => "missing".into(), }; (format!("{family} {style}"), detail) @@ -396,6 +407,12 @@ mod tests { assert_eq!(embedding_label(0x0002), "embedding not allowed"); assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print"); assert_eq!(embedding_label(0x0008), "embedding for editing"); + // As exports read it: bitmap embedding only, the restricted bit with reserved bit 0, the + // most permissive of several usage bits. + assert_eq!(embedding_label(0x0200), "embedding not allowed"); + assert_eq!(embedding_label(0x0003), "embedding not allowed"); + assert_eq!(embedding_label(0x0006), "embedding for preview and print"); + assert_eq!(embedding_label(0x000c), "embedding for editing"); } #[test] diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 876853f9..046db3a0 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -109,8 +109,15 @@ fn package(s: &mut Session, p: &Value) -> Result { lines.push("FONTS".to_string()); for (family, style) in super::fonts::used_fonts(&st.doc) { let font = format!("{family} {style}"); - let reason = match db.face(&family, &style) { - Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { + // Found by any of its names, as the canvas draws it. + let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing); + // A style the family lacks is shown in its closest style, whose file is copied. + let shown = match &resolved { + Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style), + _ => String::new(), + }; + let reason = match resolved { + Some((f, _)) if f.embeddable() => { let data = f.file_data(); let file = f.path().and_then(Path::file_name).map_or_else( || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), @@ -121,13 +128,13 @@ fn package(s: &mut Session, p: &Value) -> Result { entries.push((format!("Fonts/{file}"), data.to_vec())); fonts += 1; } - lines.push(format!("{font} → Fonts/{file}")); + lines.push(format!("{font}{shown} → Fonts/{file}")); continue; } - Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding", - _ => "not available on this computer", + Some(_) => "its licence doesn't allow embedding", + None => "not available on this computer", }; - lines.push(format!("{font}: {reason}, not copied")); + lines.push(format!("{font}{shown}: {reason}, not copied")); skipped.push(json!({ "font": font, "reason": reason })); } lines.push(String::new()); diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 2c607fc1..52f30337 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -157,6 +157,36 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +#[test] +fn a_font_named_by_another_of_its_names_is_copied() { + let dir = Folder::new("package-alias"); + let mut s = poster(&dir); + // The bundled Semibold by its PostScript name, as an SVG or a PDF can name it. + run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Alias", "font": "SourceSans3-Semibold"})); + let r = run(&mut s, "file.package", json!({})); + assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), json!([])), "{}", r["skippedFonts"]); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap(); + assert!(report.contains("SourceSans3-Semibold Regular → Fonts/"), "{report}"); + let details = report.lines().find(|l| l.starts_with("SourceSans3-Semibold Regular: ")).unwrap_or_default(); + assert!(details.contains("embedding allowed") && !details.contains("missing"), "{report}"); +} + +#[test] +fn a_substituted_style_names_the_style_shown() { + let dir = Folder::new("package-style"); + let mut s = poster(&dir); + // No Source Sans 3 has a Black Wide, bundled or installed: its closest style stands in, and + // that style's file is copied. + run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})); + let r = run(&mut s, "file.package", json!({})); + assert_eq!(r["skippedFonts"], json!([])); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap(); + assert!(report.contains("Source Sans 3 Black Wide (shown in Source Sans 3 "), "{report}"); + assert!(report.contains("Source Sans 3 Black Wide: substituted: shown in Source Sans 3 "), "{report}"); +} + #[test] fn the_document_info_report_has_every_category() { let dir = Folder::new("docinfo-report"); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index eabf0c02..4812f7f5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -270,9 +270,10 @@ impl FontFace { use skrifa::raw::TableProvider; self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()) } - /// May the font file be copied along with a document (its licence doesn't restrict embedding)? + /// May the font file be copied along with a document? Its license allows embedding its + /// outlines, as exports check it ([`Self::embedding`]). pub fn embeddable(&self) -> bool { - self.fs_type() & 0x000f != 0x0002 + self.embedding() != crate::embed::Embedding::Forbidden } /// The face's index in its font file ([`Self::file_data`]; collections hold several). pub fn face_index(&self) -> u32 { diff --git a/crates/text/src/tests_embed.rs b/crates/text/src/tests_embed.rs index 495d05ba..8490bfd9 100644 --- a/crates/text/src/tests_embed.rs +++ b/crates/text/src/tests_embed.rs @@ -19,6 +19,42 @@ fn fs_type_flags_decide_the_embedding() { assert_eq!(Embedding::from_fs_type(0x0108), Embedding::Whole, "no subsetting"); } +/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original +/// name) with OS/2 `fsType` set to `fs_type`. +fn licensed_font(family: &str, fs_type: u16) -> Vec { + let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let table = |data: &[u8], tag: &[u8; 4]| { + let f = skrifa::FontRef::new(data).unwrap(); + let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap(); + (r.offset() as usize, r.length() as usize) + }; + let (os2, _) = table(&data, b"OS/2"); + data[os2 + 8..os2 + 10].copy_from_slice(&fs_type.to_be_bytes()); + let (name, len) = table(&data, b"name"); + assert_eq!(family.len(), "Source Sans 3".len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + let mut i = name; + while let Some(at) = data[i..name + len].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// A font file goes with a document (File → Package) only when exports may embed it: not when +/// its license is restricted or allows bitmaps only. +#[test] +fn fonts_exports_may_not_embed_are_not_embeddable() { + let db = FontDb::with_font_dirs(vec![]); + for (family, fs_type, embeddable) in [("Bitmap Sans 3", 0x0200, false), ("Lowbit Sans 3", 0x0003, false), ("Wholed Sans 3", 0x0100, true)] { + assert!(db.add_font(licensed_font(family, fs_type)) > 0, "{family}"); + let face = db.face(family, "Regular").unwrap(); + assert_eq!((face.family.as_str(), face.embeddable()), (family, embeddable), "fsType {fs_type:#06x}"); + } +} + #[test] fn a_subset_holds_only_the_glyphs_used_and_maps_their_characters() { let face = FontDb::global().face("Source Sans 3", "Regular").unwrap(); diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index ca49854f..020c5309 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -114,7 +114,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = None; let n = r["files"].as_array().map_or(0, Vec::len); let missing = r["missingLinks"].as_array().map_or(0, Vec::len); - let note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() }; + // The fonts left out: their license doesn't allow it, or they aren't on this computer. + let fonts = r["skippedFonts"].as_array().into_iter().flatten().filter_map(|f| f["font"].as_str()).collect::>().join(", "); + let mut note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() }; + if !fonts.is_empty() { + note.push_str(&format!(" (fonts not copied: {fonts})")); + } if web { let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default()); let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?; @@ -124,11 +129,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let folder = r["folder"].as_str().unwrap_or_default().to_string(); app.status(format!("Packaged {n} file(s) in {folder}{note}")); - let shown_note = if missing > 0 { + let mut shown_note = if missing > 0 { format!(" ({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())])) } else { String::new() }; + if !fonts.is_empty() { + shown_note.push_str(&format!(" ({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", &fonts)]))); + } let detail = crate::i18n::fmt( tl!("{count} file(s) in {folder}{note}. Show the package folder?"), &[("count", &n.to_string()), ("folder", &folder), ("note", &shown_note)], diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index e6279378..f9607395 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -106,6 +106,30 @@ fn the_web_downloads_a_zip() { assert!(got[0].1 > 100); } +#[test] +fn fonts_left_out_of_the_package_are_named() { + let dir = Folder::new("fonts"); + let mut app = saved(&dir); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Kept"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.packageDialog", json!({})).unwrap(); + let out = dir.path("out"); + std::fs::create_dir_all(&out).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out)); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["skippedFonts"], json!([{"font": "No Such Font Family Regular", "reason": "not available on this computer"}])); + let gone = "fonts not copied: No Such Font Family Regular"; + assert!(app.ui.status.contains(gone), "{}", app.ui.status); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}"); + // The web's download says so too. + app.ui.dialog = None; + app.services.download = Some(Box::new(|_: &str, _: &[u8]| {})); + app.run("ui.packageDialog", json!({})).unwrap(); + confirm(&mut app).unwrap(); + assert!(app.ui.status.contains(gone), "{}", app.ui.status); +} + #[test] fn package_without_a_location_says_so() { let dir = Folder::new("empty"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a47a09dc..6f73b705 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2989,6 +2989,7 @@ Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando {count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete? {count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s) + fonts not copied: {fonts} fuentes no copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas? {count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e5eff4fb..8092777c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2986,6 +2986,7 @@ Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser {count} file(s) in {folder}{note}. Show the package folder? {count} fichier(s) dans {folder}{note}. Afficher le dossier de l’assemblage ? {count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s) + fonts not copied: {fonts} polices non copiées : {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ? {count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 65a41e0c..771c7b9f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2989,6 +2989,7 @@ Scrub Numeric Fields by Dragging Regola i campi numerici trascinando {count} file(s) in {folder}{note}. Show the package folder? {count} file in {folder}{note}. Vuoi mostrare la cartella del pacchetto? {count} linked file(s) not found File collegati non trovati: {count} + fonts not copied: {fonts} font non copiati: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni? {count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a3ae3aee..a8b7084f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2666,6 +2666,7 @@ to → {count} file(s) in {folder}{note}. Show the package folder? {folder} に {count} 個のファイル{note}。パッケージフォルダーを表示しますか? {count} linked file(s) not found リンクファイルが {count} 個見つかりません + fonts not copied: {fonts} コピーされなかったフォント:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか? {count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f6f3cd9..c8976d08 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2590,6 +2590,7 @@ to para {count} file(s) in {folder}{note}. Show the package folder? {count} arquivo(s) em {folder}{note}. Mostrar a pasta do pacote? {count} linked file(s) not found {count} arquivo(s) vinculado(s) não encontrado(s) + fonts not copied: {fonts} fontes não copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) desde que este documento foi salvo. Mostrar as novas versões? {count} more rules: Export writes every one. {count} regras a mais: a Exportação escreve cada uma. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objetos sem nome deixados de fora: nomeie-os, ou ative Objetos Sem Nome no menu do painel. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e335ec3..43edccb4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2985,6 +2985,7 @@ Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием {count} file(s) in {folder}{note}. Show the package folder? Файлов: {count} в {folder}{note}. Показать папку пакета? {count} linked file(s) not found Не найдено связанных файлов: {count} + fonts not copied: {fonts} шрифты не скопированы: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии? {count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a641632e..6f122fb0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2981,6 +2981,7 @@ Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням {count} file(s) in {folder}{note}. Show the package folder? файлів: {count} {folder}{note}. Показати папку пакета? {count} linked file(s) not found не знайдено пов’язаних файлів: {count} + fonts not copied: {fonts} шрифти не скопійовано: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії? {count} more rules: Export writes every one. ще правил: {count}: експорт записує все. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f196abf4..de60e7c3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2909,6 +2909,7 @@ Scrub Numeric Fields by Dragging 拖动以调整数值字段 {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗? {count} linked file(s) not found 找不到 {count} 个链接文件 + fonts not copied: {fonts} 未复制的字体:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗? {count} more rules: Export writes every one. 另有 {count} 条规则:导出时会全部写出。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略过 {count} 个未命名对象:请为它们命名,或在面板菜单中开启“未命名对象”。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 45d32e9a..11e5124d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2997,6 +2997,7 @@ Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎? {count} linked file(s) not found 找不到 {count} 個連結檔案 + fonts not copied: {fonts} 未複製的字體:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎? {count} more rules: Export writes every one. 另有 {count} 條規則:匯出時會全部寫出。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略過 {count} 個未命名物件:請為它們命名,或在面板選單中開啟「未命名物件」。