fix(stage-ui-live2d): decode legacy-codepage zip filenames (#2016)
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user