Path: blob/main/extensions/ipynb/src/notebookImagePaste.ts
3292 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import { JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR } from './constants';7import { basename, extname } from 'path';89enum MimeType {10bmp = 'image/bmp',11gif = 'image/gif',12ico = 'image/ico',13jpeg = 'image/jpeg',14png = 'image/png',15tiff = 'image/tiff',16webp = 'image/webp',17plain = 'text/plain',18uriList = 'text/uri-list',19}2021const imageMimeTypes: ReadonlySet<string> = new Set<string>([22MimeType.bmp,23MimeType.gif,24MimeType.ico,25MimeType.jpeg,26MimeType.png,27MimeType.tiff,28MimeType.webp,29]);3031const imageExtToMime: ReadonlyMap<string, string> = new Map<string, string>([32['.bmp', MimeType.bmp],33['.gif', MimeType.gif],34['.ico', MimeType.ico],35['.jpe', MimeType.jpeg],36['.jpeg', MimeType.jpeg],37['.jpg', MimeType.jpeg],38['.png', MimeType.png],39['.tif', MimeType.tiff],40['.tiff', MimeType.tiff],41['.webp', MimeType.webp],42]);4344function getImageMimeType(uri: vscode.Uri): string | undefined {45return imageExtToMime.get(extname(uri.fsPath).toLowerCase());46}4748class DropOrPasteEditProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider {4950public static readonly kind = vscode.DocumentDropOrPasteEditKind.Empty.append('markdown', 'link', 'image', 'attachment');5152async provideDocumentPasteEdits(53document: vscode.TextDocument,54_ranges: readonly vscode.Range[],55dataTransfer: vscode.DataTransfer,56_context: vscode.DocumentPasteEditContext,57token: vscode.CancellationToken,58): Promise<vscode.DocumentPasteEdit[] | undefined> {59const enabled = vscode.workspace.getConfiguration('ipynb', document).get('pasteImagesAsAttachments.enabled', true);60if (!enabled) {61return;62}6364const insert = await this.createInsertImageAttachmentEdit(document, dataTransfer, token);65if (!insert) {66return;67}6869const pasteEdit = new vscode.DocumentPasteEdit(insert.insertText, vscode.l10n.t('Insert Image as Attachment'), DropOrPasteEditProvider.kind);70pasteEdit.yieldTo = [vscode.DocumentDropOrPasteEditKind.Text];71pasteEdit.additionalEdit = insert.additionalEdit;72return [pasteEdit];73}7475async provideDocumentDropEdits(76document: vscode.TextDocument,77_position: vscode.Position,78dataTransfer: vscode.DataTransfer,79token: vscode.CancellationToken,80): Promise<vscode.DocumentDropEdit | undefined> {81const insert = await this.createInsertImageAttachmentEdit(document, dataTransfer, token);82if (!insert) {83return;84}8586const dropEdit = new vscode.DocumentDropEdit(insert.insertText);87dropEdit.yieldTo = [vscode.DocumentDropOrPasteEditKind.Text];88dropEdit.additionalEdit = insert.additionalEdit;89dropEdit.title = vscode.l10n.t('Insert Image as Attachment');90return dropEdit;91}9293private async createInsertImageAttachmentEdit(94document: vscode.TextDocument,95dataTransfer: vscode.DataTransfer,96token: vscode.CancellationToken,97): Promise<{ insertText: vscode.SnippetString; additionalEdit: vscode.WorkspaceEdit } | undefined> {98const imageData = await getDroppedImageData(dataTransfer, token);99if (!imageData.length || token.isCancellationRequested) {100return;101}102103const currentCell = getCellFromCellDocument(document);104if (!currentCell) {105return undefined;106}107108// create updated metadata for cell (prep for WorkspaceEdit)109const newAttachment = buildAttachment(currentCell, imageData);110if (!newAttachment) {111return;112}113114// build edits115const additionalEdit = new vscode.WorkspaceEdit();116const nbEdit = vscode.NotebookEdit.updateCellMetadata(currentCell.index, newAttachment.metadata);117const notebookUri = currentCell.notebook.uri;118additionalEdit.set(notebookUri, [nbEdit]);119120// create a snippet for paste121const insertText = new vscode.SnippetString();122newAttachment.filenames.forEach((filename, i) => {123insertText.appendText(' ? `<attachment:${filename}>` : `attachment:${filename}`})`);126if (i !== newAttachment.filenames.length - 1) {127insertText.appendText(' ');128}129});130131return { insertText, additionalEdit };132}133}134135async function getDroppedImageData(136dataTransfer: vscode.DataTransfer,137token: vscode.CancellationToken,138): Promise<readonly ImageAttachmentData[]> {139140// Prefer using image data in the clipboard141const files = coalesce(await Promise.all(Array.from(dataTransfer, async ([mimeType, item]): Promise<ImageAttachmentData | undefined> => {142if (!imageMimeTypes.has(mimeType)) {143return;144}145146const file = item.asFile();147if (!file) {148return;149}150151const data = await file.data();152return { fileName: file.name, mimeType, data };153})));154if (files.length) {155return files;156}157158// Then fallback to image files in the uri-list159const urlList = await dataTransfer.get('text/uri-list')?.asString();160if (token.isCancellationRequested) {161return [];162}163164if (urlList) {165const uris: vscode.Uri[] = [];166for (const resource of urlList.split(/\r?\n/g)) {167try {168uris.push(vscode.Uri.parse(resource));169} catch {170// noop171}172}173174const entries = await Promise.all(uris.map(async (uri) => {175const mimeType = getImageMimeType(uri);176if (!mimeType) {177return;178}179180const data = await vscode.workspace.fs.readFile(uri);181return { fileName: basename(uri.fsPath), mimeType, data };182}));183184return coalesce(entries);185}186187return [];188}189190function coalesce<T>(array: ReadonlyArray<T | undefined | null>): T[] {191return <T[]>array.filter(e => !!e);192}193194function getCellFromCellDocument(cellDocument: vscode.TextDocument): vscode.NotebookCell | undefined {195for (const notebook of vscode.workspace.notebookDocuments) {196if (notebook.uri.path === cellDocument.uri.path) {197for (const cell of notebook.getCells()) {198if (cell.document === cellDocument) {199return cell;200}201}202}203}204return undefined;205}206207/**208* Taken from https://github.com/microsoft/vscode/blob/743b016722db90df977feecde0a4b3b4f58c2a4c/src/vs/base/common/buffer.ts#L350-L387209*/210function encodeBase64(buffer: Uint8Array, padded = true, urlSafe = false) {211const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';212const base64UrlSafeAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';213214const dictionary = urlSafe ? base64UrlSafeAlphabet : base64Alphabet;215let output = '';216217const remainder = buffer.byteLength % 3;218219let i = 0;220for (; i < buffer.byteLength - remainder; i += 3) {221const a = buffer[i + 0];222const b = buffer[i + 1];223const c = buffer[i + 2];224225output += dictionary[a >>> 2];226output += dictionary[(a << 4 | b >>> 4) & 0b111111];227output += dictionary[(b << 2 | c >>> 6) & 0b111111];228output += dictionary[c & 0b111111];229}230231if (remainder === 1) {232const a = buffer[i + 0];233output += dictionary[a >>> 2];234output += dictionary[(a << 4) & 0b111111];235if (padded) { output += '=='; }236} else if (remainder === 2) {237const a = buffer[i + 0];238const b = buffer[i + 1];239output += dictionary[a >>> 2];240output += dictionary[(a << 4 | b >>> 4) & 0b111111];241output += dictionary[(b << 2) & 0b111111];242if (padded) { output += '='; }243}244245return output;246}247248249interface ImageAttachmentData {250readonly fileName: string;251readonly data: Uint8Array;252readonly mimeType: string;253}254255function buildAttachment(256cell: vscode.NotebookCell,257attachments: readonly ImageAttachmentData[],258): { metadata: { [key: string]: any }; filenames: string[] } | undefined {259const cellMetadata = { ...cell.metadata };260const tempFilenames: string[] = [];261if (!attachments.length) {262return undefined;263}264265if (!cellMetadata.attachments) {266cellMetadata.attachments = {};267}268269for (const attachment of attachments) {270const b64 = encodeBase64(attachment.data);271272const fileExt = extname(attachment.fileName);273const filenameWithoutExt = basename(attachment.fileName, fileExt);274275let tempFilename = filenameWithoutExt + fileExt;276for (let appendValue = 2; tempFilename in cellMetadata.attachments; appendValue++) {277const objEntries = Object.entries(cellMetadata.attachments[tempFilename]);278if (objEntries.length) { // check that mime:b64 are present279const [mime, attachmentb64] = objEntries[0];280if (mime === attachment.mimeType && attachmentb64 === b64) { // checking if filename can be reused, based on comparison of image data281break;282} else {283tempFilename = filenameWithoutExt.concat(`-${appendValue}`) + fileExt;284}285}286}287288tempFilenames.push(tempFilename);289cellMetadata.attachments[tempFilename] = { [attachment.mimeType]: b64 };290}291292return {293metadata: cellMetadata,294filenames: tempFilenames,295};296}297298export function notebookImagePasteSetup(): vscode.Disposable {299const provider = new DropOrPasteEditProvider();300return vscode.Disposable.from(301vscode.languages.registerDocumentPasteEditProvider(JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR, provider, {302providedPasteEditKinds: [DropOrPasteEditProvider.kind],303pasteMimeTypes: [304MimeType.png,305MimeType.uriList,306],307}),308vscode.languages.registerDocumentDropEditProvider(JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR, provider, {309providedDropEditKinds: [DropOrPasteEditProvider.kind],310dropMimeTypes: [311...Object.values(imageExtToMime),312MimeType.uriList,313],314})315);316}317318319