Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spx-gui/src/apps/xbuilder/.env.production-cn
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ VITE_SENTRY_DSN="https://3b532e8d49bece95a4ad2d14c73c2e0b@o4509472134987776.inge
VITE_SHOW_LICENSE="true"
VITE_SHOW_TUTORIALS_ENTRY="true"
VITE_DEFAULT_LANG="zh"
VITE_DEFAULT_FONT_PREFERENCES="basic-chinese, default"

VITE_ACCOUNT_OAUTH_CLIENT_ID="1"
1 change: 1 addition & 0 deletions spx-gui/src/apps/xbuilder/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const disableAIGC = import.meta.env.VITE_DISABLE_AIGC === 'true'
export const showLicense = import.meta.env.VITE_SHOW_LICENSE === 'true'
export const showTutorialsEntry = import.meta.env.VITE_SHOW_TUTORIALS_ENTRY === 'true'
export const defaultLang = (import.meta.env.VITE_DEFAULT_LANG as string) || 'en'
export const defaultFontPreferences = (import.meta.env.VITE_DEFAULT_FONT_PREFERENCES as string) || 'default'
export const accountOAuthClientId = import.meta.env.VITE_ACCOUNT_OAUTH_CLIENT_ID as string
const sentryTracesSampleRate = parseFloat(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE as string)
const sentryLSPSampleRate = parseFloat(import.meta.env.VITE_SENTRY_LSP_SAMPLE_RATE as string)
Expand Down
Binary file not shown.
4 changes: 4 additions & 0 deletions spx-gui/src/assets/fonts/basic-chinese/README.md
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.
13 changes: 13 additions & 0 deletions spx-gui/src/components/project/ProjectCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import { cloudHelpers } from '@/models/common/cloud'
import { xbpHelpers } from '@/models/common/xbp'
import { SpxProject } from '@/models/spx/project'
import { getDefaultProjectFile } from '@/components/project'
import { defaultFontPreferences } from '@/apps/xbuilder/env'
import { createBasicChineseFontFamily } from '@/models/spx/font'

const props = defineProps<{
remixSource?: string
Expand Down Expand Up @@ -111,6 +113,17 @@ const handleSubmit = useMessageHandle(
const project = new SpxProject(signedInState.user.username, projectName)
const serialized = await xbpHelpers.load(defaultProjectFile)
await project.load(serialized)
project.fontPreferences = defaultFontPreferences
if (
defaultFontPreferences
.split(',')
.map((item) => item.trim())
.includes('basic-chinese')
) {
project.fontCollection.splice(0, project.fontCollection.length, await createBasicChineseFontFamily())
} else {
project.fontCollection.splice(0)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since project is a newly created instance, its fontCollection is 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 unnecessary splice operations.

      const preferences = defaultFontPreferences.split(',').map((item) => item.trim())
      if (preferences.includes('basic-chinese')) {
        project.fontCollection.push(await createBasicChineseFontFamily())
      }

Copy link
Copy Markdown
Collaborator Author

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.

project.setDisplayName(projectName)
project.setVisibility(Visibility.Private)
const exported = await project.export()
Expand Down
Binary file modified spx-gui/src/components/project/default-project.xbp
Binary file not shown.
37 changes: 37 additions & 0 deletions spx-gui/src/models/spx/font.test.ts
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')
})
})
84 changes: 84 additions & 0 deletions spx-gui/src/models/spx/font.ts
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()))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The createBasicChineseFontFamily function fetches the basic Chinese font from the network every time it is called. Since font files can be quite large, performing redundant fetches on every legacy project migration or new project creation can cause significant network overhead and delay project loading. Caching the Promise<Blob> of the fetched font ensures subsequent calls resolve instantly without making additional network requests.

