Skip to content

feat: support project font persistence#3362

Open
nighca wants to merge 1 commit into
goplus:devfrom
nighca:issue-3342-project-fonts
Open

feat: support project font persistence#3362
nighca wants to merge 1 commit into
goplus:devfrom
nighca:issue-3342-project-fonts

Conversation

@nighca

@nighca nighca commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #3342

Adds Builder support for SPX project fonts:

  • persist font families under assets/fonts/<family>/ and the fontPreferences array in assets/index.json;
  • preserve fonts and preferences through project load/save/export;
  • initialize new projects from deployment-specific default preferences, including the domestic basic-Chinese preset;
  • assemble the default project from lazily loaded source assets through the project model APIs instead of an .xbp template;
  • remove Builder-bundled Scratch fonts while retaining the generic SVG font-injection mechanism.

Editor previews intentionally do not consume project fonts or preferences in this PR. That integration remains in #3348 and will be addressed separately because large fonts currently make SVG preview rendering too slow.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces support for project-specific font preferences and generic font embedding in SVGs, replacing the previous Scratch-specific implementation. It adds a new FontFamily model, updates SpxProject to manage and export font preferences, and implements a utility to inject referenced font files as base64 data URIs into SVGs. The code reviewer provided valuable feedback to improve performance and robustness: they suggested using the asynchronous FileReader.readAsDataURL API instead of a synchronous manual chunking loop to avoid blocking the main thread when encoding large font files, and recommended filtering out empty strings when parsing font preferences and extracting used font families to handle malformed inputs gracefully.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +30 to +55
function arrayBufferToDataUri(file: File, arrayBuffer: ArrayBuffer) {
const bytes = new Uint8Array(arrayBuffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, i + 8192))
}
return `data:${file.type || 'font/opentype'};base64,${btoa(binary)}`
}

function makeFontFaceRule(family: string, dataUri: string) {
return `@font-face { font-family: ${JSON.stringify(family)}; src: url(${JSON.stringify(dataUri)}); }`
}

/** Applies project font preferences and embeds the referenced font files into an SVG. */
export async function injectFontsToSvgText(svgText: string, fontPreferences: string[], fontFiles: Map<string, File>) {
const svg = parseSVGText(svgText)
const fontPreferencesRule = makeFontPreferencesRule(svg, fontPreferences)
const usedFontFamilies = new Set(getUsedFontFamilies(svg))
if (fontPreferencesRule != null) fontPreferences.forEach((family) => usedFontFamilies.add(family))
const rules = await Promise.all(
Array.from(usedFontFamilies).map(async (family) => {
const file = fontFiles.get(family)
if (file == null) return null
return makeFontFaceRule(family, arrayBufferToDataUri(file, await file.arrayBuffer()))
})
)

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

Converting large font files (such as Noto Sans CJK SC, which can be several megabytes) to base64 synchronously in the main thread using a manual chunking loop and btoa is highly inefficient. It can block the main thread, leading to UI freezes, and causes significant memory overhead due to temporary string allocations.

Since injectFontsToSvgText is already asynchronous, we can leverage the native browser FileReader.readAsDataURL API. This offloads the base64 encoding to highly optimized browser internals, making it much faster and memory-efficient.

function fileToDataUri(file: File, arrayBuffer: ArrayBuffer): Promise<string> {
  return new Promise((resolve, reject) => {
    const blob = new Blob([arrayBuffer], { type: file.type || 'font/opentype' })
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result as string)
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(blob)
  })
}

function makeFontFaceRule(family: string, dataUri: string) {
  return `@font-face { font-family: ${JSON.stringify(family)}; src: url(${JSON.stringify(dataUri)}); }`
}

