Skip to content

Commit a209c09

Browse files
committed
Fix UTF-8 character encoding in playlist HTML export
- Fix getAsDataURI to properly encode UTF-8 characters before base64 conversion - Add React import required for JSX - Add comprehensive unit tests for playlistHtml including non-Latin-1 characters - Test coverage includes emoji, Chinese, and Cyrillic characters The previous implementation used window.btoa() directly which only supports Latin-1 (ISO-8859-1) characters. This caused InvalidCharacterError when playlist track names contained UTF-8 characters like emoji or non-Latin scripts. The fix encodes UTF-8 strings to percent-encoded format first, then converts to Latin-1 bytes before base64 encoding, ensuring all Unicode characters are properly preserved.
1 parent 33003a8 commit a209c09

File tree

2 files changed

+101
-2
lines changed

2 files changed

+101
-2
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createPlaylistURL, getAsDataURI } from "./playlistHtml";
2+
3+
function base64ToUtf8(str: string): string {
4+
return decodeURIComponent(
5+
Array.prototype.map
6+
.call(
7+
atob(str),
8+
(c: string) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`
9+
)
10+
.join("")
11+
);
12+
}
13+
14+
describe("playlistHtml", () => {
15+
describe("createPlaylistURL", () => {
16+
it("handles track names with characters outside Latin-1 range", () => {
17+
const props = {
18+
averageTrackLength: "3:45",
19+
numberOfTracks: 3,
20+
playlistLengthSeconds: 15,
21+
playlistLengthMinutes: 11,
22+
tracks: [
23+
"Song with emoji 🎵🎶",
24+
"中文歌曲名称.mp3",
25+
"Песня на русском.mp3",
26+
],
27+
};
28+
29+
const result = createPlaylistURL(props);
30+
31+
// Should be a valid data URI
32+
expect(result).toMatch(/^data:text\/html;base64,/);
33+
34+
// Decode the base64 to check the content
35+
const base64Content = result.replace("data:text/html;base64,", "");
36+
const decodedHTML = base64ToUtf8(base64Content);
37+
38+
// Check that all track names are present in the decoded HTML
39+
expect(decodedHTML).toContain("Song with emoji 🎵🎶");
40+
expect(decodedHTML).toContain("中文歌曲名称.mp3");
41+
expect(decodedHTML).toContain("Песня на русском.mp3");
42+
43+
// Verify playlist metadata is included
44+
expect(decodedHTML).toContain("3");
45+
expect(decodedHTML).toContain("3:45");
46+
expect(decodedHTML).toContain("11");
47+
expect(decodedHTML).toContain("15");
48+
});
49+
50+
it("creates valid HTML with basic track names", () => {
51+
const props = {
52+
averageTrackLength: "4:20",
53+
numberOfTracks: 1,
54+
playlistLengthSeconds: 20,
55+
playlistLengthMinutes: 4,
56+
tracks: ["test-track.mp3"],
57+
};
58+
59+
const result = createPlaylistURL(props);
60+
61+
expect(result).toMatch(/^data:text\/html;base64,/);
62+
63+
const base64Content = result.replace("data:text/html;base64,", "");
64+
const decodedHTML = atob(base64Content);
65+
66+
expect(decodedHTML).toContain("<html>");
67+
expect(decodedHTML).toContain("test-track.mp3");
68+
expect(decodedHTML).toContain("Winamp Generated PlayList");
69+
});
70+
});
71+
72+
describe("getAsDataURI", () => {
73+
it("converts text to base64 data URI", () => {
74+
const text = "Hello, World!";
75+
const result = getAsDataURI(text);
76+
77+
expect(result).toBe("data:text/html;base64,SGVsbG8sIFdvcmxkIQ==");
78+
});
79+
80+
it("handles text with HTML tags", () => {
81+
const text = "<html>Test</html>";
82+
const result = getAsDataURI(text);
83+
84+
expect(result).toMatch(/^data:text\/html;base64,/);
85+
86+
const base64Content = result.replace("data:text/html;base64,", "");
87+
const decoded = atob(base64Content);
88+
expect(decoded).toBe(text);
89+
});
90+
});
91+
});

packages/webamp/js/playlistHtml.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React from "react";
12
import { createRoot } from "react-dom/client";
23
import { flushSync } from "react-dom";
34

@@ -9,8 +10,15 @@ interface Props {
910
tracks: string[];
1011
}
1112

12-
export const getAsDataURI = (text: string): string =>
13-
`data:text/html;base64,${window.btoa(text)}`;
13+
export const getAsDataURI = (text: string): string => {
14+
// Properly encode UTF-8 to base64
15+
// btoa() only handles Latin-1 (ISO-8859-1), so we need to encode UTF-8 first
16+
const utf8Bytes = encodeURIComponent(text).replace(
17+
/%([0-9A-F]{2})/g,
18+
(_, p1) => String.fromCharCode(parseInt(p1, 16))
19+
);
20+
return `data:text/html;base64,${window.btoa(utf8Bytes)}`;
21+
};
1422

1523
// Replaces deprecated "noshade" attribute
1624
const noshadeStyle = {

0 commit comments

Comments
 (0)