diff --git a/shared/util/fileUtil.ts b/shared/util/fileUtil.ts index 71601a90db..c5aacd8a0d 100644 --- a/shared/util/fileUtil.ts +++ b/shared/util/fileUtil.ts @@ -32,10 +32,18 @@ export async function loadFileBuffer(requireUrl: { // in vitest, this is the url we need to load with readfilesync // eslint-disable-next-line const fs = require("fs"); - let url = requireUrl.default; - - if (url.startsWith("/@fs/")) { - url = url.substring("/@fs".length); + // Direct paths can contain literal percent sequences. Prefer an existing + // path, then decode Vite's /@fs and encoded absolute asset URLs. + const assetPath = requireUrl.default; + let url = assetPath; + if (assetPath.startsWith("/@fs/")) { + url = decodeURIComponent(assetPath.substring("/@fs".length)); + } else if (!fs.existsSync(assetPath)) { + try { + url = decodeURIComponent(assetPath); + } catch { + // Preserve a malformed percent sequence as a literal filesystem path. + } } // On Windows, vite/vitest may yield paths like "/C:/..." after removing /@fs // Node on Windows treats paths starting with "/" as relative to current drive, diff --git a/tests/src/unit/shared/fileUtil.test.ts b/tests/src/unit/shared/fileUtil.test.ts new file mode 100644 index 0000000000..e1a009975e --- /dev/null +++ b/tests/src/unit/shared/fileUtil.test.ts @@ -0,0 +1,63 @@ +import { loadFileBuffer, loadFontDataUrl } from "@shared/util/fileUtil.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +describe("Vite asset file loading", () => { + let directory: string; + const contents = Buffer.from([0, 127, 128, 255]); + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "blocknote-file-url-")); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + for (const prefix of ["", "/@fs"]) { + it.each(["한글 font.ttf", "font #100% %20.ttf"])( + `reads encoded ${prefix || "absolute"} asset paths: %s`, + async (name) => { + const file = join(directory, name); + writeFileSync(file, contents); + + const buffer = await loadFileBuffer({ + default: prefix + pathToFileURL(file).pathname, + }); + + expect(buffer).toEqual(contents); + }, + ); + } + + it("reads an unencoded filesystem path", async () => { + const file = join(directory, "font.ttf"); + writeFileSync(file, contents); + + expect(await loadFileBuffer({ default: file })).toEqual(contents); + }); + + it.each(["font%20.ttf", "font%foo.ttf"])( + "preserves a direct filesystem path containing %s", + async (name) => { + const file = join(directory, name); + writeFileSync(file, contents); + + expect(await loadFileBuffer({ default: file })).toEqual(contents); + }, + ); + + it("creates a font data URL from an encoded asset path", async () => { + const file = join(directory, "한글 font.ttf"); + writeFileSync(file, contents); + + expect( + await loadFontDataUrl({ + default: "/@fs" + pathToFileURL(file).pathname, + }), + ).toBe(`data:font/ttf;base64,${contents.toString("base64")}`); + }); +});