-
Notifications
You must be signed in to change notification settings - Fork 57
feat: support project font collection #3348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
3601351
fc4e1ff
b847458
ef655f4
5a0abd3
aa291d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Noto Sans CJK SC | ||
|
|
||
| `NotoSansCJKsc-Regular.otf` is the Noto Sans CJK SC Regular font from | ||
| <https://github.com/notofonts/noto-cjk> and is licensed under the SIL Open Font License 1.1. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { fromText, toConfig, type Files } from '../common/file' | ||
| import { FontFamily } from './font' | ||
|
|
||
| describe('FontFamily', () => { | ||
| it('loads and exports a single project font face', async () => { | ||
| const files: Files = { | ||
| 'assets/fonts/basic-chinese/index.json': fromText('index.json', '{"faces":[{"path":"font.otf"}]}'), | ||
| 'assets/fonts/basic-chinese/font.otf': fromText('font.otf', 'font') | ||
| } | ||
|
|
||
| const [fontFamily] = await FontFamily.loadAll(files) | ||
| expect(fontFamily.name).toBe('basic-chinese') | ||
| expect(await toConfig(fontFamily.export()['assets/fonts/basic-chinese/index.json']!)).toEqual({ | ||
| faces: [{ path: 'font.otf' }] | ||
| }) | ||
| }) | ||
|
|
||
| it('rejects the reserved default family name', async () => { | ||
| const files: Files = { | ||
| 'assets/fonts/default/index.json': fromText('index.json', '{"faces":[{"path":"font.otf"}]}'), | ||
| 'assets/fonts/default/font.otf': fromText('font.otf', 'font') | ||
| } | ||
|
|
||
| await expect(FontFamily.loadAll(files)).rejects.toThrow('reserved') | ||
| }) | ||
|
|
||
| it('rejects face paths outside their family directory', async () => { | ||
| const files: Files = { | ||
| 'assets/fonts/basic-chinese/index.json': fromText('index.json', '{"faces":[{"path":"../font.otf"}]}'), | ||
| 'assets/fonts/font.otf': fromText('font.otf', 'font') | ||
| } | ||
|
|
||
| await expect(FontFamily.loadAll(files)).rejects.toThrow('invalid font path') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import { reactive } from 'vue' | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import { join, resolve } from '@/utils/path' | ||||||||||||||||||||||||||||||||||||||||||
| import { File, fromConfig, fromBlob, listDirs, toConfig, type Files } from '../common/file' | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export const fontAssetPath = 'assets/fonts' | ||||||||||||||||||||||||||||||||||||||||||
| const fontConfigFileName = 'index.json' | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| type RawFontConfig = { | ||||||||||||||||||||||||||||||||||||||||||
| faces?: { path?: string }[] | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function validateFontFamilyName(name: string) { | ||||||||||||||||||||||||||||||||||||||||||
| if (name === '') throw new Error('font family name must not be empty') | ||||||||||||||||||||||||||||||||||||||||||
| if (name.includes(',') || name.includes("'") || name.includes('"')) { | ||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`invalid font family name: ${name}`) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| if (name.toLowerCase() === 'default') throw new Error('font family name default is reserved') | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function validateFontPath(path: string, prefix: string) { | ||||||||||||||||||||||||||||||||||||||||||
| if (path === '' || path.startsWith('/') || path.split('/').includes('..')) { | ||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`invalid font path: ${path}`) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| const resolved = resolve(prefix, path) | ||||||||||||||||||||||||||||||||||||||||||
| if (!resolved.startsWith(prefix + '/')) throw new Error(`font path escapes family directory: ${path}`) | ||||||||||||||||||||||||||||||||||||||||||
| return resolved | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export class FontFamily { | ||||||||||||||||||||||||||||||||||||||||||
| name: string | ||||||||||||||||||||||||||||||||||||||||||
| face: File | ||||||||||||||||||||||||||||||||||||||||||
| facePath: string | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| constructor(name: string, face: File, facePath = face.name) { | ||||||||||||||||||||||||||||||||||||||||||
| validateFontFamilyName(name) | ||||||||||||||||||||||||||||||||||||||||||
| this.name = name | ||||||||||||||||||||||||||||||||||||||||||
| this.face = face | ||||||||||||||||||||||||||||||||||||||||||
| this.facePath = facePath | ||||||||||||||||||||||||||||||||||||||||||
| return reactive(this) as this | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| static async load(name: string, files: Files) { | ||||||||||||||||||||||||||||||||||||||||||
| const prefix = join(fontAssetPath, name) | ||||||||||||||||||||||||||||||||||||||||||
| const configFile = files[join(prefix, fontConfigFileName)] | ||||||||||||||||||||||||||||||||||||||||||
| if (configFile == null) throw new Error(`font configuration not found for ${name}`) | ||||||||||||||||||||||||||||||||||||||||||
| const config = (await toConfig(configFile)) as RawFontConfig | ||||||||||||||||||||||||||||||||||||||||||
| if (config.faces?.length !== 1) throw new Error(`font family ${name} must have exactly one face`) | ||||||||||||||||||||||||||||||||||||||||||
| const facePath = config.faces[0].path | ||||||||||||||||||||||||||||||||||||||||||
| if (facePath == null) throw new Error(`font face path not found for ${name}`) | ||||||||||||||||||||||||||||||||||||||||||
| const face = files[validateFontPath(facePath, prefix)] | ||||||||||||||||||||||||||||||||||||||||||
| if (face == null) throw new Error(`font face not found for ${name}: ${facePath}`) | ||||||||||||||||||||||||||||||||||||||||||
| return new FontFamily(name, face, facePath) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| static async loadAll(files: Files) { | ||||||||||||||||||||||||||||||||||||||||||
| const names = listDirs(files, fontAssetPath) | ||||||||||||||||||||||||||||||||||||||||||
| const normalizedNames = new Set<string>() | ||||||||||||||||||||||||||||||||||||||||||
| for (const name of names) { | ||||||||||||||||||||||||||||||||||||||||||
| validateFontFamilyName(name) | ||||||||||||||||||||||||||||||||||||||||||
| const normalized = name.toLowerCase() | ||||||||||||||||||||||||||||||||||||||||||
| if (normalizedNames.has(normalized)) throw new Error(`duplicate font family name: ${name}`) | ||||||||||||||||||||||||||||||||||||||||||
| normalizedNames.add(normalized) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| return Promise.all(names.map((name) => FontFamily.load(name, files))) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export(): Files { | ||||||||||||||||||||||||||||||||||||||||||
| const prefix = join(fontAssetPath, this.name) | ||||||||||||||||||||||||||||||||||||||||||
| const config: RawFontConfig = { faces: [{ path: this.facePath }] } | ||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||
| [join(prefix, fontConfigFileName)]: fromConfig(fontConfigFileName, config), | ||||||||||||||||||||||||||||||||||||||||||
| [validateFontPath(this.facePath, prefix)]: this.face | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const basicChineseFontUrl = new URL('../../assets/fonts/basic-chinese/NotoSansCJKsc-Regular.otf', import.meta.url).href | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export async function createBasicChineseFontFamily() { | ||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(basicChineseFontUrl) | ||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) throw new Error(`failed to load basic Chinese font: ${response.status}`) | ||||||||||||||||||||||||||||||||||||||||||
| return new FontFamily('basic-chinese', fromBlob('NotoSansCJKsc-Regular.otf', await response.blob())) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in fc4e1ff. The bundled basic Chinese font load now shares a cached Blob promise and resets it on failure for retry. |
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,11 +5,27 @@ | |
| import { computed, ref, watch, type WatchSource } from 'vue' | ||
| import { isSvgMimeType } from '@/utils/file' | ||
| import { Cancelled } from '@/utils/exception' | ||
| import type { File } from '@/models/common/file' | ||
| import type { File, Files } from '@/models/common/file' | ||
| import type { FontFamily } from '@/models/spx/font' | ||
| import { injectScratchFontsToSvgText } from './scratch-svg-font' | ||
|
|
||
| /** Cache for derived rendering SVG blobs with Scratch fonts injected (if needed), keyed by source `File`. */ | ||
| const derivedSvgCache = new WeakMap<File, Promise<Blob>>() | ||
| type SvgFontConfig = { | ||
| fontPreferences: string | ||
| fontCollection: FontFamily[] | ||
| } | ||
|
|
||
| const svgFontConfig = new WeakMap<File, SvgFontConfig>() | ||
|
|
||
| export function setSvgFontPreferences(files: Files, fontPreferences: string, fontCollection: FontFamily[]) { | ||
| Object.values(files).forEach((file) => { | ||
| if (file != null && isSvgMimeType(file.type)) { | ||
| derivedSvgCache.delete(file) | ||
| svgFontConfig.set(file, { fontPreferences, fontCollection }) | ||
| } | ||
| }) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently, export function setSvgFontPreferences(files: Files, fontPreferences: string, fontCollection: FontFamily[]) {
Object.values(files).forEach((file) => {
if (file != null && isSvgMimeType(file.type)) {
const oldConfig = svgFontConfig.get(file)
const hasChanged = !oldConfig ||
oldConfig.fontPreferences !== fontPreferences ||
oldConfig.fontCollection.length !== fontCollection.length ||
oldConfig.fontCollection.some((font, index) => font !== fontCollection[index])
if (hasChanged) {
derivedSvgCache.delete(file)
svgFontConfig.set(file, { fontPreferences, fontCollection: [...fontCollection] })
}
}
})
}
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in fc4e1ff. The SVG cache is now invalidated only when preferences or the effective font-family snapshot changes. |
||
|
|
||
| /** | ||
| * Get an image-resource URL for a `File`. | ||
|
|
@@ -26,7 +42,12 @@ export async function getRenderableImageUrl(file: File, signal: AbortSignal) { | |
| .arrayBuffer() | ||
| .then(async (ab) => { | ||
| const svgText = new TextDecoder().decode(ab) | ||
| const injectedSvgText = await injectScratchFontsToSvgText(svgText) | ||
| const config = svgFontConfig.get(file) | ||
| const injectedSvgText = await injectScratchFontsToSvgText( | ||
| svgText, | ||
| config?.fontPreferences ?? 'default', | ||
| new Map(config?.fontCollection?.map((fontFamily) => [fontFamily.name, fontFamily.face])) | ||
| ) | ||
| return new Blob([injectedSvgText ?? ab], { type: 'image/svg+xml' }) | ||
| }) | ||
| .catch((e) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
projectis a newly created instance, itsfontCollectionis guaranteed to be empty. We can simplify the logic by directly pushing the font family to the collection if the preferences include 'basic-chinese', avoiding the verbose and unnecessaryspliceoperations.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in fc4e1ff. The default project template has an explicit default preference, so a newly created project starts with an empty collection; the configured preset is now appended directly.