fix(stage-ui-live2d): decode legacy-codepage zip filenames (#2016)

This commit is contained in:
ricemaster1
2026-07-20 20:49:43 +08:00
committed by GitHub
parent eabec9a64f
commit 1f0aeb9fcc
8 changed files with 80 additions and 118 deletions
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { decodeZipFileName } from './decode-zip-filename'
describe('decodeZipFileName', () => {
it('passes ASCII names through unchanged', () => {
const bytes = new TextEncoder().encode('Sparkle.model3.json')
expect(decodeZipFileName(bytes)).toBe('Sparkle.model3.json')
})
it('decodes GBK names as GBK even when the bytes are also valid UTF-8', () => {
// GBK `一` is bytes D2 BB, which is *also* a well-formed UTF-8 sequence for `һ`.
// Preferring valid UTF-8 here would yield mojibake; the decoder must choose GBK.
const bytes = new Uint8Array([0xD2, 0xBB, ...new TextEncoder().encode('.exp3.json')])
expect(decodeZipFileName(bytes)).toBe('一.exp3.json')
})
it('decodes multi-character GBK names', () => {
// GBK bytes for `高光` followed by an ASCII suffix.
const bytes = new Uint8Array([0xB8, 0xDF, 0xB9, 0xE2, ...new TextEncoder().encode('.exp3.json')])
expect(decodeZipFileName(bytes)).toBe('高光.exp3.json')
})
it('passes a string[] through unchanged (JSZip option-signature branch)', () => {
expect(decodeZipFileName(['a', 'b', 'c'])).toBe('abc')
})
})
@@ -0,0 +1,31 @@
// Some Live2D archives — notably VTube Studio exports from CJK authors — store entry
// names without the UTF-8 flag, encoded in a legacy codepage (most commonly GBK). JSZip
// decodes those as UTF-8 by default, turning names like `手姿势切换.exp3.json` into U+FFFD
// mojibake.
//
// JSZip only calls `decodeFileName` for entries *without* the UTF-8 flag, so any name
// carrying high bytes here is almost certainly legacy-encoded. We must not simply prefer
// UTF-8 when it happens to be valid: some GBK names are also well-formed UTF-8 yet decode
// to the wrong characters (e.g. GBK `一` is bytes `D2 BB`, which is valid UTF-8 for `һ`).
// Pure-ASCII names are identical across encodings, so fast-path those and decode the rest
// as GBK, falling back to UTF-8 only if a GBK decoder is unavailable in this runtime.
//
// Pass this to `JSZip.loadAsync(data, { decodeFileName })`. It must be shared by every
// code path that opens a model archive (loader and validator alike), otherwise one path
// sees mojibake names while another sees the decoded ones.
export function decodeZipFileName(bytes: string[] | Uint8Array): string {
// JSZip passes the raw filename bytes as a Uint8Array; the string[] branch only
// exists to satisfy its option signature and is passed through unchanged.
if (Array.isArray(bytes))
return bytes.join('')
if (bytes.every(byte => byte < 0x80))
return new TextDecoder('utf-8').decode(bytes)
try {
return new TextDecoder('gbk').decode(bytes)
}
catch {
return new TextDecoder('utf-8').decode(bytes)
}
}
@@ -5,6 +5,8 @@ import { argv, exit } from 'node:process'
import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
/**
* Enhanced reporting utility to analyze and validate Live2D ZIP structures.
*/
@@ -20,7 +22,7 @@ async function generateReport(zipPath: string) {
}
const data = fs.readFileSync(zipPath)
const zip = await JSZip.loadAsync(data)
const zip = await JSZip.loadAsync(data, { decodeFileName: decodeZipFileName })
const allFiles = Object.keys(zip.files)
const report = {
@@ -1,5 +1,7 @@
import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
export interface Live2DValidationReport {
fileName: string
totalFiles: number
@@ -17,7 +19,7 @@ export interface Live2DValidationReport {
}
export async function validateLive2DZip(file: File | Blob): Promise<Live2DValidationReport> {
const zip = await JSZip.loadAsync(file)
const zip = await JSZip.loadAsync(file, { decodeFileName: decodeZipFileName })
const allPaths = Object.keys(zip.files)
const report: Live2DValidationReport = {
@@ -47,19 +47,6 @@ function createShisihangshiSettingsText(): string {
})
}
function createNonAsciiSettingsText(): string {
return JSON.stringify({
Version: 3,
FileReferences: {
Moc: '模型文件.moc3',
Textures: ['模型贴图.4096/texture_00.png'],
Physics: '模型文件.physics3.json',
DisplayInfo: '模型文件.cdi3.json',
},
Groups: [],
})
}
const appleDoubleHeader = new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0, 77, 97, 99, 32, 79, 83, 32, 88])
describe('live2d zip loader settings sanitization', () => {
@@ -150,89 +137,6 @@ describe('live2d zip loader settings sanitization', () => {
expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow()
})
it('loads an OPFS-restored file directory when model3.json references non-ASCII file names', async () => {
await import('./live2d-zip-loader')
const { FileLoader } = await import('pixi-live2d-display/cubism4')
const files = [
fileWithRelativePath(
createNonAsciiSettingsText(),
'模型文件.model3.json',
'非ASCII模型26045/模型文件.model3.json',
),
fileWithRelativePath(
new Uint8Array([77, 79, 67, 51]),
'模型文件.moc3',
'非ASCII模型26045/模型文件.moc3',
),
fileWithRelativePath(
new Uint8Array([1, 2, 3]),
'texture_00.png',
'非ASCII模型26045/模型贴图.4096/texture_00.png',
),
fileWithRelativePath(
'{}',
'模型文件.physics3.json',
'非ASCII模型26045/模型文件.physics3.json',
),
fileWithRelativePath(
'{}',
'模型文件.cdi3.json',
'非ASCII模型26045/模型文件.cdi3.json',
),
]
const settings = await FileLoader.createSettings(files)
// ROOT CAUSE:
//
// pixi-live2d-display validates OPFS-restored File objects against
// encodeURI(file.webkitRelativePath), but the settings created from the
// original model3.json currently keep non-ASCII file references unencoded.
//
// Before the fix, the settings expect "模型文件.moc3" while the available
// file list contains URI-encoded non-ASCII directory paths, so validation
// reports that the moc3 file does not exist.
expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow()
})
it('does not double encode existing URI-encoded model3.json file references', async () => {
await import('./live2d-zip-loader')
const { FileLoader } = await import('pixi-live2d-display/cubism4')
const files = [
fileWithRelativePath(
JSON.stringify({
Version: 3,
FileReferences: {
Moc: '%E6%A8%A1%E5%9E%8B%E6%96%87%E4%BB%B6.moc3',
Textures: ['%E6%A8%A1%E5%9E%8B%E8%B4%B4%E5%9B%BE.4096/texture_00.png'],
},
Groups: [],
}),
'模型文件.model3.json',
'非ASCII模型26045/模型文件.model3.json',
),
fileWithRelativePath(
new Uint8Array([77, 79, 67, 51]),
'模型文件.moc3',
'非ASCII模型26045/模型文件.moc3',
),
fileWithRelativePath(
new Uint8Array([1, 2, 3]),
'texture_00.png',
'非ASCII模型26045/模型贴图.4096/texture_00.png',
),
]
const settings = await FileLoader.createSettings(files)
expect(settings.moc).toBe('%E6%A8%A1%E5%9E%8B%E6%96%87%E4%BB%B6.moc3')
expect(settings.textures).toEqual(['%E6%A8%A1%E5%9E%8B%E8%B4%B4%E5%9B%BE.4096/texture_00.png'])
expect(settings.moc).not.toContain('%25E6')
expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow()
})
it('loads an OPFS-restored file directory when a macOS AppleDouble settings sidecar is present before the real settings file', async () => {
await import('./live2d-zip-loader')
const { FileLoader } = await import('pixi-live2d-display/cubism4')
@@ -4,7 +4,13 @@ import JSZip from 'jszip'
import { Cubism4ModelSettings, FileLoader, Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4'
ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data)
import { decodeZipFileName } from './decode-zip-filename'
// Legacy/VTube-Studio archives often store entry names without the UTF-8 flag in a legacy
// codepage; decode them so non-ASCII names (e.g. `手姿势切换.exp3.json`) don't become
// mojibake. The same decoder must be used by `validateLive2DZip`, otherwise validation
// sees mojibake paths and rejects archives before they reach this loader.
ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data, { decodeFileName: decodeZipFileName })
interface IgnoredArchivePathSegmentRule {
matches: (segment: string) => boolean
@@ -109,22 +115,7 @@ function createModelSettings(text: string, url: string): ModelSettings {
throw new Error('Unknown settings JSON')
}
// pixi-live2d-display validates files with encodeURI(file.webkitRelativePath).
// Decode first so archives that already store URI-encoded references do not
// get `%` encoded again.
const settings = runtime.createModelSettings(settingsJSON)
const normalizeFileReference = (file: string) => {
try {
return encodeURI(decodeURI(file))
}
catch {
return encodeURI(file)
}
}
settings.url = normalizeFileReference(settings.url)
settings.replaceFiles(normalizeFileReference)
return settings
return runtime.createModelSettings(settingsJSON)
}
export function isSettingsFile(file: string) {
@@ -166,7 +166,7 @@ describe('opfs cache full directory persistence', () => {
await OPFSCache.writeFile(
dir as unknown as FileSystemDirectoryHandle,
'__meta.json',
JSON.stringify({ sourceUrl: 'blob:first', version: 2 }),
JSON.stringify({ sourceUrl: 'blob:first', version: 3 }),
)
const files = await OPFSCache.get('metadata-model', 'blob:second')
@@ -2,6 +2,8 @@ import type { Live2DFactoryContext, Middleware } from 'pixi-live2d-display/cubis
import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
interface OPFSContext extends Live2DFactoryContext {
opfsKey?: string
opfsUrl?: string
@@ -17,8 +19,11 @@ interface OPFSCacheMeta {
* Cache schema version for OPFS-stored Live2D zip directories.
*
* Increment when the persisted directory shape changes.
*
* v3: entry paths are now decoded via `decodeZipFileName`; bumping invalidates
* caches written with the previous mojibake filenames so they are re-saved.
*/
const live2DOpfsCacheVersion = 2
const live2DOpfsCacheVersion = 3
interface IgnoredArchivePathSegmentRule {
matches: (segment: string) => boolean
@@ -190,7 +195,7 @@ export class OPFSCache {
*/
static async save(key: string, zipBlob: Blob, sourceUrl?: string): Promise<void> {
try {
const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer())
const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer(), { decodeFileName: decodeZipFileName })
const fileEntries = Object.values(zip.files)
.filter(file => !file.dir && !shouldIgnoreLive2DArchiveEntry(file.name))