Skip to content

Commit 04a7e8e

Browse files
authored
fix(sheets): write Excel-compatible clipboard HTML (#7479)
1 parent 2bdddf6 commit 04a7e8e

4 files changed

Lines changed: 112 additions & 5 deletions

File tree

packages/sheets-ui/src/services/clipboard/__tests__/clipboard-state.service.spec.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
IUndoRedoService,
2929
IUniverInstanceService,
3030
LocaleService,
31+
LocaleType,
3132
LocalUndoRedoService,
3233
ObjectMatrix,
3334
RANGE_TYPE,
@@ -143,8 +144,8 @@ interface IPrivateClipboardServiceAccess {
143144
_topLeftCellsMatch(rowCount: number, colCount: number, range: { topRow: number; leftCol: number }): boolean;
144145
}
145146

146-
function createTestContext() {
147-
vi.stubGlobal('navigator', { appVersion: 'Linux' });
147+
function createTestContext(appVersion = 'Linux') {
148+
vi.stubGlobal('navigator', { appVersion });
148149
const injector = new Injector();
149150
injector.add([ILogService, { useClass: DesktopLogService }]);
150151
injector.add([IConfigService, { useClass: ConfigService }]);
@@ -163,6 +164,10 @@ function createTestContext() {
163164
injector.add([LocaleService]);
164165
injector.add([ErrorService]);
165166
injector.add([ISheetClipboardService, { useClass: SheetClipboardService }]);
167+
const localeService = injector.get(LocaleService);
168+
localeService.load({ [LocaleType.ZH_CN]: {} });
169+
localeService.setLocale(LocaleType.ZH_CN);
170+
localeService.setDirection('ltr');
166171
const commandService = injector.get(ICommandService);
167172
commandService.registerCommand(SetSelectionsOperation);
168173
commandService.registerCommand(SetWorksheetActiveOperation);
@@ -276,6 +281,58 @@ describe('SheetClipboardService', () => {
276281
expect(copyId && service.copyContentCache().get(copyId)?.copyType).toBe(COPY_TYPE.COPY);
277282
});
278283

284+
it('wraps merged sheet html with Excel metadata only when writing to the system clipboard', async () => {
285+
const { injector, service } = createTestContext();
286+
selectRange(injector, 1, 1, 2, 2);
287+
service.addClipboardHook({
288+
id: 'merged-cell-html',
289+
onCopyCellStyle(_row: number, _column: number, rowSpan?: number, colSpan?: number) {
290+
return rowSpan || colSpan
291+
? { rowspan: `${rowSpan || 1}`, colspan: `${colSpan || 1}` }
292+
: undefined;
293+
},
294+
} as never);
295+
296+
const generatedHtml = service.generateCopyContent('unit-1', 'sheet-1', {
297+
startRow: 1,
298+
startColumn: 1,
299+
endRow: 2,
300+
endColumn: 2,
301+
})?.html;
302+
303+
expect(generatedHtml).toMatch(/^<google-sheets-html-origin><table/);
304+
expect(await service.copy()).toBe(true);
305+
306+
const clipboard = injector.get(IClipboardInterfaceService) as unknown as TestClipboardInterfaceService;
307+
const writtenHtml = clipboard.writes[0].html;
308+
309+
expect(writtenHtml).toContain('xmlns:o="urn:schemas-microsoft-com:office:office"');
310+
expect(writtenHtml).toContain('xmlns:x="urn:schemas-microsoft-com:office:excel"');
311+
expect(writtenHtml).toContain('xmlns="http://www.w3.org/TR/REC-html40"');
312+
expect(writtenHtml).toContain('<meta name="ProgId" content="Excel.Sheet">');
313+
expect(writtenHtml).toContain('<meta name="Generator" content="Univer">');
314+
expect(writtenHtml).toContain('<!--StartFragment--><table');
315+
expect(writtenHtml).toContain('rowspan="2" colspan="2"');
316+
expect(writtenHtml).toContain('data-copy-id=');
317+
expect(writtenHtml).toContain('</table><!--EndFragment-->');
318+
});
319+
320+
it('recognizes its Excel-compatible clipboard html as internal content on Windows', async () => {
321+
const { injector, service } = createTestContext('Windows');
322+
selectCell(injector);
323+
const notificationService = injector.get(INotificationService);
324+
const notificationSpy = vi.spyOn(notificationService, 'show');
325+
326+
expect(await service.copy()).toBe(true);
327+
328+
const clipboard = injector.get(IClipboardInterfaceService) as unknown as TestClipboardInterfaceService;
329+
const item = new MockClipboardItem({ 'text/html': clipboard.writes[0].html });
330+
331+
await service.paste(item as unknown as ClipboardItem);
332+
333+
expect(notificationSpy).not.toHaveBeenCalled();
334+
});
335+
279336
it('writes formula clipboard payload for copyable formula cells', async () => {
280337
const { injector, service } = createTestContext();
281338
selectRange(injector, 1, 2, 2, 3);
@@ -338,6 +395,7 @@ describe('SheetClipboardService', () => {
338395
const copyId = clipboard.writes[0].html.match(/data-copy-id="([^"]+)"/)?.[1];
339396

340397
expect(cut).toBe(true);
398+
expect(clipboard.writes[0].html).toContain('xmlns:x="urn:schemas-microsoft-com:office:excel"');
341399
expect(service.copyContentCache().get(copyId!)?.copyType).toBe(COPY_TYPE.CUT);
342400
});
343401

packages/sheets-ui/src/services/clipboard/clipboard.service.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ const IMAGE_MIME_TO_EXTENSION = {
142142

143143
export const FORMULA_CLIPBOARD_MIME_TYPE = 'web application/x-univer-sheets-formula';
144144

145+
function wrapHtmlForExcel(html: string): string {
146+
const table = html.match(/<table[\s\S]*<\/table>/)?.[0] ?? html;
147+
148+
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"></head><body><!--StartFragment-->${table}<!--EndFragment--></body></html>`;
149+
}
150+
145151
interface IFormulaClipboardPayload {
146152
rowCount: number;
147153
columnCount: number;
@@ -331,7 +337,7 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
331337
// 4. write html and get plain text info the clipboard interface
332338
await this._clipboardInterfaceService.write(
333339
plain,
334-
html,
340+
wrapHtmlForExcel(html),
335341
formulaClipboardPayload ? { [FORMULA_CLIPBOARD_MIME_TYPE]: formulaClipboardPayload } : undefined
336342
);
337343

@@ -370,7 +376,9 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
370376
const shouldUseHTMLPaste = imageIndex === -1 || !htmlContainsImage(html);
371377
if (html && shouldUseHTMLPaste) {
372378
// Firstly see if the html content is from Excel
373-
if (this._platformService.isWindows && htmlIsFromExcel(html)) {
379+
const copyId = extractId(html);
380+
const isInternalCopy = Boolean(copyId && this._copyContentCache.get(copyId));
381+
if (this._platformService.isWindows && htmlIsFromExcel(html) && !isInternalCopy) {
374382
this._notificationService.show({
375383
type: 'warning',
376384
title: this._localeService.t<LocaleKey>('sheets-ui.clipboard.shortCutNotify.title'),

packages/ui/src/services/clipboard/__tests__/clipboard-interface.service.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,38 @@ describe('BrowserClipboardService', () => {
132132
expect(document.body.lastElementChild?.textContent).not.toBe('rich');
133133
});
134134

135+
it('should preserve Univer Excel metadata while sanitizing legacy html', async () => {
136+
vi.mocked(supportClipboardAPI).mockReturnValue(false);
137+
const { service } = createService();
138+
const clipboardData = {
139+
setData: vi.fn(),
140+
};
141+
142+
vi.mocked(document.execCommand).mockImplementation(() => {
143+
const event = new Event('copy', { cancelable: true }) as ClipboardEvent;
144+
Object.defineProperty(event, 'clipboardData', {
145+
value: clipboardData,
146+
});
147+
document.dispatchEvent(event);
148+
return true;
149+
});
150+
151+
await service.write(
152+
'merged',
153+
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"><script>alert(1)</script></head><body><!--StartFragment--><table data-copy-id="copy-id"><tbody><tr><td rowspan="3" onclick="alert(1)">merged</td></tr></tbody></table><!--EndFragment--></body></html>'
154+
);
155+
156+
const copiedHtml = clipboardData.setData.mock.calls.find(([type]) => type === 'text/html')?.[1];
157+
158+
expect(copiedHtml).toContain('xmlns:x="urn:schemas-microsoft-com:office:excel"');
159+
expect(copiedHtml).toContain('<meta name="ProgId" content="Excel.Sheet">');
160+
expect(copiedHtml).toContain('<!--StartFragment--><table data-copy-id="copy-id">');
161+
expect(copiedHtml).toContain('<td rowspan="3">merged</td>');
162+
expect(copiedHtml).toContain('</table><!--EndFragment-->');
163+
expect(copiedHtml).not.toContain('<script');
164+
expect(copiedHtml).not.toContain('onclick');
165+
});
166+
135167
it('should sanitize html before legacy copy', async () => {
136168
vi.mocked(supportClipboardAPI).mockReturnValue(false);
137169
const { service } = createService();

packages/ui/src/services/clipboard/clipboard-interface.service.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,16 @@ function sanitizeHtmlForClipboard(html: string): DocumentFragment {
373373
function serializeSanitizedHtmlForClipboard(html: string): string {
374374
const container = document.createElement('div');
375375
container.appendChild(sanitizeHtmlForClipboard(html));
376-
return container.innerHTML;
376+
const sanitizedHtml = container.innerHTML;
377+
const isUniverExcelHtml = html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"')
378+
&& html.includes('<meta name="ProgId" content="Excel.Sheet">')
379+
&& html.includes('<meta name="Generator" content="Univer">');
380+
381+
if (!isUniverExcelHtml) {
382+
return sanitizedHtml;
383+
}
384+
385+
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"></head><body><!--StartFragment-->${sanitizedHtml}<!--EndFragment--></body></html>`;
377386
}
378387

379388
function sanitizeHtmlNode(node: Node): Node | null {

0 commit comments

Comments
 (0)