diff --git a/frontend/src/components/misc/kowl-json-view.test.tsx b/frontend/src/components/misc/kowl-json-view.test.tsx index 9460626f23..c49e6a999f 100644 --- a/frontend/src/components/misc/kowl-json-view.test.tsx +++ b/frontend/src/components/misc/kowl-json-view.test.tsx @@ -161,4 +161,24 @@ describe('KowlJsonView', () => { expect(editorLayoutSpy).toHaveBeenLastCalledWith({ width: 800, height: 480 }); }); + + test('escapeLatin1 re-escapes Latin-1 code points so copy-paste recovers the original byte', async () => { + render(); + + await waitFor(() => { + const value = editorPropsSpy.mock.lastCall?.[0].value as string; + expect(value).toContain('\\u00db'); + expect(value).not.toContain('Û'); + }); + }); + + test('without escapeLatin1, Latin-1 glyphs pass through to the editor unchanged', async () => { + render(); + + await waitFor(() => { + const value = editorPropsSpy.mock.lastCall?.[0].value as string; + expect(value).toContain('Û'); + expect(value).not.toContain('\\u00db'); + }); + }); }); diff --git a/frontend/src/components/misc/kowl-json-view.tsx b/frontend/src/components/misc/kowl-json-view.tsx index c6df2f1ff1..8abda24b35 100644 --- a/frontend/src/components/misc/kowl-json-view.tsx +++ b/frontend/src/components/misc/kowl-json-view.tsx @@ -49,15 +49,26 @@ const READ_ONLY_EDITOR_OPTIONS = { scrollBeyondLastLine: false, } as const; -export const KowlJsonView = (props: { srcObj: object | string | null | undefined; style?: CSSProperties }) => { +export const KowlJsonView = (props: { + srcObj: object | string | null | undefined; + style?: CSSProperties; + // escapeLatin1 re-escapes code points in 0x80-0xFF as \u00XX so bytes from + // Avro's JSON bytes encoding survive copy-paste out of the viewer. Without + // this, JSON.stringify emits the code point as a literal glyph and the + // clipboard receives the UTF-8 encoding, not the original byte. + escapeLatin1?: boolean; +}) => { const containerRef = useRef(null); const editorRef = useRef(null); const frameRef = useRef(null); const lastSizeRef = useRef({ width: 0, height: 0 }); - const str = useMemo( - () => (typeof props.srcObj === 'string' ? props.srcObj : JSON.stringify(props.srcObj, undefined, 4)), - [props.srcObj] - ); + const str = useMemo(() => { + const raw = typeof props.srcObj === 'string' ? props.srcObj : JSON.stringify(props.srcObj, undefined, 4); + if (!props.escapeLatin1) { + return raw; + } + return raw.replace(/[\u0080-\u00ff]/g, (c) => `\\u00${c.charCodeAt(0).toString(16).padStart(2, '0')}`); + }, [props.srcObj, props.escapeLatin1]); const scheduleLayout = useCallback(() => { if (frameRef.current !== null) { diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx index 78418637b1..a270f0e48e 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx @@ -182,7 +182,10 @@ export const PayloadComponent = (p: { payload: Payload; loadLargeMessage: () => ); } if (renderData.type === 'json') { - return ; + // Avro JSON encodes bytes fields as \u00XX escape sequences. Re-escape + // Latin-1 code points in the viewer so copy-paste yields the original + // bytes rather than their UTF-8 encoding. + return ; } return Error in RenderExpandedMessage: {renderData.content}; };