Suggested change
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()))
}
let basicChineseFontBlobPromise: Promise<Blob> | null = null
export async function createBasicChineseFontFamily() {
if (basicChineseFontBlobPromise == null) {
basicChineseFontBlobPromise = fetch(basicChineseFontUrl).then((response) => {
if (!response.ok) throw new Error(`failed to load basic Chinese font: ${response.status}`)
return response.blob()
}).catch((err) => {
basicChineseFontBlobPromise = null
throw err
})
}
const blob = await basicChineseFontBlobPromise
return new FontFamily('basic-chinese', fromBlob('NotoSansCJKsc-Regular.otf', blob))
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

14 changes: 14 additions & 0 deletions spx-gui/src/models/spx/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as hashHelper from '../common/hash'
import { Backdrop } from './backdrop'
import { Monitor } from './widget/monitor'
import { SpxProject, projectConfigFilePath, type RawProjectConfig, type ScreenshotTaker } from './project'
import { FontFamily } from './font'

function mockFile(name = 'mocked') {
return fromText(name, Math.random() + '')
Expand Down Expand Up @@ -101,6 +102,19 @@ describe('Project', () => {
expect(hash).toBe(hash2)
})

it('should preserve project font collection and preferences', async () => {
const project = new SpxProject()
project.fontPreferences = 'basic-chinese, default'
project.fontCollection.push(new FontFamily('basic-chinese', fromText('basic-chinese.otf', 'font')))

const files = project.exportFiles()
const loaded = new SpxProject()
await loaded.loadFiles(files)

expect(loaded.fontPreferences).toBe('basic-chinese, default')
expect(loaded.fontCollection.map((font) => font.name)).toEqual(['basic-chinese'])
})

it('should export with existing thumbnail after screenshot taker unbound', async () => {
const project = makeProject(null)
const thumbnail = mockFile('thumbnail')
Expand Down
22 changes: 21 additions & 1 deletion spx-gui/src/models/spx/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import { assign } from '../common'
import { ensureValidSpriteName, ensureValidSoundName } from './common/asset-name'
import { hashFiles } from '../common/hash'
import { isProjectUsingAIInteraction } from '@/utils/project'
import { setSvgFontPreferences } from '@/utils/img-rendering'
import { defaultMapSize, Stage, type RawStageConfig } from './stage'
import { DumbTilemap as Tilemap } from './tilemap'
import { Sprite } from './sprite'
import { Sound } from './sound'
import { createBasicChineseFontFamily, FontFamily } from './font'
import type { RawWidgetConfig } from './widget'
import type { Metadata, IProject, PartialMetadata, ProjectSerialized } from '@/models/project'

Expand Down Expand Up @@ -84,6 +86,7 @@ export type RawProjectConfig = RawStageConfig &
* Maximum FPS for the project.
*/
maxFPS?: number
fontPreferences?: string
}

export type ScreenshotTaker = (
Expand Down Expand Up @@ -134,6 +137,8 @@ export class SpxProject extends Disposable implements IProject {
// reference sounds by ID, and `removeSound` must clean up those cross-sprite references.
sounds: Sound[]
zorder: string[]
fontCollection: FontFamily[]
fontPreferences: string

private aiDescription: string | null
private aiDescriptionHash: string | null
Expand Down Expand Up @@ -368,13 +373,16 @@ export class SpxProject extends Disposable implements IProject {
this.displayName = name
}
this.zorder = []
this.fontCollection = []
this.fontPreferences = 'default'
this.stage = new Stage()
this.sprites = []
this.sounds = []
this.addDisposer(() => {
this.sprites.splice(0).forEach((s) => s.dispose())
this.sounds.splice(0).forEach((s) => s.dispose())
this.zorder = []
this.fontCollection.splice(0)
this.stage.dispose()
this.tilemap?.dispose()
this.tilemap = null
Expand Down Expand Up @@ -425,11 +433,19 @@ export class SpxProject extends Disposable implements IProject {
builder_soundOrder: soundOrder,
tilemapPath,
maxFPS,
fontPreferences,
...rawStageConfig
} = config

const sounds = await Sound.loadAll(files)
const sprites = await Sprite.loadAll(files, { sounds })
const fontCollection = await FontFamily.loadAll(files)
if (fontPreferences == null && fontCollection.length === 0) {
fontCollection.push(await createBasicChineseFontFamily())
this.fontPreferences = 'basic-chinese, default'
} else {
this.fontPreferences = fontPreferences ?? 'default'
}

const widgets: RawWidgetConfig[] = []
const zorder: string[] = []
Expand Down Expand Up @@ -460,6 +476,7 @@ export class SpxProject extends Disposable implements IProject {
this.sounds.splice(0).forEach((s) => s.dispose())
orderBy(sounds, soundOrder).forEach((s) => this.addSound(s))
this.zorder = zorder ?? []
this.fontCollection.splice(0, this.fontCollection.length, ...fontCollection)

this.tilemap?.dispose()
this.tilemap = tilemapPath != null ? await Tilemap.load(tilemapPath, assetsDir, files) : null
Expand Down Expand Up @@ -500,12 +517,15 @@ export class SpxProject extends Disposable implements IProject {
zorder: [...zorderNames, ...(widgets ?? [])],
builder_spriteOrder: this.sprites.map((s) => s.id),
builder_soundOrder: this.sounds.map((s) => s.id),
maxFPS: this.maxFPS ?? 60 // Default is 60 FPS
maxFPS: this.maxFPS ?? 60, // Default is 60 FPS
fontPreferences: this.fontPreferences
}
files[projectConfigFilePath] = fromConfig(projectConfigFileName, config)
Object.assign(files, stageFiles)
Object.assign(files, ...this.sprites.map((s) => s.export({ sounds: this.sounds })))
Object.assign(files, ...this.sounds.map((s) => s.export()))
Object.assign(files, ...this.fontCollection.map((fontFamily) => fontFamily.export()))
setSvgFontPreferences(files, this.fontPreferences, this.fontCollection)
return files
}

Expand Down
15 changes: 14 additions & 1 deletion spx-gui/src/utils/img-rendering.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fromText } from '@/models/common/file'
import { getRenderableImageUrl } from './img-rendering'
import { getRenderableImageUrl, setSvgFontPreferences } from './img-rendering'
import { injectScratchFontsToSvgText } from './scratch-svg-font'

vi.mock('./scratch-svg-font', () => ({
Expand Down Expand Up @@ -86,4 +86,17 @@ describe('getRenderableImageUrl', () => {
ctrl.abort()
expect(URL.revokeObjectURL).toHaveBeenCalledWith(url)
})

it('passes project font preferences to SVG rendering', async () => {
const file = fromText('costume.svg', '<svg><text>你好</text></svg>', { type: 'image/svg+xml' })
setSvgFontPreferences({ 'assets/costume.svg': file }, 'basic-chinese, default', [])

await getRenderableImageUrl(file, new AbortController().signal)

expect(injectScratchFontsToSvgText).toHaveBeenCalledWith(
'<svg><text>你好</text></svg>',
'basic-chinese, default',
expect.any(Map)
)
})
})
25 changes: 23 additions & 2 deletions spx-gui/src/utils/img-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Currently, setSvgFontPreferences unconditionally deletes files from derivedSvgCache and updates svgFontConfig every time it is called. Since exportFiles() is triggered frequently during project edits, this constantly invalidates the SVG render cache, leading to redundant SVG parsing, font injection, and blob URL creation. This causes significant performance degradation and memory churn. Checking if the font preferences or collection have actually changed before invalidating the cache will resolve this.

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] })
      }
    }
  })
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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`.
Expand All @@ -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) => {
Expand Down
Loading