Merge pull request #782 from dotCooCoo/feat/package-font-resolution

Package: fonts found by any of their names are copied, and skipped fonts are named
This commit is contained in:
BFlat 2026-10-10 01:43:01 +07:00 committed by GitHub
commit 116573ba86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 152 additions and 20 deletions

View File

@ -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]

View File

@ -109,8 +109,15 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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());

View File

@ -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");

View File

@ -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 {

View File

@ -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<u8> {
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::<Vec<u8>>();
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();

View File

@ -114,7 +114,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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::<Vec<_>>().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<Value, String> {
}
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)],

View File

@ -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");

View File

@ -2990,6 +2990,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.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2987,6 +2987,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.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2990,6 +2990,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.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2668,6 +2668,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} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2986,6 +2986,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}: назовите их или включите «Объекты без имени» в меню панели.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2982,6 +2982,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}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2910,6 +2910,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} 个未命名对象:请为它们命名,或在面板菜单中开启“未命名对象”。

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2998,6 +2998,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} 個未命名物件:請為它們命名,或在面板選單中開啟「未命名物件」。

Can't render this file because it contains an unexpected character in line 6 and column 47.