Skip to content

Commit 18a6c73

Browse files
authored
fix: handle non-ASCII filenames in export Content-Disposition headers (#7739)
Fixes #7730 - Exporting HTML/script/markdown fails with `UnicodeEncodeError` when the filename contains non-ASCII characters (Chinese, emojis, etc.).
1 parent 0b0875c commit 18a6c73

File tree

3 files changed

+76
-7
lines changed

3 files changed

+76
-7
lines changed

marimo/_server/api/endpoints/export.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
from marimo._server.api.deps import AppState
1717
from marimo._server.api.utils import parse_request
1818
from marimo._server.export.exporter import AutoExporter, Exporter
19-
from marimo._server.export.utils import get_download_filename
19+
from marimo._server.export.utils import (
20+
get_download_filename,
21+
make_download_headers,
22+
)
2023
from marimo._server.models.export import (
2124
ExportAsHTMLRequest,
2225
ExportAsMarkdownRequest,
@@ -89,7 +92,7 @@ async def export_as_html(
8992
)
9093

9194
if body.download:
92-
headers = {"Content-Disposition": f"attachment; filename={filename}"}
95+
headers = make_download_headers(filename)
9396
else:
9497
headers = {}
9598

@@ -212,7 +215,7 @@ async def export_as_script(
212215
)
213216

214217
if body.download:
215-
headers = {"Content-Disposition": f"attachment; filename={filename}"}
218+
headers = make_download_headers(filename)
216219
else:
217220
headers = {}
218221

@@ -269,9 +272,7 @@ async def export_as_markdown(
269272
download_filename = get_download_filename(
270273
app_file_manager.filename, "md"
271274
)
272-
headers = {
273-
"Content-Disposition": f"attachment; filename={download_filename}"
274-
}
275+
headers = make_download_headers(download_filename)
275276
else:
276277
headers = {}
277278

marimo/_server/export/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import os
55
from typing import Optional
6+
from urllib.parse import quote
67

78

89
def get_filename(filename: Optional[str], default: str = "notebook.py") -> str:
@@ -17,3 +18,28 @@ def get_download_filename(filename: Optional[str], extension: str) -> str:
1718
if basename.endswith(f".{extension}"):
1819
return basename
1920
return f"{os.path.splitext(basename)[0]}.{extension}"
21+
22+
23+
def make_download_headers(filename: str) -> dict[str, str]:
24+
"""Create headers for file download with proper Content-Disposition encoding.
25+
26+
This function handles non-ASCII filenames using RFC 5987 encoding
27+
(filename*=UTF-8''...) to avoid UnicodeEncodeError when the filename
28+
contains characters outside the Latin-1 range.
29+
30+
Args:
31+
filename: The filename for the download (may contain non-ASCII chars)
32+
33+
Returns:
34+
A dict with the Content-Disposition header properly encoded
35+
"""
36+
# URL-encode the filename for RFC 5987 (preserves safe chars like .)
37+
encoded_filename = quote(filename, safe="")
38+
39+
# Use RFC 5987 encoding: filename*=UTF-8''<url-encoded-filename>
40+
# Also provide a fallback ASCII filename for older clients
41+
return {
42+
"Content-Disposition": (
43+
f"attachment; filename*=UTF-8''{encoded_filename}"
44+
)
45+
}

tests/_server/api/endpoints/test_export.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
with_read_session,
2424
with_session,
2525
)
26-
from tests.mocks import snapshotter
26+
from tests.mocks import EDGE_CASE_FILENAMES, snapshotter
2727

2828
if TYPE_CHECKING:
2929
from starlette.testclient import TestClient
@@ -579,3 +579,45 @@ def test_export_html_unnamed_file(client: TestClient) -> None:
579579
# Should return 400 Bad Request when file is unnamed
580580
assert response.status_code == 400
581581
assert "File must have a name before exporting" in response.text
582+
583+
584+
@with_session(SESSION_ID)
585+
def test_export_html_download_edge_case_filenames(client: TestClient) -> None:
586+
"""Test that HTML export with download=True works for non-ASCII filenames."""
587+
for filename in EDGE_CASE_FILENAMES:
588+
session = get_session_manager(client).get_session(SESSION_ID)
589+
assert session
590+
session.app_file_manager.filename = filename
591+
response = client.post(
592+
"/api/export/html",
593+
headers=HEADERS,
594+
json={
595+
"download": True,
596+
"files": [],
597+
"includeCode": True,
598+
},
599+
)
600+
assert response.status_code == 200, f"Failed for filename: {filename}"
601+
assert "Content-Disposition" in response.headers
602+
assert "attachment" in response.headers["Content-Disposition"]
603+
604+
605+
@with_session(SESSION_ID)
606+
def test_export_script_download_edge_case_filenames(
607+
client: TestClient,
608+
) -> None:
609+
"""Test that script export with download=True works for non-ASCII filenames."""
610+
for filename in EDGE_CASE_FILENAMES:
611+
session = get_session_manager(client).get_session(SESSION_ID)
612+
assert session
613+
session.app_file_manager.filename = filename
614+
response = client.post(
615+
"/api/export/script",
616+
headers=HEADERS,
617+
json={
618+
"download": True,
619+
},
620+
)
621+
assert response.status_code == 200, f"Failed for filename: {filename}"
622+
assert "Content-Disposition" in response.headers
623+
assert "attachment" in response.headers["Content-Disposition"]

0 commit comments

Comments
 (0)