/** Applies project font preferences and embeds the referenced font files into an SVG. */
export async function injectFontsToSvgText(svgText: string, fontPreferences: string[], fontFiles: Map<string, File>) {
  const svg = parseSVGText(svgText)
  const fontPreferencesRule = makeFontPreferencesRule(svg, fontPreferences)
  const usedFontFamilies = new Set(getUsedFontFamilies(svg))
  if (fontPreferencesRule != null) fontPreferences.forEach((family) => usedFontFamilies.add(family))
  const rules = await Promise.all(
    Array.from(usedFontFamilies).map(async (family) => {
      const file = fontFiles.get(family)
      if (file == null) return null
      const dataUri = await fileToDataUri(file, await file.arrayBuffer())
      return makeFontFaceRule(family, dataUri)
    })
  )

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.

Deferred to #3348. #3362 intentionally has no editor font context, so this path does not encode project fonts. We also found that FileReader does not address the dominant SVG data-URI parse/render cost; the renderer strategy will be resolved in #3348.

Comment on lines +17 to +28
function getUsedFontFamilies(svg: SVGSVGElement) {
const used = new Set<string>()
for (const el of [svg, ...Array.from(svg.querySelectorAll('[font-family]'))]) {
const attrValue = el.getAttribute('font-family')
if (attrValue == null) continue
attrValue
.split(',')
.map(stripQuotes)
.forEach((family) => used.add(family))
}
return Array.from(used)
}

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

If the font-family attribute contains empty or malformed values (e.g., trailing commas or extra spaces like Arial, , sans-serif), getUsedFontFamilies will add an empty string "" to the set of used font families. Filtering out empty strings prevents unnecessary processing and potential lookup issues.

Suggested change
function getUsedFontFamilies(svg: SVGSVGElement) {
const used = new Set<string>()
for (const el of [svg, ...Array.from(svg.querySelectorAll('[font-family]'))]) {
const attrValue = el.getAttribute('font-family')
if (attrValue == null) continue
attrValue
.split(',')
.map(stripQuotes)
.forEach((family) => used.add(family))
}
return Array.from(used)
}
function getUsedFontFamilies(svg: SVGSVGElement) {
const used = new Set<string>()
for (const el of [svg, ...Array.from(svg.querySelectorAll('[font-family]'))]) {
const attrValue = el.getAttribute('font-family')
if (attrValue == null) continue
attrValue
.split(',')
.map(stripQuotes)
.filter((family) => family !== '')
.forEach((family) => used.add(family))
}
return Array.from(used)
}

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.

Handled in 1a0b50b: empty family names are now ignored before lookup.

Comment thread spx-gui/src/models/spx/project.ts Outdated
Comment on lines +48 to +50
export function parseFontPreferences(fontPreferences: string) {
return fontPreferences.split(',').map(stripFontPreferenceQuotes)
}

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

If fontPreferences contains empty or malformed values (e.g., trailing commas or extra spaces like basic-chinese, , default), parseFontPreferences will return an array containing empty strings (e.g., ['basic-chinese', '', 'default']). This can lead to invalid CSS rules or state. Filtering out empty strings ensures robustness.

Suggested change
export function parseFontPreferences(fontPreferences: string) {
return fontPreferences.split(',').map(stripFontPreferenceQuotes)
}
export function parseFontPreferences(fontPreferences: string) {
return fontPreferences
.split(',')
.map(stripFontPreferenceQuotes)
.filter((name) => name !== '')
}

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.

Handled in 1a0b50b: parsed preferences now omit empty entries.

@fennoai fennoai Bot left a comment

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.

Code Review Summary

This PR cleanly adds a project-font persistence layer (FontFamily model, project.fonts / fontPreferences, config round-trip, legacy-project migration) with good persistence-side test coverage, and refactors the Scratch-specific SVG font injection into a generic injectFontsToSvgText(svgText, fontPreferences, fontFiles).

The main concern is that the new mechanism is not actually wired into the runtime rendering path, which makes the feature inert at render time and regresses the previous Scratch-font embedding. A few smaller robustness/perf items follow. Security review found no exploitable issues (CSS font-family values are escaped via JSON.stringify, style is injected through XMLSerializer, and faces[].path is only used for in-memory map lookups, not filesystem access).

Inline comments cover the concrete diff-line findings. Additional items that don't map to a single changed line:

  • maintainability — duplicated quote-stripping: stripQuotes in spx-gui/src/utils/svg-font.ts:9 and stripFontPreferenceQuotes in spx-gui/src/models/spx/project.ts:40 are byte-for-byte identical. Extract a single shared helper so preference parsing and SVG rule generation can't diverge on family-name semantics.
  • maintainability — migration condition coupling: spx-gui/src/models/spx/project.ts:469 triggers legacy migration only when fontPreferences == null && fonts.length === 0. The null check alone is the real signal of a pre-feature project; the extra fonts.length === 0 clause is subtle and undocumented. Consider keying migration on fontPreferences == null only, or add a comment explaining why both are required.
  • stale comment (outside this diff): spx-gui/src/utils/file.ts:125 still says useRenderableImageUrl "applies rendering-specific handling such as Scratch font injection for SVG files." Since scratch-svg-font.ts was removed, this should read "font injection for SVG files."
  • licensing hygiene: the deleted scratch/ fonts vendored their license text (OFL.txt / LICENSE.txt), but the new basic-chinese/README.md only links to the source repo. If policy is to vendor the OFL text alongside binary font assets, consider adding it.
  • test gap: spx-gui/src/utils/img-rendering.test.ts fully mocks injectFontsToSvgText, so no test asserts what arguments the real render path passes — which is why the wiring gap below is invisible to CI. Once wiring is added, assert that getRenderableImageUrl forwards the project's preferences and font files.

.then(async (ab) => {
const svgText = new TextDecoder().decode(ab)
const injectedSvgText = await injectScratchFontsToSvgText(svgText)
const injectedSvgText = await injectFontsToSvgText(svgText, [], new Map())

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.

Project fonts are never wired into runtime SVG rendering (likely functional gap + regression).

getRenderableImageUrl is the only caller of injectFontsToSvgText in the codebase, and it always passes an empty fontPreferences ([]) and an empty fontFiles (new Map()). As a result:

  • makeFontPreferencesRule returns null (empty preferences), and every fontFiles.get(family) is undefined, so no @font-face rules are produced — the function always returns null and the source SVG bytes are used unchanged.
  • The new project.fonts / project.fontPreferences are persisted and exported but never reach the render path, so the feature has no observable runtime effect.
  • This also regresses the removed scratch-svg-font.ts, which auto-embedded vendored fonts by inspecting SVG font-family attributes; SVGs relying on those fonts will now fall back to system fonts.

getRenderableImageUrl has no access to the owning Project, so plumbing to pass project.fontPreferences and a name -> File map built from project.fonts appears missing. Please wire this up, or, if runtime wiring is intentionally deferred, call that out in the PR description and address the Scratch-font embedding regression.

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.

Intentional split. #3362 only persists project fonts; #3348 owns editor context and rendering, as documented in this PR. Scratch font removal is deliberate: converted Scratch projects must carry the fonts they use.

for (let i = 0; i < bytes.byteLength; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, i + 8192))
}
return `data:${file.type || 'font/opentype'};base64,${btoa(binary)}`

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.

Synchronous main-thread base64 encoding of large fonts, with no result caching.

Despite the async wrapper, this loop plus btoa runs synchronously on the main thread. For the newly added Noto Sans CJK SC (~16MB NotoSansCJKsc-Regular.otf) this builds a ~16MB binary string and a ~21MB base64 string, blocking the UI and allocating 50MB+ transiently — once per SVG, since the encoded data URI isn't cached (the old scratch-svg-font.ts had a per-family fontDataUriCache that this rewrite dropped; File.arrayBuffer() caches only the raw buffer, not the base64).

Suggest offloading via async FileReader.readAsDataURL (or a Web Worker), and caching the encoded data URI per File/family so a shared font is encoded at most once. (Consistent with the gemini-code-assist high-priority note on this hunk.)

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.

Deferred to #3348. #3362 does not provide project fonts to the renderer. FileReader alone does not address the dominant data-URI SVG parse/render cost; the rendering strategy belongs to the follow-up.

Comment thread spx-gui/src/models/spx/project.ts Outdated
}

