diff --git a/backend/app/routers/video.py b/backend/app/routers/video.py index 148885f..e75b6cc 100644 --- a/backend/app/routers/video.py +++ b/backend/app/routers/video.py @@ -238,6 +238,20 @@ def video_spec(payload: VideoRenderRequest, request: Request, session: Session = note_id=note.id, ) return DataResponse(data=VideoSpecRead( - spec=imported.spec.model_dump(mode="json", by_alias=True, exclude_none=True), - warnings=imported.warnings, + spec=_utf8_safe(imported.spec.model_dump(mode="json", by_alias=True, exclude_none=True)), + warnings=_utf8_safe(imported.warnings), )) + + +def _utf8_safe(value): + """Replace lone UTF-16 surrogates — what a half-deleted emoji leaves in a + note — which a Python string can hold but a UTF-8 response cannot, so one + stray character would otherwise fail the whole response. Rendering keeps + the text as it is; only this document's copy is touched.""" + if isinstance(value, str): + return value.encode("utf-16", "surrogatepass").decode("utf-16", "replace") + if isinstance(value, list): + return [_utf8_safe(v) for v in value] + if isinstance(value, dict): + return {k: _utf8_safe(v) for k, v in value.items()} + return value diff --git a/backend/tests/test_video_spec.py b/backend/tests/test_video_spec.py index 470ffca..5334672 100644 --- a/backend/tests/test_video_spec.py +++ b/backend/tests/test_video_spec.py @@ -484,3 +484,17 @@ def test_the_spec_route_refuses_another_users_note_and_bad_options(notes): _spec_route(notes, options={"aspect": "4:3"}) assert invalid.value.status_code == 400 + +def test_the_spec_route_survives_half_of_a_deleted_emoji(notes): + # JSON.stringify writes a lone surrogate as \ud83d; decoded, it is a valid + # Python string but not valid UTF-8, so the response could not be encoded. + note = notes.get(Note, "mine") + note.content = json.dumps([ + {"id": "p", "type": "paragraph", "content": [{"type": "text", "text": "Hi \ud83d there \U0001f98e"}]}, + ]) + notes.add(note) + notes.commit() + data = _spec_route(notes, options={"title_card": False}) + encoded = json.dumps(data.model_dump(), ensure_ascii=False).encode("utf-8") + assert "Hi \ufffd there \U0001f98e." in encoded.decode("utf-8") +