diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts new file mode 100644 index 00000000..caa29053 --- /dev/null +++ b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { SerializeAddon } from './SerializeAddon'; +import { Terminal } from 'browser/public/Terminal'; +import { ColorManager } from 'browser/ColorManager'; +import { SelectionModel } from 'browser/selection/SelectionModel'; +import { IBufferService } from 'common/services/Services'; + +function sgr(...seq: string[]): string { + return `\x1b[${seq.join(';')}m`; +} + +function writeP(terminal: Terminal, data: string | Uint8Array): Promise { + return new Promise(r => terminal.write(data, r)); +} + +class TestSelectionService { + private _model: SelectionModel; + private _hasSelection: boolean = false; + + constructor( + bufferService: IBufferService + ) { + this._model = new SelectionModel(bufferService); + } + + public get model(): SelectionModel { return this._model; } + + public get hasSelection(): boolean { return this._hasSelection; } + + public get selectionStart(): [number, number] | undefined { return this._model.finalSelectionStart; } + public get selectionEnd(): [number, number] | undefined { return this._model.finalSelectionEnd; } + + public setSelection(col: number, row: number, length: number): void { + this._model.selectionStart = [col, row]; + this._model.selectionStartLength = length; + this._hasSelection = true; + } +} + +describe('xterm-addon-serialize html', () => { + let cm: ColorManager; + let dom: jsdom.JSDOM; + let document: Document; + let window: jsdom.DOMWindow; + + let serializeAddon: SerializeAddon; + let terminal: Terminal; + let selectionService: any; + + before(() => { + serializeAddon = new SerializeAddon(); + }); + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + window = dom.window; + document = window.document; + + (window as any).HTMLCanvasElement.prototype.getContext = () => ({ + createLinearGradient(): any { + return null; + }, + + fillRect(): void { }, + + getImageData(): any { + return { data: [0, 0, 0, 0xFF] }; + } + }); + + terminal = new Terminal({ cols: 10, rows: 2 }); + terminal.loadAddon(serializeAddon); + + selectionService = new TestSelectionService((terminal as any)._core._bufferService); + cm = new ColorManager(document, false); + (terminal as any)._core._colorManager = cm; + (terminal as any)._core._selectionService = selectionService; + }); + + it('empty terminal with selection turned off', () => { + const output = serializeAddon.serializeAsHTML(); + assert.notEqual(output, ''); + assert.equal((output.match(new RegExp('
{10}<\/div>', 'g')) || []).length, 2); + }); + + it('empty terminal with no selection', () => { + const output = serializeAddon.serializeAsHTML({ + onlySelection: true + }); + assert.equal(output, ''); + }); + + it('basic terminal with selection', async () => { + await writeP(terminal, ' terminal '); + terminal.select(1, 0, 8); + + const output = serializeAddon.serializeAsHTML({ + onlySelection: true + }); + assert.equal((output.match(new RegExp('
terminal<\/span><\/div>', 'g')) || []).length, 1, output); + }); + + it('cells with bold styling', async () => { + await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with italic styling', async () => { + await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with inverse styling', async () => { + await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with underline styling', async () => { + await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with invisible styling', async () => { + await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with dim styling', async () => { + await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with strikethrough styling', async () => { + await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with combined styling', async () => { + await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp(' <\/span>', 'g')) || []).length, 1, output); + assert.equal((output.match(new RegExp('termi<\/span>', 'g')) || []).length, 1, output); + assert.equal((output.match(new RegExp('nal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with color styling', async () => { + await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('cells with background styling', async () => { + await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); + }); + + it('empty terminal with default options', async () => { + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(new RegExp('color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output); + }); + + it('empty terminal with custom options', async () => { + terminal.options.fontFamily = 'verdana'; + terminal.options.fontSize = 20; + terminal.options.theme = { + foreground: '#ff00ff', + background: '#00ff00' + }; + const output = serializeAddon.serializeAsHTML({ + includeGlobalBackground: true + }); + assert.equal((output.match(new RegExp('color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;', 'g')) || []).length, 1, output); + }); + + it('empty terminal with background included', async () => { + const output = serializeAddon.serializeAsHTML({ + includeGlobalBackground: true + }); + assert.equal((output.match(new RegExp('color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output); + }); +}); diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index 24020b74..25a42e65 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -5,8 +5,8 @@ * (EXPERIMENTAL) This Addon is still under development */ -import { Terminal, ITerminalAddon, IBuffer, IBufferCell } from 'xterm'; - +import { Terminal, ITerminalAddon, IBuffer, IBufferCell, IBufferRange } from 'xterm'; +import { IColorSet } from 'browser/Types'; function constrain(value: number, low: number, high: number): number { return Math.max(low, Math.min(value, high)); @@ -19,18 +19,25 @@ abstract class BaseSerializeHandler { ) { } - public serialize(startRow: number, endRow: number): string { + public serialize(range: IBufferRange): string { // we need two of them to flip between old and new cell const cell1 = this._buffer.getNullCell(); const cell2 = this._buffer.getNullCell(); let oldCell = cell1; + const startRow = range.start.x; + const endRow = range.end.x; + const startColumn = range.start.y; + const endColumn = range.end.y; + this._beforeSerialize(endRow - startRow, startRow, endRow); - for (let row = startRow; row < endRow; row++) { + for (let row = startRow; row <= endRow; row++) { const line = this._buffer.getLine(row); if (line) { - for (let col = 0; col < line.length; col++) { + const startLineColumn = row !== range.start.x ? 0 : startColumn; + const endLineColumn = row !== range.end.x ? line.length : endColumn; + for (let col = startLineColumn; col < endLineColumn; col++) { const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); if (!c) { console.warn(`Can't get cell at row=${row}, col=${col}`); @@ -40,7 +47,7 @@ abstract class BaseSerializeHandler { oldCell = c; } } - this._rowEnd(row, row === endRow - 1); + this._rowEnd(row, row === endRow); } this._afterSerialize(); @@ -403,7 +410,35 @@ export class SerializeAddon implements ITerminalAddon { const maxRows = buffer.length; const handler = new StringSerializeHandler(buffer, terminal); const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows); - return handler.serialize(maxRows - correctRows, maxRows); + return handler.serialize({ + start: { x: maxRows - correctRows, y: 0 }, + end: { x: maxRows - 1, y: terminal.cols } + }); + } + + private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string { + const buffer = terminal.buffer.active; + const handler = new HTMLSerializeHandler(buffer, terminal, options); + const onlySelection = options.onlySelection ?? false; + if (!onlySelection) { + const maxRows = buffer.length; + const scrollback = options.scrollback; + const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows); + return handler.serialize({ + start: { x: maxRows - correctRows, y: 0 }, + end: { x: maxRows - 1, y: terminal.cols } + }); + } + + const selection = this._terminal?.getSelectionPosition(); + if (selection !== undefined) { + return handler.serialize({ + start: { x: selection.startRow, y: selection.startColumn }, + end: { x: selection.endRow, y: selection.endColumn } + }); + } + + return ''; } private _serializeModes(terminal: Terminal): string { @@ -460,6 +495,14 @@ export class SerializeAddon implements ITerminalAddon { return content; } + public serializeAsHTML(options?: Partial): string { + if (!this._terminal) { + throw new Error('Cannot use addon until it has been loaded'); + } + + return this._serializeBufferAsHTML(this._terminal, options || {}); + } + public dispose(): void { } } @@ -469,3 +512,150 @@ interface ISerializeOptions { excludeModes?: boolean; excludeAltBuffer?: boolean; } + +interface IHTMLSerializeOptions { + scrollback: number; + onlySelection: boolean; + includeGlobalBackground: boolean; +} + +export class HTMLSerializeHandler extends BaseSerializeHandler { + private _currentRow: string = ''; + + private _htmlContent = ''; + + private _colors: IColorSet; + + constructor( + buffer: IBuffer, + private readonly _terminal: Terminal, + private readonly _options: Partial + ) { + super(buffer); + + // https://github.com/xtermjs/xterm.js/issues/3601 + this._colors = (_terminal as any)._core._colorManager.colors; + } + + private _padStart(target: string, targetLength: number, padString: string): string { + targetLength = targetLength >> 0; + padString = padString ?? ' '; + if (target.length > targetLength) { + return target; + } + + targetLength = targetLength - target.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return padString.slice(0, targetLength) + target; + } + + protected _beforeSerialize(rows: number, start: number, end: number): void { + this._htmlContent += '
';
+
+    let foreground = '#000000';
+    let background = '#ffffff';
+    if (this._options.includeGlobalBackground ?? false) {
+      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';
+      background = this._terminal.options.theme?.background ?? '#000000';
+    }
+
+    const globalStyleDefinitions = [];
+    globalStyleDefinitions.push('color: ' + foreground + ';');
+    globalStyleDefinitions.push('background-color: ' + background + ';');
+    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');
+    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');
+    this._htmlContent += '
'; + } + + protected _afterSerialize(): void { + this._htmlContent += '
'; + this._htmlContent += '
'; + } + + protected _rowEnd(row: number, isLastRow: boolean): void { + this._htmlContent += '
' + this._currentRow + '
'; + this._currentRow = ''; + } + + private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined { + const color = isFg ? cell.getFgColor() : cell.getBgColor(); + if (isFg ? cell.isFgRGB() : cell.isBgRGB()) { + const rgb = [ + (color >> 16) & 255, + (color >> 8) & 255, + (color ) & 255 + ]; + return rgb.map(x => this._padStart(x.toString(16), 2, '0')).join(''); + } + if (isFg ? cell.isFgPalette() : cell.isBgPalette()) { + return this._colors.ansi[color].css; + } + return undefined; + } + + private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined { + const content: string[] = []; + + const fgChanged = !equalFg(cell, oldCell); + const bgChanged = !equalBg(cell, oldCell); + const flagsChanged = !equalFlags(cell, oldCell); + + if (fgChanged || bgChanged || flagsChanged) { + const fgHexColor = this._getHexColor(cell, true); + if (fgHexColor) { + content.push('color: ' + fgHexColor + ';'); + } + + const bgHexColor = this._getHexColor(cell, false); + if (bgHexColor) { + content.push('background-color: ' + bgHexColor + ';'); + } + + if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); } + if (cell.isBold()) { content.push('font-weight: bold;'); } + if (cell.isUnderline()) { content.push('text-decoration: underline;'); } + if (cell.isBlink()) { content.push('text-decoration: blink;'); } + if (cell.isInvisible()) { content.push('visibility: hidden;'); } + if (cell.isItalic()) { content.push('font-style: italic;'); } + if (cell.isDim()) { content.push('opacity: 0.5;'); } + if (cell.isStrikethrough()) { content.push('text-decoration: line-through;'); } + + return content; + } + + return undefined; + } + + protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { + // a width 0 cell don't need to be count because it is just a placeholder after a CJK character; + const isPlaceHolderCell = cell.getWidth() === 0; + if (isPlaceHolderCell) { + return; + } + + // this cell don't have content + const isEmptyCell = cell.getChars() === ''; + + const styleDefinitions = this._diffStyle(cell, oldCell); + + // handles style change + if (styleDefinitions) { + this._currentRow += styleDefinitions.length === 0 ? + '
' : + ''; + } + + // handles actual content + if (isEmptyCell) { + this._currentRow += ' '; + } else { + this._currentRow += cell.getChars(); + } + } + + protected _serializeString(): string { + return this._htmlContent; + } +} diff --git a/addons/xterm-addon-serialize/src/tsconfig.json b/addons/xterm-addon-serialize/src/tsconfig.json index ef4a14ab..38ef6a7f 100644 --- a/addons/xterm-addon-serialize/src/tsconfig.json +++ b/addons/xterm-addon-serialize/src/tsconfig.json @@ -14,6 +14,9 @@ "paths": { "common/*": [ "../../../src/common/*" + ], + "browser/*": [ + "../../../src/browser/*" ] }, "strict": true, @@ -28,6 +31,9 @@ "references": [ { "path": "../../../src/common" + }, + { + "path": "../../../src/browser" } ] } diff --git a/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts b/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts index f50fc4de..4cb6283d 100644 --- a/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts +++ b/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts @@ -24,14 +24,23 @@ declare module 'xterm-addon-serialize' { * the state. The cursor will also be positioned to the correct cell. When restoring a terminal * it is best to do before `Terminal.open` is called to avoid wasting CPU cycles rendering * incomplete frames. - * + * * It's recommended that you write the serialized data into a terminal of the same size in which * it originated from and then resize it after if needed. - * + * * @param options Custom options to allow control over what gets serialized. */ public serialize(options?: ISerializeOptions): string; + /** + * Serializes terminal content as HTML, which can be written to the clipboard using the + * `text/html` mimetype. For applications that support it, the pasted text should then retain + * its colors/styles. + * + * @param options Custom options to allow control over what gets serialized. + */ + public serializeAsHTML(options?: Partial): string; + /** * Disposes the addon. */ @@ -56,4 +65,24 @@ declare module 'xterm-addon-serialize' { */ excludeAltBuffer?: boolean; } + + export interface IHTMLSerializeOptions { + /** + * The number of rows in the scrollback buffer to serialize, starting from the bottom of the + * scrollback buffer. When not specified, all available rows in the scrollback buffer will be + * serialized. This setting is ignored if {@link IHTMLSerializeOptions.onlySelection} is true. + */ + scrollback: number; + + /** + * Whether to only serialize the selection. If false, the whole active buffer is serialized in HTML. + * False by default. + */ + onlySelection: boolean; + + /** + * Whether to include the global background of the terminal. False by default. + */ + includeGlobalBackground: boolean; + } } diff --git a/demo/client.ts b/demo/client.ts index a8283461..c3602ea0 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -147,6 +147,7 @@ if (document.location.pathname === '/test') { createTerminal(); document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); document.getElementById('serialize').addEventListener('click', serializeButtonHandler); + document.getElementById('htmlserialize').addEventListener('click', htmlSerializeButtonHandler); document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); document.getElementById('add-decoration').addEventListener('click', addDecoration); @@ -448,6 +449,21 @@ function serializeButtonHandler(): void { } } +function htmlSerializeButtonHandler(): void { + const output = addons.serialize.instance.serializeAsHTML(); + document.getElementById('htmlserialize-output').innerText = output; + + // Deprecated, but the most supported for now. + function listener(e: any) { + e.clipboardData.setData("text/html", output); + e.preventDefault(); + } + document.addEventListener("copy", listener); + document.execCommand("copy"); + document.removeEventListener("copy", listener); + document.getElementById("htmlserialize-output-result").innerText = "Copied to clipboard"; +} + function writeCustomGlyphHandler() { term.write('\n\r'); diff --git a/demo/index.html b/demo/index.html index aa28000b..e024222c 100644 --- a/demo/index.html +++ b/demo/index.html @@ -49,6 +49,10 @@
+ + + +