export function parseFontPreferences(fontPreferences: string) {
return fontPreferences.split(',').map(stripFontPreferenceQuotes)

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.

parseFontPreferences does not filter empty entries.

An empty or trailing-comma input (e.g. '' or 'basic-chinese, , default') yields entries like [''] / ['basic-chinese', '', 'default']. Those empty names get persisted and, in svg-font.ts makeFontPreferencesRule, emit invalid CSS such as svg { font-family: ""; }. Note an explicitly empty string bypasses the fontPreferences == null migration branch and lands here.

Suggest filtering: .map(stripFontPreferenceQuotes).filter((name) => name !== ''). The same empty-value handling applies to getUsedFontFamilies in spx-gui/src/utils/svg-font.ts (flagged by gemini-code-assist).

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.

Handled in 1a0b50b: parsed preferences now omit empty entries.

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.fonts.map((fontFamily) => fontFamily.export()))

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.

The ~16MB default font is embedded into every project export/save.

export() now serializes each FontFamily's file into the project's file set, and load() auto-materializes createBasicChineseFontFamily() for legacy projects. This means the ~16MB Noto CJK binary becomes part of the hashed/uploaded payload on every save, adding significant per-save hashing, I/O, and upload cost. Consider deduplicating shared/default fonts by reference (a shared asset URL) rather than embedding the full binary per project, or confirm the storage layer already skips unchanged blobs by content hash.

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.

Kept as designed. Project font files must be exported with the XBP for self-contained offline/runtime portability; switching to a shared URL changes that contract. Storage-level deduplication is a separate concern.

@nighca
nighca force-pushed the issue-3342-project-fonts branch from 1d9307a to 1a0b50b Compare July 22, 2026 05:53
@nighca

nighca commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review-summary follow-up:

  • The empty-family cases and the stale useFileUrl comment were fixed in 1a0b50b.
  • I kept the project-model parser and generic SVG parser local: they serve separate layers and remain small.
  • I kept the migration predicate: only the absence of both preferences and collection identifies the old no-font configuration; a project already carrying fonts is not rewritten.
  • The bundled Noto CJK asset is temporary and will be replaced, so we are not adding a separate license text file now.
  • Project-font rendering and its end-to-end tests remain in feat: render project fonts in editor #3348, which owns editor integration and the ongoing rendering-performance work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track project-level font support

1 participant