├── fixture ├── example.png ├── palette.png ├── palette.blob ├── testimages │ ├── spinfox.png │ ├── w3c_home.gif │ ├── w3c_home.jpg │ ├── w3c_home.png │ ├── nikon-e950.jpg │ ├── w3c_home_2.gif │ ├── w3c_home_2.jpg │ ├── w3c_home_2.png │ ├── iphone_hdr_YES.jpg │ ├── w3c_home_256.gif │ ├── w3c_home_256.jpg │ ├── w3c_home_256.png │ ├── w3c_home_gray.gif │ ├── w3c_home_gray.jpg │ ├── w3c_home_gray.png │ ├── agfa-makernotes.jpg │ ├── sony-alpha-6000.jpg │ └── w3c_home_animation.gif ├── endless.sh ├── iip │ ├── palette.iip │ ├── w3c_png.iip │ ├── w3c_gif.iip │ └── w3c_jpg.iip ├── growing_rect.js ├── inspect_palette.sh ├── overdraw.sh ├── gcrglf.sh ├── textcursor.sh └── palette.sixel ├── tsconfig.json ├── .npmignore ├── .gitignore ├── bootstrap.sh ├── src ├── tsconfig.json ├── IIPMetrics.test.ts ├── IIPMetrics.ts ├── base64.benchmark.ts ├── Types.d.ts ├── IIPHeaderParser.test.ts ├── SixelHandler.ts ├── IIPHeaderParser.ts ├── base64.test.ts ├── IIPHandler.ts ├── ImageAddon.ts ├── base64.wasm.ts ├── ImageRenderer.ts └── ImageStorage.ts ├── package.json ├── overwrite ├── tsconfig.all.json ├── demo │ ├── tsconfig.json │ ├── start.js │ ├── server.js │ └── index.html ├── addons │ └── tsconfig.eslint.addons.json └── .eslintrc.json ├── test ├── tsconfig.json └── ImageAddon.api.ts ├── webpack.config.js ├── LICENSE ├── .github └── workflows │ └── node.js.yml ├── typings └── xterm-addon-image.d.ts └── README.md /fixture/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/example.png -------------------------------------------------------------------------------- /fixture/palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/palette.png -------------------------------------------------------------------------------- /fixture/palette.blob: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/palette.blob -------------------------------------------------------------------------------- /fixture/testimages/spinfox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/spinfox.png -------------------------------------------------------------------------------- /fixture/testimages/w3c_home.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home.gif -------------------------------------------------------------------------------- /fixture/testimages/w3c_home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home.png -------------------------------------------------------------------------------- /fixture/testimages/nikon-e950.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/nikon-e950.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_2.gif -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_2.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_2.png -------------------------------------------------------------------------------- /fixture/testimages/iphone_hdr_YES.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/iphone_hdr_YES.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_256.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_256.gif -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_256.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_256.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_256.png -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_gray.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_gray.gif -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_gray.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_gray.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_gray.png -------------------------------------------------------------------------------- /fixture/testimages/agfa-makernotes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/agfa-makernotes.jpg -------------------------------------------------------------------------------- /fixture/testimages/sony-alpha-6000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/sony-alpha-6000.jpg -------------------------------------------------------------------------------- /fixture/testimages/w3c_home_animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerch/xterm-addon-image/HEAD/fixture/testimages/w3c_home_animation.gif -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "include": [], 4 | "references": [ 5 | { "path": "./src" }, 6 | { "path": "./test" } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | fixture 3 | overwrite 4 | bootstrap.sh 5 | tsconfig.json 6 | webpack.config.js 7 | test 8 | out-test 9 | inwasm-sdks 10 | inwasm-builds 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.swp 3 | .lock-wscript 4 | lib/ 5 | out/ 6 | out-test/ 7 | out-worker/ 8 | .nyc_output/ 9 | Makefile.gyp 10 | *.Makefile 11 | *.target.gyp.mk 12 | *.node 13 | example/*.log 14 | docs/ 15 | npm-debug.log 16 | /.idea/ 17 | .env 18 | build/ 19 | .DS_Store 20 | package-lock.json 21 | yarn.lock 22 | 23 | # Keep bundled code out of Git 24 | dist/ 25 | demo/dist/ 26 | 27 | # dont commit benchmark folders 28 | .benchmark/ 29 | timeline/ 30 | inwasm-sdks/ 31 | inwasm-builds/ 32 | -------------------------------------------------------------------------------- /fixture/endless.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # sixel endless mode 4 | # Should print an endless sine curve, abort with Ctrl-C. 5 | 6 | period=200 7 | amplitude=50 8 | 9 | sixels=(@\$ A\$ C\$ G\$ O\$ _\$-) 10 | pi=$(echo "scale=10; 4*a(1)" | bc -l) 11 | run=true 12 | trap run=false INT 13 | 14 | echo -ne "\x1bP0;0;0q\"1;1#1;2;100;0;0#1" 15 | y=0 16 | while $run 17 | do 18 | x=$(echo "s(2*${pi}*${y}/${period})*${amplitude}+2*${amplitude}+0.5" | bc -l) 19 | echo -ne "!${x%%.*}?${sixels[$((y%6))]}" 20 | (( y++ )) 21 | done 22 | echo -e "\x1b\\" 23 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir xterm-addon-image 4 | cd xterm-addon-image 5 | 6 | # clone xterm.js base repo 7 | git clone --depth 1 --branch $XTERMJS https://github.com/xtermjs/xterm.js.git 8 | cd xterm.js 9 | rm -rf .git 10 | 11 | # clone addon 12 | cd addons 13 | git clone --branch $IMAGEADDON https://github.com/jerch/xterm-addon-image 14 | cd .. 15 | 16 | # overwrite files in base repo to have full test integration 17 | cp -avx addons/xterm-addon-image/overwrite/* . 18 | # client.ts is named ts_copy to avoid TS recognizing it, rename 19 | mv demo/client.ts_copy demo/client.ts 20 | 21 | # to fix eslint 22 | cp -avx addons/xterm-addon-image/overwrite/.eslintrc.json . 23 | 24 | # init all 25 | yarn 26 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "outDir": "../out", 7 | "rootDir": ".", 8 | "strict": true, 9 | "noUnusedLocals": true, 10 | "preserveWatchOutput": true, 11 | "types": [ 12 | "../../../node_modules/@types/mocha" 13 | ], 14 | "baseUrl": ".", 15 | "paths": { 16 | "browser/*": [ "../../../src/browser/*" ], 17 | "common/*": [ "../../../src/common/*" ] 18 | } 19 | }, 20 | "include": [ 21 | "./**/*", 22 | "../../../typings/xterm.d.ts" 23 | ], 24 | "references": [ 25 | { "path": "../../../src/browser" }, 26 | { "path": "../../../src/common" } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xterm-addon-image", 3 | "version": "0.4.3", 4 | "author": "Joerg Breitbart ", 5 | "main": "lib/xterm-addon-image.js", 6 | "types": "typings/xterm-addon-image.d.ts", 7 | "repository": "https://github.com/jerch/xterm-addon-image", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "../../node_modules/.bin/tsc -p src && node_modules/.bin/inwasm out/base64.wasm.js", 11 | "prepackage": "npm run build", 12 | "package": "../../node_modules/.bin/webpack", 13 | "prepublishOnly": "npm run package" 14 | }, 15 | "peerDependencies": { 16 | "xterm": "~5.2.0" 17 | }, 18 | "devDependencies": { 19 | "inwasm": "^0.0.13", 20 | "sixel": "^0.16.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fixture/iip/palette.iip: -------------------------------------------------------------------------------- 1 | ]1337;File=inline=1;size=525;name=Li4vcGFsZXR0ZS5wbmc=:iVBORw0KGgoAAAANSUhEUgAAAoAAAABQCAYAAACJbMQlAAAABmJLR0QA/wD/AP+gvaeTAAABwklEQVR4nO3dwQnEMAwAQedIIem/yVwLBj0C3pm3uAO/Fj2ia631rh3P1lRu7vnof0+Z836zOe83m/N+sznvN5vzfrM57zeb+23+HAAAhxCAAAAxAhAAIEYAAgDECEAAgBgBCAAQIwABAGIEIABAjAAEAIi59z8tDQDACWwAAQBiBCAAQIwABACIEYAAADECEAAgRgACAMQIQACAGAEIABAjAAEAYlwCAQCIsQEEAIgRgAAAMQIQACBGAAIAxAhAAIAYAQgAECMAAQBiBCAAQIwABACIcQkEACDGBhAAIEYAAgDECEAAgBgBCAAQIwABAGIEIABAjAAEAIgRgAAAMQIQACDGJRAAgBgbQACAGAEIABAjAAEAYgQgAECMAAQAiBGAAAAxAhAAIEYAAgDECEAAgBiXQAAAYmwAAQBiBCAAQIwABACIEYAAADECEAAgRgACAMQIQACAGAEIABAjAAEAYlwCAQCIsQEEAIgRgAAAMQIQACBGAAIAxAhAAIAYAQgAECMAAQBiBCAAQIwABACIcQkEACDGBhAAIEYAAgDECEAAgBgBCAAQIwABAGIEIABAjAAEAIgRgAAAMQIQACDmDz4lC02AaqMRAAAAAElFTkSuQmCC 2 | -------------------------------------------------------------------------------- /overwrite/tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "include": [], 4 | "references": [ 5 | { "path": "./src/browser" }, 6 | { "path": "./src/headless" }, 7 | { "path": "./test/api" }, 8 | { "path": "./test/benchmark" }, 9 | { "path": "./addons/xterm-addon-attach" }, 10 | { "path": "./addons/xterm-addon-canvas" }, 11 | { "path": "./addons/xterm-addon-fit" }, 12 | { "path": "./addons/xterm-addon-image" }, 13 | { "path": "./addons/xterm-addon-ligatures" }, 14 | { "path": "./addons/xterm-addon-search" }, 15 | { "path": "./addons/xterm-addon-serialize" }, 16 | { "path": "./addons/xterm-addon-unicode11" }, 17 | { "path": "./addons/xterm-addon-web-links" }, 18 | { "path": "./addons/xterm-addon-webgl" } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "rootDir": ".", 6 | "outDir": "../out-test", 7 | "sourceMap": true, 8 | "removeComments": true, 9 | "strict": true, 10 | "baseUrl": ".", 11 | "paths": { 12 | "browser/*": [ "../../../src/browser/*" ], 13 | "common/*": [ "../../../src/common/*" ] 14 | }, 15 | "types": [ 16 | "../../../node_modules/@types/mocha", 17 | "../../../node_modules/@types/node", 18 | "../../../out-test/api/TestUtils" 19 | ] 20 | }, 21 | "include": [ 22 | "./**/*", 23 | "../../../typings/xterm.d.ts" 24 | ], 25 | "references": [ 26 | { "path": "../../../src/browser" }, 27 | { "path": "../../../src/common" } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | const path = require('path'); 7 | 8 | const addonName = 'ImageAddon'; 9 | const mainFile = 'xterm-addon-image.js'; 10 | const mainFileWorker = 'xterm-addon-image-worker.js'; 11 | const workerName = 'main'; 12 | 13 | const addon = { 14 | entry: `./out/${addonName}.js`, 15 | devtool: 'source-map', 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | use: ["source-map-loader"], 21 | enforce: "pre", 22 | exclude: /node_modules/ 23 | } 24 | ] 25 | }, 26 | output: { 27 | filename: mainFile, 28 | path: path.resolve('./lib'), 29 | library: addonName, 30 | libraryTarget: 'umd' 31 | }, 32 | mode: 'production' 33 | }; 34 | 35 | module.exports = [addon]; 36 | -------------------------------------------------------------------------------- /overwrite/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2015", 5 | "rootDir": ".", 6 | "sourceMap": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "xterm-addon-attach": ["../addons/xterm-addon-attach"], 10 | "xterm-addon-canvas": ["../addons/xterm-addon-canvas"], 11 | "xterm-addon-fit": ["../addons/xterm-addon-fit"], 12 | "xterm-addon-image": ["../addons/xterm-addon-image"], 13 | "xterm-addon-search": ["../addons/xterm-addon-search"], 14 | "xterm-addon-serialize": ["../addons/xterm-addon-serialize"], 15 | "xterm-addon-web-links": ["../addons/xterm-addon-web-links"], 16 | "xterm-addon-webgl": ["../addons/xterm-addon-webgl"] 17 | } 18 | }, 19 | "include": [ 20 | "client.ts", 21 | "../typings/xterm.d.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /overwrite/addons/tsconfig.eslint.addons.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "rootDir": ".", 6 | "outDir": "../out-test", 7 | "sourceMap": true, 8 | "removeComments": true, 9 | "strict": true, 10 | "baseUrl": ".", 11 | "paths": { 12 | "browser/*": [ "../../../src/browser/*" ], 13 | "common/*": [ "../../../src/common/*" ] 14 | }, 15 | "types": [ 16 | "../node_modules/@types/mocha", 17 | "../node_modules/@types/node", 18 | "../out-test/api/TestUtils" 19 | ] 20 | }, 21 | "include": [ 22 | "../typings/xterm.d.ts", 23 | "./**/src/*", 24 | "./**/src/**/*", 25 | "./**/src-worker/*", 26 | "./**/test/*", 27 | "./**/benchmark/*" 28 | ], 29 | "references": [ 30 | { "path": "../src/browser" }, 31 | { "path": "../src/common" } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, 2020, 2021, 2022 Joerg Breitbart. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm install yarn 29 | - run: | 30 | XTERMJS=5.2.0 IMAGEADDON=${{ github.head_ref || github.ref_name }} ./bootstrap.sh 31 | - run: cd xterm-addon-image/xterm.js/addons/xterm-addon-image && node_modules/.bin/inwasm out/base64.wasm.js 32 | - run: cd xterm-addon-image/xterm.js && yarn test 33 | - run: cd xterm-addon-image/xterm.js && yarn test-api-chromium --headless 34 | -------------------------------------------------------------------------------- /fixture/growing_rect.js: -------------------------------------------------------------------------------- 1 | const sixelEncode = require('../node_modules/sixel/lib/SixelEncoder').image2sixel; 2 | const toRGBA8888 = require('../node_modules/sixel/lib/Colors').toRGBA8888; 3 | 4 | function createRect(size, color) { 5 | const pixels = new Uint32Array(size * size); 6 | pixels.fill(toRGBA8888(...color)); 7 | return sixelEncode(new Uint8ClampedArray(pixels.buffer), size, size); 8 | } 9 | 10 | function createRectMinusOne(size, color) { 11 | const pixels = new Uint32Array(size * size); 12 | if (size - 1) { 13 | const sub = new Uint32Array(size - 1); 14 | sub.fill(toRGBA8888(...color)); 15 | const last = size * (size - 1); 16 | for (let y = 0; y < last; y += size) { 17 | pixels.set(sub, y); 18 | } 19 | } 20 | return sixelEncode(new Uint8ClampedArray(pixels.buffer), size, size); 21 | } 22 | 23 | async function main() { 24 | // clear + cursor and sixelScrolling off 25 | process.stdout.write('\x1b[2J\x1b[?25;80h'); 26 | 27 | for (let i = 1; i < 300; ++i) { 28 | await new Promise(res => setTimeout(() => { 29 | process.stdout.write(createRect(i, [0, 255, 0])); 30 | res(); 31 | }, 5)); 32 | } 33 | for (let i = 299; i >= 1; --i) { 34 | await new Promise(res => setTimeout(() => { 35 | process.stdout.write(createRectMinusOne(i, [0, 255, 0])); 36 | res(); 37 | }, 5)); 38 | } 39 | 40 | // re-enable cursor and sixel scrolling 41 | process.stdout.write('\x1b[2J\x1b[?25;80l'); 42 | } 43 | 44 | main(); 45 | -------------------------------------------------------------------------------- /fixture/inspect_palette.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function print_palette() { 4 | L=$(( LOWER / 256)) 5 | U=$(( (UPPER-1) / 256)) 6 | for ((p = $L; p <= $U; p++)) 7 | do 8 | echo "slot $((p*256))..$((p*256+255)):" 9 | echo -ne "\x1bP;1q" 10 | for i in {0..15} 11 | do 12 | a=$((i * 16 + p * 256)) 13 | for j in {0..15} 14 | do 15 | echo -ne "#$((a+j))!6~" 16 | done 17 | echo -ne "\$-" 18 | done 19 | echo -e "\x1b\\" 20 | done 21 | } 22 | 23 | colors=undefined 24 | max_colors=undefined 25 | 26 | echo "Terminal Reports (XTSMGRAPHICS):" 27 | IFS=";" read -a REPLY -s -t 1 -d "S" -p $'\e[?1;1;0S' 28 | [[ ${REPLY[1]} == "0" ]] && colors=${REPLY[2]} 29 | echo "active colors: ${colors}" 30 | 31 | IFS=";" read -a REPLY -s -t 1 -d "S" -p $'\e[?1;4;0S' 32 | [[ ${REPLY[1]} == "0" ]] && max_colors=${REPLY[2]} 33 | echo "max colors : ${max_colors}" 34 | echo 35 | 36 | 37 | # query up to colors by default 38 | # if colors is undefined (no XTSMGRAPHICS), assume 256 39 | ARG1=${1:-${colors}} 40 | if [[ $colors == "undefined" ]] 41 | then 42 | ARG1=${1:-256} 43 | fi 44 | LOWER=0 45 | UPPER=$ARG1 46 | ARG2=${2:-undefined} 47 | if [[ $ARG2 != "undefined" ]] 48 | then 49 | LOWER=ARG1 50 | UPPER=ARG2 51 | fi 52 | 53 | if [[ $colors != "undefined" ]] 54 | then 55 | if [[ $colors -lt $UPPER ]] || [[ $colors -lt 256 ]] 56 | then 57 | echo -e "\x1b[33mNote: Active colors is smaller than test range." 58 | echo -e "A spec-conform terminal may repeat colors in 'slot mod ${colors}'.\x1b[m" 59 | echo 60 | fi 61 | else 62 | echo -e "\x1b[33mNote: Cannot query active colors." 63 | echo -e "The terminal may repeat colors beyond it max slot (e.g. slot mod 16).\x1b[m" 64 | echo 65 | fi 66 | 67 | print_palette 68 | -------------------------------------------------------------------------------- /src/IIPMetrics.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { imageType, IMetrics } from './IIPMetrics'; 3 | 4 | // fix missing nodejs decl 5 | declare const require: (s: string) => any; 6 | const fs = require('fs'); 7 | 8 | 9 | const TEST_IMAGES: [string, IMetrics][] = [ 10 | ['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }], 11 | ['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }], 12 | ['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }], 13 | ['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }], 14 | ['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }], 15 | ['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }], 16 | ['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }], 17 | ['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }], 18 | ['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }], 19 | ['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }], 20 | ['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }], 21 | ['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }], 22 | ['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }], 23 | ['spinfox.png', { mime: 'image/png', width: 148, height: 148 }], 24 | ['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }], 25 | ['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }], 26 | ['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }], 27 | ['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }] 28 | ]; 29 | 30 | 31 | describe('IIPMetrics', () => { 32 | it('bunch of testimages', () => { 33 | for (let i = 0; i < TEST_IMAGES.length; ++i) { 34 | const imageData = fs.readFileSync('./addons/xterm-addon-image/fixture/testimages/' + TEST_IMAGES[i][0]); 35 | assert.deepStrictEqual(imageType(imageData), TEST_IMAGES[i][1]); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /fixture/iip/w3c_png.iip: -------------------------------------------------------------------------------- 1 | ]1337;File=inline=1;size=1658;name=Li4vdGVzdGltYWdlcy93M2NfaG9tZV8yNTYucG5n:iVBORw0KGgoAAAANSUhEUgAAAEgAAAAwCAMAAACFQszZAAADAFBMVEUEAgSMiozMzsxERkR0jrwMQpy8urzc5vQ0ZqwkIiRsbmzc3tx0lsSUqtScnpwkWqz8/vxUfrwcHhy8xuRcXlxkisQUTqTs7uxEcrQ8Pjx8fny0wuQMDgyMjoxMTkwMRpy8vrw8arQsLix0dnTU2uyEnsykttysrqzEzuRsisTs8vwAgl8AADewABfiAACBAAlwzwPVbADkrqSB1thwbwLFAQByAG8AAAEcADBHYAf3GgK/ggIA/ACwaADjHByBggAAfA4AxgAAchQAACB0HEbVRwTk98WBvwGkAO7FYBNyG/cAgr/PAGdsAAGiAADVAABvAOMBaaIAHPcAgr90sBXVxgXkcgCBAABfz6wWbMcArnIA1gCEb1TFARNyAAAAAABQANRCaU35HFG/ggAAuLCwGSji9wCBvwD4kKAIxqMAcvcAAL8AUAAAQgAA+VAAvwC4AIQZYHb3GlG/ggDElFTVDxMAAAAAAADgAAD4AAByAAAAAABtuAwYGQD391C/vwDP0ABs1gCiAFDVAAAA4NQA+E0AclEAAACibUDVGAB/9wBXvwBkzwATbAAArgAA1gAAABgAAAVGAAACAAAArhUA1gUIfwAAVwADR9QAAcd0AHIlAAADAFAAAKU3RvcGAr96AAAzAAClAVCwAABHA2cBAKUAAPcACb/4AwAIAAAAX1AAFgClAEOwAABHpQABsAD4R0PFAQByAAAAAAAYlByJD8V/AFCBAAAQpQLSsAA8RwCCAQAQFKKT1yimZvfSKL/4H/jFF8dyAHIAAAAMlGbGD7RyAPcAAL8YAACJYAB/GlCBggB4ANUKCbQ9A/eCAL8QAACTAEqmA4XSAIEMlO/GDxlyAPgAAL8OADOhABr3APi/AL8mAACzAEr3GoW/IIEARoRgBHY8xVGCAQAAz5sAAW4AAPcAAL/PpB5s2MVmAlDWAABvpOMB2CgAAkIAAAAAABZgAMU7KFCCAAA8HL3Gem5yAvcAAL8YAJuJAG5/APeBAL9EAAQKAAA9DACCFwCk+0jYAAAACXBIWXMAAAAAAAAAAACdYiYyAAADIElEQVR4nO2WW3OqQAyAAUehArJWXcDSUiwg3f//A8/msheU0/bBhzNnutMOEZKPJJtkCYJHLfGg9V+CwjD82DwAFEah/oseABKrciXCR4CeVhsEqUkvRXdjkD2tQ57n4+SWuqFEQnyIVbRKARS3eo30oAfZI8m2lXBL8j89it6C5phqobwIsQn19aOE0AqtcfIsM8uZ1q3B0ELQjkp5J8QZzHWywxJ3LQNrNj1p7cKCsrVFAE1W6I9pik3ZBO9GFUATqPX0ewSb2DxN1hSb9B16NqDnLbrlQBBPy/Eo0M/5IWbvE8WX1sSPgV3O2ye4NuUMtNdKCd+o9Ov3LOdgTRsFeZQo1pQeUR61cBYz0AjOxy5JbKxfsG47lAaIbgCpDGw8R88hAimvADAdB5KBj2EqaffgGgRHY3xxDnGvdVqP4umlTYY4gNhbN4l+DoLUGEflLQiTwTFAEIWNEqUJblZk8Ba8iaVFoB40X0BKaJ/Rj8JsZuXVRO12fAFkbWLuiEFwuwB9dNECqPwKZKIYuSESildCwasCb3VUsvXzIseADmDfY0XJgQvA7EBuOgT3ta6/BAkuaHh1vNdWo6uJwfYs/Dp/A4LirrAREnRh77puGoYhwYaD0t59AxrBd5XhEIEWkzptkssa1oRu6T1IvwHRdCto5+GCloNTxD2E3J+XMN5xBAXUsS4MqM6WEy/wEG6830NmoIxTCvUycXILXxPyJ/M7wB2o5wmGHcUj+uRr9u081r+BBHukKIzWGwKewk9AZEwzYKROmR0+MTj5eZOhm3mEC4cGT1mFoe1nNpi44ab1aztSHEhhWrjHKwM97M0MzuDx9Drb/TC43oPQ2FRgzmWd2EypAoNN/SkSNW5oe6DcnSVYnwCFxNFJlXEGt46kOTfHkTWW9rQuJM0nLIRh6rEc4bE+FEOOS3MuYgGkO0NaOePTcPAPbMz+FU7G63UL52STLoIyrwInA63MNNJzgcphZz9km0gsgiZzJMGSDFWfxp/OlNWu4SPb48w//aRXgScLnWDQtZ33knR7CYL6dTa8/6Wv2l/Qj0GPWn8AP2jx/kucqREAAAAASUVORK5CYII= 2 | -------------------------------------------------------------------------------- /fixture/iip/w3c_gif.iip: -------------------------------------------------------------------------------- 1 | ]1337;File=inline=1;size=1667;name=Li4vdGVzdGltYWdlcy93M2NfaG9tZV8yNTYuZ2lm:R0lGODlhSAAwAPcAAAQCBIyKjMzOzERGRHSOvAxCnLy6vNzm9DRmrCQiJGxubNze3HSWxJSq1JyenCRarPz+/FR+vBweHLzG5FxeXGSKxBROpOzu7ERytDw+PHx+fLTC5AwODIyOjExOTAxGnLy+vDxqtCwuLHR2dNTa7ISezKS23KyurMTO5GyKxOzy/ACCXwAAN7AAF+IAjIEAw8DPArtsAOSupIHW2HBvAsUBAHIAbwAAARwAiEdgA/caAr+CAgCIALAjAOMcJIGCAAB8EADGAAByiAAArcQcSrtHBOT3zYG/AaQA7sVgE3Ib9wCCv88AZ2wAAaIAANUAAG+M4wEjogAc9wCCv8SwGbvGBeRyAIEAAF/PrBZsxwCucgDWAIRvpMUBaHIAAQAAAFCMfEIjj/kcUb+CAAC4sLAZKOL3AIG/AECQoAfGowBy9wAAvwBQAABCAAD5UAC/ALgALBlguPcaUb+CAMRwpNUFaAAAAQAAAOAAAPgAAHIAAAAAAG24DBgZAPf3UL+/AM/QAGzWAKIAUNUAAADgfAD4jwByUQAAAKJtQNUYAH/3AFe/AGTPABNsAACuAADWAAAAMgAABUYAAAIAAACuGQDWBQh/AABXAAJH1AABx8QAcgsAAAMAUAAApTdG9wYCv3oAADMAAKUBULAAAEcCZwEApQCM9wDDv0ACAAcAAABfUAAWAKUAQ7AAAEelAAGwAPhHQ8UBAHIAAAAAABhwFIkFxX8AUIEAABClAtKwADxHAIIBABAUopPXKKZm99Iov/gf+MUXx3IAcgAAAAxwZsYFtHIA9wAAvxgAAIlgAH8aUIGCAHiM1QrDtD0C94IAvxAAAJMASqYChdIAgQxw78YFGXIA+AAAvw4AM6EAGvcA+L8AvyYAALMASveNhb+tgQBKLGAEuDzNUYIBAADRmwABbgAA9wAAv8+kFmzYxWYCUNYAAG+k4wHYKAACQgAAAAAAHmAAxTsoUIIAADwcvcZ6bnIC9wAAvxgAm4kAbn8A94EAv0QABAoAAD0MAIIXACH5BAAAAAAALAAAAABIADAABwj/AAEIHEiwoMGDCBMqXGgQgsOHECNKnEixosWLGDNq3Mixo8ePIEOKHCkxgMkRBkiq7BhAgEmXAlbKxBgAwoALAyDUnMlzYs0EA1LuVIGiKAoVEg8YRXFxQoOnJpZKLYpUY0wII2wKGLBg54EPYD+YkEgi7AemFS2AVRuW7Qe3aB8K8ACAA4UFDy+IcGhg54URFx4+CFthIlsLDCqiKGD2bWOzcSE4MOjAoQYAck0GCPyQweGJFcI+qMiA8eO2azFAFIDQwAUOAEBcRNGWhEQTZg9QRFDg8NrTkSUglKBAYOWLbhNHVGG2wcSvYVNE3GC28MPJA0VoUJCAIAfOFiOE/0UwEYPjCBMbmK0quC17CBkGHodwgcJADRlxO9YdMXTY9w6Jx1gIEZXg2AclQHSBfBLZ911GzIU1VkRufTCBRL8595AKbo0G0QkCUUCRdhuFEBZ6EJFQoXUPTWCWbQ/5B9aFEF0GAF4TCQAeRuqFVeBvH3gYo2gQ0eaYahHR5cFKZTm2AUQIPAajQ4OBpZxD5oXFH0TxzUdSlR9c+VWFFiToUJNgPemQfmCxyCUAO44ko5BsukWeQz1aYMGGg7kVwpQOZSDBTC46BqN4bxn4n0MmgoUiBD2iJhaXGfDUnENuHSCeWmNFCNaEEJRQoVmgalDpTIh+oBp1YJEXKXpsfv+wJQol1IqAbxZU5cCpMuHGlgoMrJUYdG9BEJpaBFKEQoVmLsDrSsSKBSaMYC4blpkUoVmsZT1FCVaj20Lg2bcvXnQsWDDKxtO4jbFoZGNCVsTqWxr2BEGTvtGIqaRuZmsWtvaO+sF7Moalr0VuAdxTwR88uuaBYAH43G8pqItRnBkVGla9DnmqlsOK/btkRhnguFGEbG35UJZg1TtBBHdGFCxbKHSA30UBAHCCRywnG1GksjrkrYXLgTnwAgB4KZEAsN3MUaRXQkSszzJaAGiwJzpUnNJywZa0R9BZEBlED6glpltlokDCuW+h9RoAO0EUgNd7fdQnaWBFpqjAWT9gBCIAxJ1wggLCCcSByR0F22+R4a7cWIUIAIjdQRxc9RFtoBq2uAopnPZBCBJL5jVBElgOUq4WVZB5kZt+u3pECygggkAZdICxvbjnrvvuvPfu++/ABy88Q8QXb/zxAgUEADs= 2 | -------------------------------------------------------------------------------- /src/IIPMetrics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | 7 | export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | ''; 8 | 9 | export interface IMetrics { 10 | mime: ImageType; 11 | width: number; 12 | height: number; 13 | } 14 | 15 | export const UNSUPPORTED_TYPE: IMetrics = { 16 | mime: 'unsupported', 17 | width: 0, 18 | height: 0 19 | }; 20 | 21 | export function imageType(d: Uint8Array): IMetrics { 22 | if (d.length < 24) { 23 | return UNSUPPORTED_TYPE; 24 | } 25 | const d32 = new Uint32Array(d.buffer, d.byteOffset, 6); 26 | // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG) 27 | // + first chunk must be IHDR 28 | if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) { 29 | return { 30 | mime: 'image/png', 31 | width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19], 32 | height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23] 33 | }; 34 | } 35 | // JPEG: FF D8 FF E0 xx xx JFIF or FF D8 FF E1 xx xx Exif 00 00 36 | if ((d32[0] === 0xE0FFD8FF || d32[0] === 0xE1FFD8FF) 37 | && ( 38 | (d[6] === 0x4a && d[7] === 0x46 && d[8] === 0x49 && d[9] === 0x46) 39 | || (d[6] === 0x45 && d[7] === 0x78 && d[8] === 0x69 && d[9] === 0x66) 40 | ) 41 | ) { 42 | const [width, height] = jpgSize(d); 43 | return { mime: 'image/jpeg', width, height }; 44 | } 45 | // GIF: GIF87a or GIF89a 46 | if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) { 47 | return { 48 | mime: 'image/gif', 49 | width: d[7] << 8 | d[6], 50 | height: d[9] << 8 | d[8] 51 | }; 52 | } 53 | return UNSUPPORTED_TYPE; 54 | } 55 | 56 | function jpgSize(d: Uint8Array): [number, number] { 57 | const len = d.length; 58 | let i = 4; 59 | let blockLength = d[i] << 8 | d[i + 1]; 60 | while (true) { 61 | i += blockLength; 62 | if (i >= len) { 63 | // exhausted without size info 64 | return [0, 0]; 65 | } 66 | if (d[i] !== 0xFF) { 67 | return [0, 0]; 68 | } 69 | if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) { 70 | if (i + 8 < len) { 71 | return [ 72 | d[i + 7] << 8 | d[i + 8], 73 | d[i + 5] << 8 | d[i + 6] 74 | ]; 75 | } 76 | return [0, 0]; 77 | } 78 | i += 2; 79 | blockLength = d[i] << 8 | d[i + 1]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /fixture/overdraw.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function smiling_smiley() { 4 | echo -ne '\x1bP;2q"1;1;60;60 5 | #6!60~$- 6 | !60~$- 7 | !60~$ 8 | !15?#1!4]!22?!4]$- 9 | #6!60~$- 10 | !60~$- 11 | !60~$- 12 | !60~$ 13 | !15?#1!4~!22?!4~$- 14 | #6!60~$ 15 | !15?#1!30N$- 16 | #6!60~$- 17 | !60~$- 18 | ' 19 | echo -ne '\x1b\\' 20 | } 21 | 22 | function indifferent_smiley() { 23 | echo -ne '\x1bP;2q"1;1;60;60 24 | #6!60~$- 25 | !60~$- 26 | !60~$ 27 | !15?#1!4]!22?!4]$- 28 | #6!60~$- 29 | !60~$- 30 | !60~$- 31 | !60~$- 32 | !60~$ 33 | !15?#1!30N$- 34 | #6!60~$- 35 | !60~$- 36 | ' 37 | echo -ne '\x1b\\' 38 | } 39 | 40 | function sad_smiley() { 41 | echo -ne '\x1bP;2q"1;1;60;60 42 | #6!60~$- 43 | !60~$- 44 | !60~$ 45 | !15?#1!4]!22?!4]$- 46 | #6!60~$- 47 | !60~$- 48 | !60~$- 49 | !60~$- 50 | !60~$ 51 | !15?#1!30N$ 52 | !15?#1!4o!22?!4o$- 53 | #6!60~$ 54 | !15?#1!4B!22?!4B$- 55 | #6!60~$- 56 | ' 57 | echo -ne '\x1b\\' 58 | } 59 | 60 | function smiling_smiley_slim() { 61 | echo -ne '\x1bP;1q"1;1;60;60 62 | $- 63 | $- 64 | $- 65 | $- 66 | $- 67 | $- 68 | !15?#1!4~!22?!4~$- 69 | !15?#1!30N 70 | ' 71 | echo -ne '\x1b\\' 72 | } 73 | 74 | function indifferent_smiley_slim() { 75 | echo -ne '\x1bP;1q"1;1;60;60 76 | $- 77 | $- 78 | $- 79 | $- 80 | $- 81 | $- 82 | !15?#6!4~!22?!4~$- 83 | !15?!4o!22?!4o$ !15?#1!30N$- 84 | !15?#6!4B!22?!4B 85 | ' 86 | echo -ne '\x1b\\' 87 | } 88 | 89 | function sad_smiley_slim() { 90 | echo -ne '\x1bP;1q"1;1;60;60 91 | $- 92 | $- 93 | $- 94 | $- 95 | $- 96 | $- 97 | $- 98 | !15?#1!30N$ 99 | !15?#1!4o!22?!4o$- 100 | !15?#1!4B!22?!4B$- 101 | ' 102 | echo -ne '\x1b\\' 103 | } 104 | 105 | function full() { 106 | smiling_smiley 107 | sleep .5 108 | indifferent_smiley 109 | sleep .5 110 | sad_smiley 111 | sleep .5 112 | indifferent_smiley 113 | sleep .5 114 | smiling_smiley 115 | } 116 | 117 | function slim() { 118 | smiling_smiley 119 | sleep .5 120 | indifferent_smiley_slim 121 | sleep .5 122 | sad_smiley_slim 123 | sleep .5 124 | indifferent_smiley_slim 125 | sleep .5 126 | smiling_smiley_slim 127 | } 128 | 129 | # clear screen and place cursor to 1;10 130 | echo -ne '\x1b[2J\x1b[10;1H' 131 | 132 | # switch sixel scrolling off 133 | echo -ne '\x1b[?80h' 134 | 135 | case "$1" in 136 | full ) full ;; 137 | slim ) slim ;; 138 | esac 139 | 140 | # re-enable sixel scrolling 141 | echo -ne '\x1b[?80l' 142 | -------------------------------------------------------------------------------- /src/base64.benchmark.ts: -------------------------------------------------------------------------------- 1 | import { ThroughputRuntimeCase, perfContext } from 'xterm-benchmark'; 2 | import { Base64Decoder } from './base64.wasm'; 3 | 4 | // eslint-disable-next-line 5 | declare const Buffer: any; 6 | 7 | function toBytes(s: string): Uint8Array { 8 | const bytes = new Uint8Array(s.length); 9 | for (let i = 0; i < s.length; ++i) { 10 | bytes[i] = s.charCodeAt(i) & 0xFF; 11 | } 12 | return bytes; 13 | } 14 | 15 | const d256 = 'ABCD'.repeat(64); 16 | const d4096 = 'ABCD'.repeat(64 * 16); 17 | const d65536 = 'ABCD'.repeat(64 * 16 * 16); 18 | const d1M = 'ABCD'.repeat(64 * 16 * 16 * 16); 19 | const b256 = toBytes(d256); 20 | const b4096 = toBytes(d4096); 21 | const b65536 = toBytes(d65536); 22 | const b1M = toBytes(d1M); 23 | const dec = new Base64Decoder(4000000); 24 | 25 | 26 | const RUNS = 100; 27 | 28 | perfContext('Base64', () => { 29 | perfContext('Node - Buffer', () => { 30 | new ThroughputRuntimeCase('decode - 256', () => { 31 | Buffer.from(d256, 'base64'); 32 | return { payloadSize: d256.length }; 33 | }, { repeat: RUNS }).showAverageThroughput(); 34 | 35 | new ThroughputRuntimeCase('decode - 4096', () => { 36 | Buffer.from(d4096, 'base64'); 37 | return { payloadSize: d4096.length }; 38 | }, { repeat: RUNS }).showAverageThroughput(); 39 | 40 | new ThroughputRuntimeCase('decode - 65536', () => { 41 | Buffer.from(d65536, 'base64'); 42 | return { payloadSize: d65536.length }; 43 | }, { repeat: RUNS }).showAverageThroughput(); 44 | 45 | new ThroughputRuntimeCase('decode - 1048576', () => { 46 | Buffer.from(d1M, 'base64'); 47 | return { payloadSize: d1M.length }; 48 | }, { repeat: RUNS }).showAverageThroughput(); 49 | }); 50 | 51 | perfContext('Base64Decoder', () => { 52 | new ThroughputRuntimeCase('decode - 256', () => { 53 | dec.init(192); 54 | dec.put(b256, 0, b256.length); 55 | dec.end(); 56 | return { payloadSize: b256.length }; 57 | }, { repeat: RUNS }).showAverageThroughput(); 58 | 59 | new ThroughputRuntimeCase('decode - 4096', () => { 60 | dec.init(3072); 61 | dec.put(b4096, 0, b4096.length); 62 | dec.end(); 63 | return { payloadSize: b4096.length }; 64 | }, { repeat: RUNS }).showAverageThroughput(); 65 | 66 | new ThroughputRuntimeCase('decode - 65536', () => { 67 | dec.init(49152); 68 | dec.put(b65536, 0, b65536.length); 69 | dec.end(); 70 | return { payloadSize: b65536.length }; 71 | }, { repeat: RUNS }).showAverageThroughput(); 72 | 73 | new ThroughputRuntimeCase('decode - 1048576', () => { 74 | dec.init(786432); 75 | dec.put(b1M, 0, b1M.length); 76 | dec.end(); 77 | return { payloadSize: b1M.length }; 78 | }, { repeat: RUNS }).showAverageThroughput(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /fixture/iip/w3c_jpg.iip: -------------------------------------------------------------------------------- 1 | ]1337;File=inline=1;size=2159;name=Li4vdGVzdGltYWdlcy93M2NfaG9tZV8yNTYuanBn:/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAwAEgDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD33/hXPgn/AKE7w3/4K4P/AImj/hXPgn/oTvDf/grg/wDia8y/aLuNX0PWtB1nS7+8ghcGJ4o5mWPejbhlQcHIYj6LXr07J4l8IO1jM8K6lZboZUbayb0yrAjoRkH8K6J4flpwqX0l+BhGrzSlC2qM7/hXPgn/AKE7w3/4K4P/AImj/hXPgn/oTvDf/grg/wDia81/Zt8S313da3ousXk9xcR7biLz5S7DB2OMknvsrov2ifEEujeB0trSd4bq/uFiDRsVYIvzMQR9FH/AquWDlHELD3+ZMcQnS9qdR/wrnwT/ANCd4b/8FcH/AMTR/wAK58E/9Cd4b/8ABXB/8TXhFr4F8dL4SXX5fFCWOntbfays1/OrqhGRkKpGSMcZzyB1q7+z9D4g8QeKzqF3q2pSaZpwzIsty7LJIwIVME4Pcn0wPUVvPL4xhKaqJqJnHFNyUXFq57V/wrnwT/0J3hv/AMFcH/xNH/CufBP/AEJ3hv8A8FcH/wATXgnxP8YePPiB8Sr/AMI/DKe8tbLSG8u4ntZ/ILSKcOzy5BVQ2VCg87ScHoF0/wAcfE3T9Z8O/D7xJZSNr0mq20635YYuLJG3upZeGHycsOdoYEZ6+Ydh6Pd2fgW38Qvpn/CA+G2EaNKzfYoPMEStIrPs8voDDJ37D+JlUld9ceFNNnvzcyISGfzGjKoQW+Yn5iu8Al3yoYA73yPmbJQBzvx30X+2fhtqRRN01jtvE9tmdx/74L1ynw18Z/Y/gVqN20n+laMktuhJ5y3MX4ZdV/CvZ7qCO6tpredQ8UqGN1PdSMEfrXxPqb6hoD674WDExveKkygHLmJnC4Hvuz74Fexl8FiaToy6NP5dTz8VJ0Zqouqa/wAjb+Ed/P4c8f8Ah++uEeO0v2MIc8K6Oxjzn0DgH/gNdt8bpH8V/FnQ/DFu5KQeXC+P4GlIZz+CbD+FT/Grwf8A2L8NvCk1uu2fSQtvMyccuNzN/wB/F/8AH6p/AeO48V/E/VfE2ohWkgRpiQOFkk+VQPYLvH4V3SqQmnjV0TXzvoc6jKP+zPq0/wDM0P2k/FRt47Twnp+Y4QizXOOAQPuR/QY3H/gNdx8BtQ8PS+CoLDQJibm3G+9jlULIZW6uR3BxgEE8ADqK7nUdG0zUlcajp1pdb12t50KuSPqRXzl4bgTwP+0C2m2EhSwMxgKs/HlSR7wpP+ySp5/u1wUnDE4Z0Y6OKv6nTNSpVlUeqenoej/s26IuneAZdSmX/iY6xf3N3cuw+YkSsgB+gXOOxY16bNY2s17b3k1vE91bh1hlZQWjDY3BT2zgZ+lQ6JpcGj2H2O0GIfOllA9PMkaQgewLEVeJx1ryDvFooooAK8K8X+C/tf7QWjTCPNneKt9KSPl3QjDD8dsf/fdel/8ACxvBP/Q4+G//AAaQf/FVG3j/AMCNcJO3i3wwZ0VkSQ6lBuVWILAHdkA7VyO+B6V0YevKg249U0ZVaSqJJ9Hcu/EDRf8AhIfBesaYF3yT27eUP+mi/Mn/AI8BXm3wa8Panb/CDUbjRZ/sms6mzy28xUEgJ8qDDDAztbntuzXoH/CxvBP/AEOPhv8A8GkH/wAVUdv4/wDAltCkNv4t8MRRIMKialAqj6ANTp4mUKTpLun939IUqKlPn8rHkXg340XnhuwuNJ8Y2N9eX9s77ZWbEuSSdkm70PQjtgY4ql8KdCu/iD4+1LxNrVsTppMpkyPkd3QoI1Pfarfhgetet6n4l+F+qzCbVNa8F3soG0PcXVrIwHpliavW3j7wHawJBa+LPC8MKDCRx6jAqqPQANgV1SxtNRl7KFpS3/4BjHDTuueV0tjg0+LX/CvL5/DfxPivRNCT9i1iKHzI7+AfddgORJjAYAEZ54yM8xd/ETV/jN4v0zw/4Btrux8PWV3DealqU67WZY3DKMAkAZXhc5YgdADXrereMPhxrNr9m1fxF4Qv7bOfKur22lTPrhmIpdK8ZfDnSLRbXSfEfhGxtV5ENtfW0SD/AICrAV5h2HbUVyv/AAsbwT/0OPhv/wAGkH/xVFAH/9k= 2 | -------------------------------------------------------------------------------- /overwrite/demo/start.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 The xterm.js authors. All rights reserved. 3 | * @license MIT 4 | * 5 | * This file is the entry point for browserify. 6 | */ 7 | 8 | const path = require('path'); 9 | const webpack = require('webpack'); 10 | const startServer = require('./server.js'); 11 | 12 | startServer(); 13 | 14 | /** 15 | * This webpack config builds and watches the demo project. It works by taking the output from tsc 16 | * (via `yarn watch`) which is put into `out/` and then webpacks it into `demo/dist/`. The aliases 17 | * are used fix up the absolute paths output by tsc (because of `baseUrl` and `paths` in 18 | * `tsconfig.json`. 19 | * 20 | * For production builds see `webpack.config.js` in the root directory. If that is built the demo 21 | * can use that by switching out which `Terminal` is imported in `client.ts`, this is useful for 22 | * validating that the packaged version works correctly. 23 | */ 24 | const clientConfig = { 25 | entry: path.resolve(__dirname, 'client.ts'), 26 | devtool: 'inline-source-map', 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | use: 'ts-loader', 32 | exclude: /node_modules/ 33 | }, 34 | { 35 | test: /\.js$/, 36 | use: ["source-map-loader"], 37 | enforce: "pre", 38 | exclude: /node_modules/ 39 | } 40 | ] 41 | }, 42 | resolve: { 43 | modules: [ 44 | 'node_modules', 45 | path.resolve(__dirname, '..'), 46 | path.resolve(__dirname, '../addons') 47 | ], 48 | extensions: [ '.tsx', '.ts', '.js' ], 49 | alias: { 50 | common: path.resolve('./out/common'), 51 | browser: path.resolve('./out/browser') 52 | }, 53 | fallback: { 54 | // The ligature modules contains fallbacks for node environments, we never want to browserify them 55 | stream: false, 56 | util: false, 57 | os: false, 58 | path: false, 59 | fs: false 60 | } 61 | }, 62 | output: { 63 | filename: 'client-bundle.js', 64 | path: path.resolve(__dirname, 'dist') 65 | }, 66 | mode: 'development', 67 | watch: true 68 | }; 69 | 70 | /** 71 | * Blueprint to bundle addon workers. 72 | * Expects entry under `./addons/xterm-addon-${addonName}/src-worker/main.ts`. 73 | */ 74 | function generateAddonWorker(addonName) { 75 | return { 76 | entry: `./addons/xterm-addon-${addonName}/src-worker/main.ts`, 77 | devtool: 'inline-source-map', 78 | module: { 79 | rules: [ 80 | { 81 | test: /\.tsx?$/, 82 | use: 'ts-loader', 83 | exclude: /node_modules/ 84 | }, 85 | { 86 | test: /\.js$/, 87 | use: ["source-map-loader"], 88 | enforce: "pre", 89 | exclude: /node_modules/ 90 | } 91 | ] 92 | }, 93 | resolve: { 94 | modules: [ 95 | 'node_modules', 96 | path.resolve(__dirname, '..'), 97 | path.resolve(__dirname, '../addons') 98 | ], 99 | extensions: [ '.tsx', '.ts', '.js' ], 100 | alias: { 101 | common: path.resolve('./out/common'), 102 | browser: path.resolve('./out/browser') 103 | } 104 | }, 105 | output: { 106 | filename: `xterm-addon-${addonName}-worker.js`, 107 | path: path.resolve(__dirname, 'workers') 108 | }, 109 | mode: 'development', 110 | watch: true 111 | } 112 | } 113 | 114 | const compiler = webpack([ 115 | clientConfig, 116 | // generateAddonWorker('image') 117 | ]); 118 | 119 | compiler.watch({ 120 | // Example watchOptions 121 | aggregateTimeout: 300, 122 | poll: undefined 123 | }, (err, stats) => { 124 | // Print watch/build result here... 125 | console.log(stats.toString({ 126 | colors: true 127 | })); 128 | }); 129 | -------------------------------------------------------------------------------- /src/Types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { IDisposable, IMarker, Terminal } from 'xterm'; 7 | 8 | // private imports from base repo we build against 9 | import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants'; 10 | import type { AttributeData } from 'common/buffer/AttributeData'; 11 | import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types'; 12 | import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types'; 13 | import type { ITerminal, ReadonlyColorSet } from 'browser/Types'; 14 | import type { IRenderDimensions } from 'browser/renderer/shared/Types'; 15 | import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; 16 | 17 | export const enum Cell { 18 | CONTENT = 0, // codepoint and wcwidth information (enum Content) 19 | FG = 1, // foreground color in lower 3 bytes (rgb), attrs in 4th byte (enum FgFlags) 20 | BG = 2, // background color in lower 3 bytes (rgb), attrs in 4th byte (enum BgFlags) 21 | SIZE = 3 // size of single cell on buffer array 22 | } 23 | 24 | // export some privates for local usage 25 | export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet }; 26 | 27 | /** 28 | * Plugin ctor options. 29 | */ 30 | export interface IImageAddonOptions { 31 | enableSizeReports: boolean; 32 | pixelLimit: number; 33 | storageLimit: number; 34 | showPlaceholder: boolean; 35 | sixelSupport: boolean; 36 | sixelScrolling: boolean; 37 | sixelPaletteLimit: number; 38 | sixelSizeLimit: number; 39 | iipSupport: boolean; 40 | iipSizeLimit: number; 41 | } 42 | 43 | export interface IResetHandler { 44 | // attached to RIS and DECSTR 45 | reset(): void; 46 | } 47 | 48 | /** 49 | * Stub into private interfaces. 50 | * This should be kept in line with common libs. 51 | * Any change made here should be replayed in the accessors test case to 52 | * have a somewhat reliable testing against code changes in the core repo. 53 | */ 54 | 55 | // overloaded IExtendedAttrs to hold image refs 56 | export interface IExtendedAttrsImage extends IExtendedAttrs { 57 | imageId: number; 58 | tileId: number; 59 | clone(): IExtendedAttrsImage; 60 | } 61 | 62 | /* eslint-disable */ 63 | export interface IBufferLineExt extends IBufferLine { 64 | _extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined}; 65 | _data: Uint32Array; 66 | } 67 | 68 | interface IInputHandlerExt extends IInputHandler { 69 | _parser: IEscapeSequenceParser; 70 | _curAttrData: AttributeData; 71 | _dirtyRowTracker: { 72 | markRangeDirty(y1: number, y2: number): void; 73 | markAllDirty(): void; 74 | markDirty(y: number): void; 75 | }; 76 | onRequestReset(handler: () => void): IDisposable; 77 | } 78 | 79 | export interface ICoreTerminalExt extends ITerminal { 80 | _themeService: IThemeService | undefined; 81 | _inputHandler: IInputHandlerExt; 82 | _renderService: IRenderService; 83 | _coreBrowserService: ICoreBrowserService | undefined; 84 | } 85 | 86 | export interface ITerminalExt extends Terminal { 87 | _core: ICoreTerminalExt; 88 | } 89 | /* eslint-enable */ 90 | 91 | 92 | /** 93 | * Some storage definitions. 94 | */ 95 | export interface ICellSize { 96 | width: number; 97 | height: number; 98 | } 99 | 100 | export interface IImageSpec { 101 | orig: HTMLCanvasElement | ImageBitmap | undefined; 102 | origCellSize: ICellSize; 103 | actual: HTMLCanvasElement | ImageBitmap | undefined; 104 | actualCellSize: ICellSize; 105 | marker: IMarker | undefined; 106 | tileCount: number; 107 | bufferType: 'alternate' | 'normal'; 108 | } 109 | -------------------------------------------------------------------------------- /typings/xterm-addon-image.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2022 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { Terminal, ITerminalAddon } from 'xterm'; 7 | 8 | declare module 'xterm-addon-image' { 9 | export interface IImageAddonOptions { 10 | /** 11 | * Enable size reports in windowOptions: 12 | * - getWinSizePixels (CSI 14 t) 13 | * - getCellSizePixels (CSI 16 t) 14 | * - getWinSizeChars (CSI 18 t) 15 | * 16 | * If `true` (default), the reports will be activated during addon loading. 17 | * If `false`, no settings will be touched. Use `false`, if you have high 18 | * security constraints and/or deal with windowOptions by other means. 19 | * On addon disposal, the settings will not change. 20 | */ 21 | enableSizeReports?: boolean; 22 | 23 | /** 24 | * Maximum pixels a single image may hold. Images exceeding this number will 25 | * be discarded during processing with no changes to the terminal buffer 26 | * (no cursor advance, no placeholder). 27 | * This setting is mainly used to restrict images sizes during initial decoding 28 | * including the final canvas creation. 29 | * 30 | * Note: The image worker decoder may hold additional memory up to 31 | * `pixelLimit` * 4 bytes permanently, plus the same amount on top temporarily 32 | * for pixel transfers, which should be taken into account under memory pressure conditions. 33 | * 34 | * Note: Browsers restrict allowed canvas dimensions further. We dont reflect those 35 | * limits here, thus the construction of an oddly shaped image having most pixels 36 | * in one dimension still can fail. 37 | * 38 | * Note: `storageLimit` bytes are calculated from images by multiplying the pixels with 4 39 | * (4 channels with one byte, images are stored as RGBA8888). 40 | * 41 | * Default is 2^16 (4096 x 4096 pixels). 42 | */ 43 | pixelLimit?: number; 44 | 45 | /** 46 | * Storage limit in MB. 47 | * The storage implements a FIFO cache removing old images, when the limit gets hit. 48 | * Also exposed as addon property for runtime adjustments. 49 | * Default is 128 MB. 50 | */ 51 | storageLimit?: number; 52 | 53 | /** 54 | * Whether to show a placeholder for images removed from cache, default is true. 55 | */ 56 | showPlaceholder?: boolean; 57 | 58 | /** 59 | * SIXEL settings 60 | */ 61 | 62 | /** Whether SIXEL is enabled (default is true). */ 63 | sixelSupport?: boolean; 64 | /** Whether SIXEL scrolling is enabled (default is true). Same as DECSET 80. */ 65 | sixelScrolling?: boolean; 66 | /** Palette color limit (default 256). */ 67 | sixelPaletteLimit?: number; 68 | /** SIXEL image size limit in bytes (default 25000000 bytes). */ 69 | sixelSizeLimit?: number; 70 | 71 | /** 72 | * IIP settings (iTerm image protocol) 73 | */ 74 | 75 | /** Whether iTerm image protocol style is enabled (default is true). */ 76 | iipSupport?: boolean; 77 | /** IIP sequence size limit (default 20000000 bytes). */ 78 | iipSizeLimit?: number; 79 | } 80 | 81 | export class ImageAddon implements ITerminalAddon { 82 | constructor(options?: IImageAddonOptions); 83 | public activate(terminal: Terminal): void; 84 | public dispose(): void; 85 | 86 | /** 87 | * Reset the image addon. 88 | * 89 | * This resets all runtime options to default values (as given to the ctor) 90 | * and resets the image storage. 91 | */ 92 | public reset(): void; 93 | 94 | /** 95 | * Getter/Setter for the storageLimit in MB. 96 | * Synchronously deletes images if the stored data exceeds the new value. 97 | */ 98 | public storageLimit: number; 99 | 100 | /** 101 | * Current memory usage of the stored images in MB. 102 | */ 103 | public readonly storageUsage: number; 104 | 105 | /** 106 | * Getter/Setter whether the placeholder should be shown. 107 | */ 108 | public showPlaceholder: boolean; 109 | 110 | /** 111 | * Get original image canvas at buffer position. 112 | */ 113 | public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined; 114 | 115 | /** 116 | * Extract single tile canvas at buffer position. 117 | */ 118 | public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /overwrite/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "project": [ 10 | "src/browser/tsconfig.json", 11 | "src/common/tsconfig.json", 12 | "src/headless/tsconfig.json", 13 | "test/api/tsconfig.json", 14 | "test/benchmark/tsconfig.json", 15 | "addons/tsconfig.eslint.addons.json" 16 | ], 17 | "sourceType": "module" 18 | }, 19 | "ignorePatterns": [ 20 | "**/inwasm-sdks/*", 21 | "**/typings/*.d.ts", 22 | "**/node_modules", 23 | "**/*.js" 24 | ], 25 | "plugins": [ 26 | "@typescript-eslint" 27 | ], 28 | "rules": { 29 | "no-extra-semi": "error", 30 | "@typescript-eslint/array-type": [ 31 | "warn", 32 | { 33 | "default": "array", 34 | "readonly": "generic" 35 | } 36 | ], 37 | "@typescript-eslint/consistent-type-assertions": "warn", 38 | "@typescript-eslint/consistent-type-definitions": "warn", 39 | "@typescript-eslint/explicit-function-return-type": [ 40 | "warn", 41 | { 42 | "allowExpressions": true 43 | } 44 | ], 45 | "@typescript-eslint/explicit-member-accessibility": [ 46 | "warn", 47 | { 48 | "accessibility": "explicit", 49 | "overrides": { 50 | "constructors": "off" 51 | } 52 | } 53 | ], 54 | "@typescript-eslint/indent": [ 55 | "warn", 56 | 2 57 | ], 58 | "@typescript-eslint/member-delimiter-style": [ 59 | "warn", 60 | { 61 | "multiline": { 62 | "delimiter": "semi", 63 | "requireLast": true 64 | }, 65 | "singleline": { 66 | "delimiter": "comma", 67 | "requireLast": false 68 | } 69 | } 70 | ], 71 | "@typescript-eslint/naming-convention": [ 72 | "warn", 73 | { "selector": "default", "format": ["camelCase"] }, 74 | // variableLike 75 | { "selector": "variable", "format": ["camelCase", "UPPER_CASE"] }, 76 | { "selector": "variable", "filter": "^I.+Service$", "format": ["PascalCase"], "prefix": ["I"] }, 77 | // memberLike 78 | { "selector": "memberLike", "modifiers": ["private"], "format": ["camelCase"], "leadingUnderscore": "require" }, 79 | { "selector": "memberLike", "modifiers": ["protected"], "format": ["camelCase"], "leadingUnderscore": "require" }, 80 | { "selector": "enumMember", "format": ["UPPER_CASE"] }, 81 | // memberLike - Allow enum-like objects to use UPPER_CASE 82 | { "selector": "property", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"] }, 83 | { "selector": "method", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"] }, 84 | // typeLike 85 | { "selector": "typeLike", "format": ["PascalCase"] }, 86 | { "selector": "interface", "format": ["PascalCase"], "prefix": ["I"] } 87 | ], 88 | "@typescript-eslint/prefer-namespace-keyword": "warn", 89 | "@typescript-eslint/type-annotation-spacing": "warn", 90 | "@typescript-eslint/quotes": [ 91 | "warn", 92 | "single", 93 | { "allowTemplateLiterals": true } 94 | ], 95 | "@typescript-eslint/semi": [ 96 | "warn", 97 | "always" 98 | ], 99 | "comma-dangle": [ 100 | "warn", 101 | { 102 | "objects": "never", 103 | "arrays": "never", 104 | "functions": "never" 105 | } 106 | ], 107 | "curly": [ 108 | "warn", 109 | "multi-line" 110 | ], 111 | "eol-last": "warn", 112 | "eqeqeq": [ 113 | "warn", 114 | "always" 115 | ], 116 | "keyword-spacing": "warn", 117 | "new-parens": "warn", 118 | "no-duplicate-imports": "warn", 119 | "no-else-return": [ 120 | "warn", 121 | { 122 | "allowElseIf": false 123 | } 124 | ], 125 | "no-eval": "warn", 126 | "no-irregular-whitespace": "warn", 127 | "no-restricted-imports": [ 128 | "warn", 129 | { 130 | "patterns": [ 131 | ".*\\/out\\/.*" 132 | ] 133 | } 134 | ], 135 | "no-trailing-spaces": "warn", 136 | "no-unsafe-finally": "warn", 137 | "no-var": "warn", 138 | "one-var": [ 139 | "warn", 140 | "never" 141 | ], 142 | "object-curly-spacing": [ 143 | "warn", 144 | "always" 145 | ], 146 | "prefer-const": "warn", 147 | "spaced-comment": [ 148 | "warn", 149 | "always", 150 | { 151 | "markers": ["/"], 152 | "exceptions": ["-"] 153 | } 154 | ] 155 | }, 156 | "overrides": [ 157 | { 158 | "files": ["**/*.test.ts"], 159 | "rules": { 160 | "object-curly-spacing": "off" 161 | } 162 | } 163 | ] 164 | } 165 | -------------------------------------------------------------------------------- /fixture/gcrglf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # GLF should move the text cursor downwards, 4 | # even if no pixels were modified, 5 | # when sixel scrolling is on. 6 | 7 | echo -n $'\e[?80l' # Ensure sixel scrolling is on (disable DECSDM) 8 | 9 | clear 10 | echo "A test of Sixel GLF (Graphics Line Feed) when sixel scrolling is on" 11 | echo 12 | 13 | # Move cursor down three using GLF ("-") 14 | echo -e '\x1bPq$-$-$-$-\x1b\\' 15 | 16 | # Show a single sixel line that says, "Your terminal ->" 17 | cat <<'EOF' 18 | P0;0;0q"1;1;244;21#0;2;0;0;0#1;2;80;80;80#0~~~NFFFN~~~n!4FN!68~NFN!64~bbb!42~rbbbBB!41~$#1???owwwo???O!4wo!68?owo!64?[[[!42?K[[[{{-#0!5~{woBFB`o{}!4~NFBpp!4xpbBF~~~x@@@~~~xxp@@!5~xxp@@b`pxwxpz!17~zpp???!5px!5~NFBpxxwxxpBBN!4~xxp@@b`pxwxpz~x@@@xxp@@pxx@@F!4~xppp@@!8~x@@@pxxwxp@B!5~zxXXXWXX@BF!10~??!24~!10^FNN^~~~$#1!5?BFN{w{]NB@!4?ow{MM!4EM[{w???E}}}???EEM}}!5?EEM}}[]MEFEMC!17?CMM~~~!5ME!5?ow{MEEFEEM{{o!4?EEM}}[]MEFEMC?E}}}EEM}}MEE}}w!4?EMMM}}!8?E}}}MEEFEM}{!5?CEeeefee}{w!10?~~!24?!10_woo_-#0!5~nFFF??FFN!5~wo_FF!4NFb_o!4~{o_!4Ffb??Fn~~~NFF??!4FN!23~_??!5NFFn~~~w__C!7KCC!4~NFF??!4FN!4~N???N~~??Fn~???N~~N!4F??!4Fn~~~F???Fn~~nF??Fn~~p??KMMMEE???Fn~~~N!4F??!4Fn!29~{{}!4~$#1!5?Owww~~wwo!5?FN^ww!4ow[^N!4?BN^!4wW[~~wO???oww~~!4wo!23?^~~!5owwO???F^^z!7rzz!4?oww~~!4wo!4?o~~~o??~~wO?~~~o??o!4w~~!4wO???w~~~wO??Ow~~wO??M~~rpppxx~~~wO???o!4w~~!4wO!29?BB@-#0!25FE!63FE!15FE!77FE!60F$#1!25?@!63?@!15?@!77?@-\ 19 | EOF 20 | 21 | sleep 1 22 | tput cup 2 30 23 | 24 | # Show four lines saying, "<- A genuine VT340 would end here" 25 | cat <<'EOF' 26 | P0;0;0q"1;1;144;80#0;2;0;0;0#1;2;80;80;80#0~~rpp@@`@BN!25~!11^!5~^^^N^^^!6~!7^N^^^!5~!4^~~~!5^!6~^^^WWW!8~!7^N^^^!8~^^^N^^^!5~$#1??KMM}}]}{o!25?!11_!5?___o___!6?!7_o___!5?!4_???!5_!6?___fff!8?!7_o___!8?___o___-#0~~NB??EFE??@F^!19~B??{!4}{W??}}~~B@?K!5MK??B~~~}???{!4}{??!4~}???~~~}}{??!6~}{{{??!8~}???{!4}{??!4~B@?K!5MK??B~~$#1??o{~~xwx~~}w_!19?{~~B!4@Bf~~@@??{}~r!5pr~~{???@~~~B!4@B~~!4?@~~~???@@B~~!6?@BBB~~!8?@~~~B!4@B~~!4?{}~r!5pr~~{-#0rpoopr~~~zpooopr!17~}{ww!4pwW??!4~}wwprrb!4rpp~~~pooopz~~zpoopz!4~{w!4pxwoopz~~~r!4poo!4pz~~~pooopz~~zpoopz~~}wwprrb!4rpp~~$#1KMNNMK???CMNNNMK!17?@BFF!4MFf~~!4?@FFMKK[!4KMM???MNNNMC??CMNNMC!4?BF!4MEFNNMC???K!4MNN!4MC???MNNNMC??CMNNMC??@FFMKK[!4KMM-#0!32~^NNNKWwww[[MNNN^~!13N!5~NN!5FNN^!10~N!4F!8~NN!4FN^!36~$#1!32?_ooorfFFFbbpooo_?!13o!5?oo!5woo_!10?o!4w!8?oo!4wo_-#0!32~}{o?AM!4~E?_w}}~o_o}}???}}}__!5~}}~fFFFAOw!6~^FB_w{???!6~B??}!4~{??!35~$#1!32?@BN~|p!4?x~^F@@?N^N@@~~~@@@^^!5?@@?Wwww|nF!6?_w{^FB~~~!6?{~~@!4?B~~-#0!36~{_?FB?o}!7~^NN???NN^!6~!8NE?_x!4~poooP@@???X!5~{o?F!4NB_o!35~$#1!36?B^~w{~N@!7?_oo~~~oo_!6?!8ox~^E!4?MNNNm}}~~~e!5?BN~w!4o{^N-#0!38~}}}!11~!8}!8~!6}!13~!6}!9~!4}!38~$#1!38?@@@!11?!8@!8?!6@!13?!6@!9?!4@-#0!5^!5~!5^!5~!7^!6~!4^~~~!5^!6~{www??!11~^^^N^\W???!22~^^^N^^^!6~!7^N^^^!8~^^^N^\W???~~$#1!5_!5?!5_!5?!7_!6?!4_???!5_!6?BFFF~~!11?___o_af~~~!22?___o___!6?!7_o___!8?___o_af~~~-#0}{??CMB@@FM??o{~~B@?{{!4}{w?@~~~}???~~~}}{??!10~??!8~B??{!5}{???!19~B@?K!5MK??B~~~}???{!4}{??!4~B??{!5}{???~~$#1@B~~zp{}}wp~~NB??{}~BB!4@BF~}???@~~~???@@B~~!10?~~!8?{~~B!5@B~~~!19?{}~r!5pr~~{???@~~~B!4@B~~!4?{~~B!5@B~~~-#0~~~woow~}woo{!4~}{wpprbrrpww{!5~{w!4pxwoopz~~~r!4poo!4pz~~~}{ww!5pwooopz!17~}wwprrb!4rpp~~~pooopz~~zpoopz~~}{ww!5pwooopz$#1???FNNF?@FNNB!4?@BFMMK[KKMFFB!5?BF!4MEFNNMC???K!4MNN!4MC???@BFF!5MFNNNMC!17?@FFMKK[!4KMM???MNNNMC??CMNNMC??@BFF!5MFNNNMC-#0!40~n!4F!99~$#1!40?O!4w-#0!10~^^n!29~???b!4rbBF!4~^NFbrrprrbFF^!4~rrbBBFBbrrrbv~~^NFbrrprrbFF^!42~$#1!10?__O!29?~~~[!4K[{w!4?_ow[KKMKK[ww_!4?KK[{{w{[KKK[G??_ow[KKMKK[ww_-#0!9~}{wq!10}!18~N???N^~~^N??N^~~o??H!6XWGG!4~^NN??!4N^!5~o??H!6XWGG!42~$#1!9?@BFL!10@!18?o~~~o_??_o~~o_??N~~u!6efvv!4?_oo~~!4o_!5?N~~u!6efvv-#0!41B!6ABB!6A!5BAAA?!6A!4B!10A!8BAAA?!6A!42B$#1!41?!6@??!6@!5?@@@B!6@!4?!10@!8?@@@B!6@$-\This text should be indented. 27 | EOF 28 | 29 | # For more details, please see: 30 | # https://github.com/jerch/xterm-addon-image/issues/37 31 | 32 | # By default, sixel is in a 2:1 aspect ratio, which means 33 | # every sixel graphics linefeed (GLF) adds another 12 pixels. 34 | # With four GLFs, we have five sixel lines = 5 * 12 = 60 pixels. 35 | 36 | # Which text line that ends up on depends upon the height of your 37 | # font. On the VT340, the font is 20 pixels high. 38 | 39 | # On a VT340 sixel scrolling is on by default. DEC refers to this by 40 | # two different names "sixel scrolling" and its negation, "DECSDM" 41 | # (Sixel Display Mode). They control the same thing, so when DECSDM is 42 | # on, sixel scrolling is off, and vice versa. 43 | 44 | # When DECSDM is on, the graphic line feeds do not affect the text cursor. 45 | 46 | 47 | # How hackerb9 created the two sixel test images: 48 | # 49 | # convert -family "Courier" -style normal -density 72 -pointsize 26 -interline-spacing -12 -gravity center -fill gray80 -background none label:$'A genuine\nVT340\nwould end\n← here ' +trim +dither -colors 2 sixel:- > reference.six 50 | # 51 | # convert -family "Courier" -style normal -density 72 -pointsize 26 -interline-spacing -12 -gravity center -fill gray80 -background none label:$'Your terminal →' +trim -bordercolor none -border 2 +dither -colors 2 sixel:- > yourterminal.six 52 | 53 | -------------------------------------------------------------------------------- /overwrite/demo/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WARNING: This demo is a barebones implementation designed for development and evaluation 3 | * purposes only. It is definitely NOT production ready and does not aim to be so. Exposing the 4 | * demo to the public as is would introduce security risks for the host. 5 | **/ 6 | 7 | var express = require('express'); 8 | var expressWs = require('express-ws'); 9 | var os = require('os'); 10 | var pty = require('node-pty'); 11 | 12 | // Whether to use binary transport. 13 | const USE_BINARY = os.platform() !== "win32"; 14 | 15 | function startServer() { 16 | var app = express(); 17 | expressWs(app); 18 | 19 | var terminals = {}, 20 | logs = {}; 21 | 22 | app.use('/xterm.css', express.static(__dirname + '/../css/xterm.css')); 23 | app.get('/logo.png', (req, res) => { 24 | res.sendFile(__dirname + '/logo.png'); 25 | }); 26 | 27 | app.get('/', (req, res) => { 28 | res.sendFile(__dirname + '/index.html'); 29 | }); 30 | 31 | app.get('/test', (req, res) => { 32 | res.sendFile(__dirname + '/test.html'); 33 | }); 34 | 35 | app.get('/style.css', (req, res) => { 36 | res.sendFile(__dirname + '/style.css'); 37 | }); 38 | 39 | app.use('/dist', express.static(__dirname + '/dist')); 40 | app.use('/src', express.static(__dirname + '/src')); 41 | app.use('/workers', express.static(__dirname + '/workers')); 42 | 43 | app.post('/terminals', (req, res) => { 44 | const env = Object.assign({}, process.env); 45 | env['COLORTERM'] = 'truecolor'; 46 | var cols = parseInt(req.query.cols), 47 | rows = parseInt(req.query.rows), 48 | term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : 'bash', [], { 49 | name: 'xterm-256color', 50 | cols: cols || 80, 51 | rows: rows || 24, 52 | cwd: process.platform === 'win32' ? undefined : env.PWD, 53 | env: env, 54 | encoding: USE_BINARY ? null : 'utf8' 55 | }); 56 | 57 | console.log('Created terminal with PID: ' + term.pid); 58 | terminals[term.pid] = term; 59 | logs[term.pid] = ''; 60 | term.on('data', function(data) { 61 | logs[term.pid] += data; 62 | }); 63 | res.send(term.pid.toString()); 64 | res.end(); 65 | }); 66 | 67 | app.post('/terminals/:pid/size', (req, res) => { 68 | var pid = parseInt(req.params.pid), 69 | cols = parseInt(req.query.cols), 70 | rows = parseInt(req.query.rows), 71 | term = terminals[pid]; 72 | 73 | term.resize(cols, rows); 74 | console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); 75 | res.end(); 76 | }); 77 | 78 | app.ws('/terminals/:pid', function (ws, req) { 79 | var term = terminals[parseInt(req.params.pid)]; 80 | console.log('Connected to terminal ' + term.pid); 81 | ws.send(logs[term.pid]); 82 | 83 | // string message buffering 84 | function buffer(socket, timeout) { 85 | let s = ''; 86 | let sender = null; 87 | return (data) => { 88 | s += data; 89 | if (!sender) { 90 | sender = setTimeout(() => { 91 | socket.send(s); 92 | s = ''; 93 | sender = null; 94 | }, timeout); 95 | } 96 | }; 97 | } 98 | // binary message buffering 99 | function bufferUtf8(socket, timeout) { 100 | let buffer = []; 101 | let sender = null; 102 | let length = 0; 103 | return (data) => { 104 | buffer.push(data); 105 | length += data.length; 106 | if (!sender) { 107 | sender = setTimeout(() => { 108 | socket.send(Buffer.concat(buffer, length)); 109 | buffer = []; 110 | sender = null; 111 | length = 0; 112 | }, timeout); 113 | } 114 | }; 115 | } 116 | const send = USE_BINARY ? bufferUtf8(ws, 5) : buffer(ws, 5); 117 | 118 | // WARNING: This is a naive implementation that will not throttle the flow of data. This means 119 | // it could flood the communication channel and make the terminal unresponsive. Learn more about 120 | // the problem and how to implement flow control at https://xtermjs.org/docs/guides/flowcontrol/ 121 | term.on('data', function(data) { 122 | try { 123 | send(data); 124 | } catch (ex) { 125 | // The WebSocket is not open, ignore 126 | } 127 | }); 128 | ws.on('message', function(msg) { 129 | term.write(msg); 130 | }); 131 | ws.on('close', function () { 132 | term.kill(); 133 | console.log('Closed terminal ' + term.pid); 134 | // Clean things up 135 | delete terminals[term.pid]; 136 | delete logs[term.pid]; 137 | }); 138 | }); 139 | 140 | var port = process.env.PORT || 3000, 141 | host = os.platform() === 'win32' ? '127.0.0.1' : '0.0.0.0'; 142 | 143 | console.log('App listening to http://127.0.0.1:' + port); 144 | app.listen(port, host); 145 | } 146 | 147 | module.exports = startServer; 148 | -------------------------------------------------------------------------------- /src/IIPHeaderParser.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser'; 3 | 4 | 5 | const CASES: [string, IHeaderFields][] = [ 6 | ['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}], 7 | ['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}], 8 | // utf-8 encoding in name 9 | ['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}], 10 | ['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}], 11 | // full header spec 12 | [ 13 | 'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', 14 | { 15 | inline: 1, 16 | width: '10px', 17 | height: '20%', 18 | preserveAspectRatio: 1, 19 | size: 123456, 20 | name: 'ümläutß' 21 | } 22 | ], 23 | [ 24 | 'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', 25 | { 26 | inline: 1, 27 | width: 'auto', 28 | height: '20', 29 | preserveAspectRatio: 1, 30 | size: 123456, 31 | name: 'ümläutß' 32 | } 33 | ] 34 | ]; 35 | 36 | function fromBs(bs: string): Uint32Array { 37 | const r = new Uint32Array(bs.length); 38 | for (let i = 0; i < r.length; ++i) r[i] = bs.charCodeAt(i); 39 | return r; 40 | } 41 | 42 | describe('IIPHeaderParser', () => { 43 | it('at once', () => { 44 | const hp = new HeaderParser(); 45 | for (const example of CASES) { 46 | hp.reset(); 47 | const inp = fromBs(example[0]); 48 | const res = hp.parse(inp, 0, inp.length); 49 | assert.strictEqual(res, inp.length); 50 | assert.strictEqual(hp.state, HeaderState.END); 51 | assert.deepEqual(hp.fields, example[1]); 52 | } 53 | }); 54 | it('bytewise', () => { 55 | const hp = new HeaderParser(); 56 | for (const example of CASES) { 57 | hp.reset(); 58 | const inp = fromBs(example[0]); 59 | let pos = 0; 60 | let res = -2; 61 | while (res === -2 && pos < inp.length) { 62 | res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1); 63 | } 64 | assert.strictEqual(res, 1); 65 | assert.strictEqual(hp.state, HeaderState.END); 66 | assert.deepEqual(hp.fields, example[1]); 67 | } 68 | }); 69 | it('no File= starter', () => { 70 | const hp = new HeaderParser(); 71 | let inp = fromBs('size=123456;name=dGVzdA==:'); 72 | let res = hp.parse(inp, 0, inp.length); 73 | assert.strictEqual(res, -1); 74 | hp.reset(); 75 | inp = fromBs(CASES[0][0]); 76 | res = hp.parse(inp, 0, inp.length); 77 | assert.strictEqual(res, inp.length); 78 | assert.strictEqual(hp.state, HeaderState.END); 79 | assert.deepEqual(hp.fields, CASES[0][1]); 80 | }); 81 | it('empty key - error', () => { 82 | const hp = new HeaderParser(); 83 | let inp = fromBs('File=size=123456;=dGVzdA==:'); 84 | let res = hp.parse(inp, 0, inp.length); 85 | assert.strictEqual(res, -1); 86 | hp.reset(); 87 | inp = fromBs(CASES[0][0]); 88 | res = hp.parse(inp, 0, inp.length); 89 | assert.strictEqual(res, inp.length); 90 | assert.strictEqual(hp.state, HeaderState.END); 91 | assert.deepEqual(hp.fields, CASES[0][1]); 92 | }); 93 | it('empty size value - set to 0', () => { 94 | const hp = new HeaderParser(); 95 | let inp = fromBs('File=size=;name=dGVzdA==:'); 96 | let res = hp.parse(inp, 0, inp.length); 97 | assert.strictEqual(res, inp.length); 98 | assert.strictEqual(hp.state, HeaderState.END); 99 | assert.deepEqual(hp.fields, {name: 'test', size: 0}); 100 | hp.reset(); 101 | inp = fromBs(CASES[0][0]); 102 | res = hp.parse(inp, 0, inp.length); 103 | assert.strictEqual(res, inp.length); 104 | assert.strictEqual(hp.state, HeaderState.END); 105 | assert.deepEqual(hp.fields, CASES[0][1]); 106 | }); 107 | it('empty name value - set to empty string', () => { 108 | const hp = new HeaderParser(); 109 | let inp = fromBs('File=size=123456;name=:'); 110 | let res = hp.parse(inp, 0, inp.length); 111 | assert.strictEqual(res, inp.length); 112 | assert.strictEqual(hp.state, HeaderState.END); 113 | assert.deepEqual(hp.fields, {name: '', size: 123456}); 114 | hp.reset(); 115 | inp = fromBs(CASES[0][0]); 116 | res = hp.parse(inp, 0, inp.length); 117 | assert.strictEqual(res, inp.length); 118 | assert.strictEqual(hp.state, HeaderState.END); 119 | assert.deepEqual(hp.fields, CASES[0][1]); 120 | }); 121 | it('empty size value - error', () => { 122 | const hp = new HeaderParser(); 123 | let inp = fromBs('File=inline=1;width=;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:'); 124 | let res = hp.parse(inp, 0, inp.length); 125 | assert.strictEqual(res, -1); 126 | hp.reset(); 127 | inp = fromBs(CASES[0][0]); 128 | res = hp.parse(inp, 0, inp.length); 129 | assert.strictEqual(res, inp.length); 130 | assert.strictEqual(hp.state, HeaderState.END); 131 | assert.deepEqual(hp.fields, CASES[0][1]); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/SixelHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020, 2022 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { ImageStorage } from './ImageStorage'; 7 | import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types'; 8 | import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors'; 9 | import { RGBA8888 } from 'sixel/lib/Types'; 10 | import { ImageRenderer } from './ImageRenderer'; 11 | 12 | import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; 13 | 14 | // always free decoder ressources after decoding if it exceeds this limit 15 | const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB 16 | 17 | // custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096) 18 | const DEFAULT_PALETTE = PALETTE_ANSI_256; 19 | DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); 20 | 21 | 22 | export class SixelHandler implements IDcsHandler, IResetHandler { 23 | private _size = 0; 24 | private _aborted = false; 25 | private _dec: Decoder | undefined; 26 | 27 | constructor( 28 | private readonly _opts: IImageAddonOptions, 29 | private readonly _storage: ImageStorage, 30 | private readonly _coreTerminal: ITerminalExt 31 | ) { 32 | DecoderAsync({ 33 | memoryLimit: this._opts.pixelLimit * 4, 34 | palette: DEFAULT_PALETTE, 35 | paletteLimit: this._opts.sixelPaletteLimit 36 | }).then(d => this._dec = d); 37 | } 38 | 39 | public reset(): void { 40 | /** 41 | * reset sixel decoder to defaults: 42 | * - release all memory 43 | * - nullify palette (4096) 44 | * - apply default palette (256) 45 | */ 46 | if (this._dec) { 47 | this._dec.release(); 48 | // FIXME: missing interface on decoder to nullify full palette 49 | (this._dec as any)._palette.fill(0); 50 | this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); 51 | } 52 | } 53 | 54 | public hook(params: IParams): void { 55 | this._size = 0; 56 | this._aborted = false; 57 | if (this._dec) { 58 | const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( 59 | this._coreTerminal._core._inputHandler._curAttrData, 60 | this._coreTerminal._core._themeService?.colors); 61 | this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); 62 | } 63 | } 64 | 65 | public put(data: Uint32Array, start: number, end: number): void { 66 | if (this._aborted || !this._dec) { 67 | return; 68 | } 69 | this._size += end - start; 70 | if (this._size > this._opts.sixelSizeLimit) { 71 | console.warn(`SIXEL: too much data, aborting`); 72 | this._aborted = true; 73 | this._dec.release(); 74 | return; 75 | } 76 | try { 77 | this._dec.decode(data, start, end); 78 | } catch (e) { 79 | console.warn(`SIXEL: error while decoding image - ${e}`); 80 | this._aborted = true; 81 | this._dec.release(); 82 | } 83 | } 84 | 85 | public unhook(success: boolean): boolean | Promise { 86 | if (this._aborted || !success || !this._dec) { 87 | return true; 88 | } 89 | 90 | const width = this._dec.width; 91 | const height = this._dec.height; 92 | 93 | // partial fix for https://github.com/jerch/xterm-addon-image/issues/37 94 | if (!width || ! height) { 95 | if (height) { 96 | this._storage.advanceCursor(height); 97 | } 98 | return true; 99 | } 100 | 101 | const canvas = ImageRenderer.createCanvas(undefined, width, height); 102 | canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8, width, height), 0, 0); 103 | if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { 104 | this._dec.release(); 105 | } 106 | this._storage.addImage(canvas); 107 | return true; 108 | } 109 | } 110 | 111 | 112 | /** 113 | * Some helpers to extract current terminal colors. 114 | */ 115 | 116 | // get currently active background color from terminal 117 | // also respect INVERSE setting 118 | function extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 { 119 | let bg = 0; 120 | if (!colors) { 121 | // FIXME: theme service is prolly not available yet, 122 | // happens if .open() was not called yet (bug in core?) 123 | return bg; 124 | } 125 | if (attr.isInverse()) { 126 | if (attr.isFgDefault()) { 127 | bg = convertLe(colors.foreground.rgba); 128 | } else if (attr.isFgRGB()) { 129 | const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor()); 130 | bg = toRGBA8888(...t); 131 | } else { 132 | bg = convertLe(colors.ansi[attr.getFgColor()].rgba); 133 | } 134 | } else { 135 | if (attr.isBgDefault()) { 136 | bg = convertLe(colors.background.rgba); 137 | } else if (attr.isBgRGB()) { 138 | const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor()); 139 | bg = toRGBA8888(...t); 140 | } else { 141 | bg = convertLe(colors.ansi[attr.getBgColor()].rgba); 142 | } 143 | } 144 | return bg; 145 | } 146 | 147 | // rgba values on the color managers are always in BE, thus convert to LE 148 | function convertLe(color: number): RGBA8888 { 149 | if (BIG_ENDIAN) return color; 150 | return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF; 151 | } 152 | -------------------------------------------------------------------------------- /src/IIPHeaderParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | 7 | // eslint-disable-next-line 8 | declare const Buffer: any; 9 | 10 | 11 | export interface IHeaderFields { 12 | // base-64 encoded filename. Defaults to "Unnamed file". 13 | name: string; 14 | // File size in bytes. The file transfer will be canceled if this size is exceeded. 15 | size: number; 16 | /** 17 | * Optional width and height to render: 18 | * - N: N character cells. 19 | * - Npx: N pixels. 20 | * - N%: N percent of the session's width or height. 21 | * - auto: The image's inherent size will be used to determine an appropriate dimension. 22 | */ 23 | width?: string; 24 | height?: string; 25 | // Optional, defaults to 1 respecting aspect ratio (width takes precedence). 26 | preserveAspectRatio?: number; 27 | // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded (download not supported). 28 | inline?: number; 29 | } 30 | 31 | export const enum HeaderState { 32 | START = 0, 33 | ABORT = 1, 34 | KEY = 2, 35 | VALUE = 3, 36 | END = 4 37 | } 38 | 39 | // field value decoders 40 | 41 | // ASCII bytes to string 42 | function toStr(data: Uint32Array): string { 43 | let s = ''; 44 | for (let i = 0; i < data.length; ++i) { 45 | s += String.fromCharCode(data[i]); 46 | } 47 | return s; 48 | } 49 | 50 | // digits to integer 51 | function toInt(data: Uint32Array): number { 52 | let v = 0; 53 | for (let i = 0; i < data.length; ++i) { 54 | if (data[i] < 48 || data[i] > 57) { 55 | throw new Error('illegal char'); 56 | } 57 | v = v * 10 + data[i] - 48; 58 | } 59 | return v; 60 | } 61 | 62 | // check for correct size entry 63 | function toSize(data: Uint32Array): string { 64 | const v = toStr(data); 65 | if (!v.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/)) { 66 | throw new Error('illegal size'); 67 | } 68 | return v; 69 | } 70 | 71 | // name is base64 encoded utf-8 72 | function toName(data: Uint32Array): string { 73 | if (typeof Buffer !== 'undefined') { 74 | return Buffer.from(toStr(data), 'base64').toString(); 75 | } 76 | const bs = atob(toStr(data)); 77 | const b = new Uint8Array(bs.length); 78 | for (let i = 0; i < b.length; ++i) { 79 | b[i] = bs.charCodeAt(i); 80 | } 81 | return new TextDecoder().decode(b); 82 | } 83 | 84 | const DECODERS: {[key: string]: (v: Uint32Array) => any} = { 85 | inline: toInt, 86 | size: toInt, 87 | name: toName, 88 | width: toSize, 89 | height: toSize, 90 | preserveAspectRatio: toInt 91 | }; 92 | 93 | 94 | const FILE_MARKER = [70, 105, 108, 101]; 95 | const MAX_FIELDCHARS = 1024; 96 | 97 | 98 | export class HeaderParser { 99 | public state: HeaderState = HeaderState.START; 100 | private _buffer = new Uint32Array(MAX_FIELDCHARS); 101 | private _position = 0; 102 | private _key = ''; 103 | public fields: {[key: string]: any} = {}; 104 | 105 | public reset(): void { 106 | this._buffer.fill(0); 107 | this.state = HeaderState.START; 108 | this._position = 0; 109 | this.fields = {}; 110 | this._key = ''; 111 | } 112 | 113 | public parse(data: Uint32Array, start: number, end: number): number { 114 | let state = this.state; 115 | let pos = this._position; 116 | const buffer = this._buffer; 117 | if (state === HeaderState.ABORT || state === HeaderState.END) return -1; 118 | if (state === HeaderState.START && pos > 6) return -1; 119 | for (let i = start; i < end; ++i) { 120 | const c = data[i]; 121 | switch (c) { 122 | case 59: // ; 123 | if (!this._storeValue(pos)) return this._a(); 124 | state = HeaderState.KEY; 125 | pos = 0; 126 | break; 127 | case 61: // = 128 | if (state === HeaderState.START) { 129 | for (let k = 0; k < FILE_MARKER.length; ++k) { 130 | if (buffer[k] !== FILE_MARKER[k]) return this._a(); 131 | } 132 | state = HeaderState.KEY; 133 | pos = 0; 134 | } else if (state === HeaderState.KEY) { 135 | if (!this._storeKey(pos)) return this._a(); 136 | state = HeaderState.VALUE; 137 | pos = 0; 138 | } else if (state === HeaderState.VALUE) { 139 | if (pos >= MAX_FIELDCHARS) return this._a(); 140 | buffer[pos++] = c; 141 | } 142 | break; 143 | case 58: // : 144 | if (state === HeaderState.VALUE) { 145 | if (!this._storeValue(pos)) return this._a(); 146 | } 147 | this.state = HeaderState.END; 148 | return i + 1; 149 | default: 150 | if (pos >= MAX_FIELDCHARS) return this._a(); 151 | buffer[pos++] = c; 152 | } 153 | } 154 | this.state = state; 155 | this._position = pos; 156 | return -2; 157 | } 158 | 159 | private _a(): number { 160 | this.state = HeaderState.ABORT; 161 | return -1; 162 | } 163 | 164 | private _storeKey(pos: number): boolean { 165 | const k = toStr(this._buffer.subarray(0, pos)); 166 | if (k) { 167 | this._key = k; 168 | this.fields[k] = null; 169 | return true; 170 | } 171 | return false; 172 | } 173 | 174 | private _storeValue(pos: number): boolean { 175 | if (this._key) { 176 | try { 177 | const v = this._buffer.slice(0, pos); 178 | this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v; 179 | } catch (e) { 180 | return false; 181 | } 182 | return true; 183 | } 184 | return false; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { Base64Decoder } from './base64.wasm'; 3 | 4 | // eslint-disable-next-line 5 | declare const Buffer: any; 6 | 7 | 8 | // some helpers 9 | function toBs(bytes: Uint8Array): string { 10 | let bs = ''; 11 | for (let i = 0; i < bytes.length; ++i) bs += String.fromCharCode(bytes[i]); 12 | return bs; 13 | } 14 | function fromBs(bs: string): Uint8Array { 15 | const r = new Uint8Array(bs.length); 16 | for (let i = 0; i < r.length; ++i) r[i] = bs.charCodeAt(i); 17 | return r; 18 | } 19 | function encNative(bytes: Uint8Array): string { 20 | return typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64') : btoa(toBs(bytes)); 21 | } 22 | function rtrim(x: string, c: string): string { 23 | let end = x.length - 1; 24 | while (c.indexOf(x[end]) >= 0) end -= 1; 25 | return x.slice(0, end + 1); 26 | } 27 | const MAP = new Uint8Array( 28 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 29 | .split('') 30 | .map(el => el.charCodeAt(0)) 31 | ); 32 | 33 | 34 | describe('Base64Decoder', () => { 35 | describe('decoding', () => { 36 | it('single bytes', () => { 37 | const dec = new Base64Decoder(0); 38 | for (let i = 0; i < 256; ++i) { 39 | dec.init(1); 40 | const inp = new Uint8Array([i]); 41 | const data = fromBs(encNative(inp)); 42 | assert.strictEqual(dec.put(data, 0, data.length), 0); 43 | assert.strictEqual(dec.end(), 0); 44 | assert.deepEqual(dec.data8, inp); 45 | } 46 | }); 47 | it('1+2 bytes', () => { 48 | const dec = new Base64Decoder(0); 49 | for (let a = 0; a < 256; ++a) { 50 | for (let b = 0; b < 256; ++b) { 51 | dec.init(2); 52 | const inp = new Uint8Array([a, b]); 53 | const data = fromBs(encNative(inp)); 54 | assert.strictEqual(dec.put(data, 0, data.length), 0); 55 | assert.strictEqual(dec.end(), 0); 56 | assert.deepEqual(dec.data8, inp); 57 | } 58 | } 59 | }); 60 | it('2+3 bytes', () => { 61 | const dec = new Base64Decoder(0); 62 | for (let a = 0; a < 256; ++a) { 63 | for (let b = 0; b < 256; ++b) { 64 | dec.init(3); 65 | const inp = new Uint8Array([0, a, b]); 66 | const data = fromBs(encNative(inp)); 67 | assert.strictEqual(dec.put(data, 0, data.length), 0); 68 | assert.strictEqual(dec.end(), 0); 69 | assert.deepEqual(dec.data8, inp); 70 | } 71 | } 72 | }); 73 | it('3+4 bytes', () => { 74 | const dec = new Base64Decoder(0); 75 | for (let a = 0; a < 256; ++a) { 76 | for (let b = 0; b < 256; ++b) { 77 | dec.init(4); 78 | const inp = new Uint8Array([0, 0, a, b]); 79 | const data = fromBs(encNative(inp)); 80 | assert.strictEqual(dec.put(data, 0, data.length), 0); 81 | assert.strictEqual(dec.end(), 0); 82 | assert.deepEqual(dec.data8, inp); 83 | } 84 | } 85 | }); 86 | it('padding', () => { 87 | const dec = new Base64Decoder(0); 88 | const d = fromBs('Hello, here comes the mouse'); 89 | const encData = []; 90 | const encDataTrimmed = []; 91 | for (let i = 1; i < d.length; ++i) { 92 | encData.push(encNative(d.slice(0, i))); 93 | encDataTrimmed.push(rtrim(encNative(d.slice(0, i)), '=')); 94 | } 95 | for (let i = 0; i < encData.length; ++i) { 96 | // with padding 97 | dec.init(i + 1); 98 | let enc = fromBs(encData[i]); 99 | assert.strictEqual(dec.put(enc, 0, enc.length), 0); 100 | assert.strictEqual(dec.end(), 0); 101 | assert.deepEqual(dec.data8, d.slice(0, i + 1)); 102 | // w'o padding 103 | dec.init(i + 1); 104 | enc = fromBs(encDataTrimmed[i]); 105 | assert.strictEqual(dec.put(enc, 0, enc.length), 0); 106 | assert.strictEqual(dec.end(), 0); 107 | assert.deepEqual(dec.data8, d.slice(0, i + 1)); 108 | } 109 | }); 110 | it('exit on false byte', () => { 111 | const dec = new Base64Decoder(0); 112 | for (let pos = 0; pos < 8; ++pos) { 113 | const inp = new Uint8Array([65, 65, 65, 65, 65, 65, 65, 65]); 114 | for (let i = 0; i < 256; ++i) { 115 | dec.release(); 116 | dec.init(6); 117 | inp[pos] = i; 118 | dec.put(inp, 0, 8); 119 | assert.strictEqual(dec.end(), MAP.includes(i) ? 0 : 1); 120 | } 121 | } 122 | }); 123 | }); 124 | describe('memory', () => { 125 | it('always release (keepSize 0)', () => { 126 | const dec = new Base64Decoder(0); 127 | dec.init(16); 128 | dec.put(fromBs('A'.repeat(16)), 0, 16); 129 | dec.end(); 130 | assert.strictEqual(dec.data8.length, 12); 131 | dec.release(); 132 | assert.strictEqual(dec.data8.length, 0); 133 | assert.isNull((dec as any)._mem); 134 | }); 135 | it('keep 1 page (keepSize 65536)', () => { 136 | const dec = new Base64Decoder(65536); 137 | dec.init(384); 138 | dec.put(fromBs('A'.repeat(512)), 0, 512); 139 | dec.end(); 140 | assert.strictEqual(dec.data8.length, 384); 141 | dec.release(); 142 | assert.strictEqual(dec.data8.length, 0); 143 | assert.isNotNull((dec as any)._mem); 144 | // grow to 2 pages + free afterwards 145 | dec.init(65536); 146 | dec.put(fromBs('A'.repeat(65536)), 0, 65536); 147 | dec.end(); 148 | assert.strictEqual(dec.data8.length, 49152); 149 | dec.release(); 150 | assert.strictEqual(dec.data8.length, 0); 151 | assert.isNull((dec as any)._mem); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/IIPHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; 7 | import { ImageRenderer } from './ImageRenderer'; 8 | import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; 9 | import { Base64Decoder } from './base64.wasm'; 10 | import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; 11 | import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; 12 | 13 | 14 | // eslint-disable-next-line 15 | declare const Buffer: any; 16 | 17 | // limit hold memory in base64 decoder 18 | const KEEP_DATA = 4194304; 19 | 20 | // default IIP header values 21 | const DEFAULT_HEADER: IHeaderFields = { 22 | name: 'Unnamed file', 23 | size: 0, 24 | width: 'auto', 25 | height: 'auto', 26 | preserveAspectRatio: 1, 27 | inline: 0 28 | }; 29 | 30 | 31 | export class IIPHandler implements IOscHandler, IResetHandler { 32 | private _aborted = false; 33 | private _hp = new HeaderParser(); 34 | private _header: IHeaderFields = DEFAULT_HEADER; 35 | private _dec = new Base64Decoder(KEEP_DATA); 36 | private _metrics = UNSUPPORTED_TYPE; 37 | 38 | constructor( 39 | private readonly _opts: IImageAddonOptions, 40 | private readonly _renderer: ImageRenderer, 41 | private readonly _storage: ImageStorage, 42 | private readonly _coreTerminal: ITerminalExt 43 | ) {} 44 | 45 | public reset(): void {} 46 | 47 | public start(): void { 48 | this._aborted = false; 49 | this._header = DEFAULT_HEADER; 50 | this._metrics = UNSUPPORTED_TYPE; 51 | this._hp.reset(); 52 | } 53 | 54 | public put(data: Uint32Array, start: number, end: number): void { 55 | if (this._aborted) return; 56 | 57 | if (this._hp.state === HeaderState.END) { 58 | if (this._dec.put(data, start, end)) { 59 | this._dec.release(); 60 | this._aborted = true; 61 | } 62 | } else { 63 | const dataPos = this._hp.parse(data, start, end); 64 | if (dataPos === -1) { 65 | this._aborted = true; 66 | return; 67 | } 68 | if (dataPos > 0) { 69 | this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields); 70 | if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) { 71 | this._aborted = true; 72 | return; 73 | } 74 | this._dec.init(this._header.size); 75 | if (this._dec.put(data, dataPos, end)) { 76 | this._dec.release(); 77 | this._aborted = true; 78 | } 79 | } 80 | } 81 | } 82 | 83 | public end(success: boolean): boolean | Promise { 84 | if (this._aborted) return true; 85 | 86 | let w = 0; 87 | let h = 0; 88 | 89 | // early exit condition chain 90 | let cond: number | boolean = true; 91 | if (cond = success) { 92 | if (cond = !this._dec.end()) { 93 | this._metrics = imageType(this._dec.data8); 94 | if (cond = this._metrics.mime !== 'unsupported') { 95 | w = this._metrics.width; 96 | h = this._metrics.height; 97 | if (cond = w && h && w * h < this._opts.pixelLimit) { 98 | [w, h] = this._resize(w, h).map(Math.floor); 99 | cond = w && h && w * h < this._opts.pixelLimit; 100 | } 101 | } 102 | } 103 | } 104 | if (!cond) { 105 | this._dec.release(); 106 | return true; 107 | } 108 | 109 | const blob = new Blob([this._dec.data8], { type: this._metrics.mime }); 110 | this._dec.release(); 111 | 112 | if (!window.createImageBitmap) { 113 | const url = URL.createObjectURL(blob); 114 | const img = new Image(); 115 | return new Promise(r => { 116 | img.addEventListener('load', () => { 117 | URL.revokeObjectURL(url); 118 | const canvas = ImageRenderer.createCanvas(window.document, w, h); 119 | canvas.getContext('2d')?.drawImage(img, 0, 0, w, h); 120 | this._storage.addImage(canvas); 121 | r(true); 122 | }); 123 | img.src = url; 124 | // sanity measure to avoid terminal blocking from dangling promise 125 | // happens from corrupt data (onload never gets fired) 126 | setTimeout(() => r(true), 1000); 127 | }); 128 | } 129 | return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) 130 | .then(bm => { 131 | this._storage.addImage(bm); 132 | return true; 133 | }); 134 | } 135 | 136 | private _resize(w: number, h: number): [number, number] { 137 | const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; 138 | const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; 139 | const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols; 140 | const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows; 141 | 142 | const rw = this._dim(this._header.width!, width, cw); 143 | const rh = this._dim(this._header.height!, height, ch); 144 | if (!rw && !rh) { 145 | const wf = width / w; // TODO: should this respect initial cursor offset? 146 | const hf = (height - ch) / h; // TODO: fix offset issues from float cell height 147 | const f = Math.min(wf, hf); 148 | return f < 1 ? [w * f, h * f] : [w, h]; 149 | } 150 | return !rw 151 | ? [w * rh / h, rh] 152 | : this._header.preserveAspectRatio || !rw || !rh 153 | ? [rw, h * rw / w] : [rw, rh]; 154 | } 155 | 156 | private _dim(s: string, total: number, cdim: number): number { 157 | if (s === 'auto') return 0; 158 | if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100; 159 | if (s.endsWith('px')) return parseInt(s.slice(0, -2)); 160 | return parseInt(s) * cdim; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /overwrite/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | xterm.js demo 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

xterm.js: A terminal for the web

18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 |

Options

31 |

These options can be set in the Terminal constructor or by using the Terminal.options property.

32 |
33 |
34 |
35 |

Addons

36 |

Addons can be loaded and unloaded on a particular terminal to extend its functionality.

37 |
38 |

Addons Control

39 |

SearchAddon

40 |
41 | 42 | 43 |
Results:
44 | 45 | 46 | 47 | 48 |
49 |

SerializeAddon

50 |
51 | 52 | 53 |
54 | 55 | 56 | 57 |
58 |
59 |

Image Addon

60 |
61 | image addon settings 62 |
63 |
64 | 65 |

66 | 70 |
71 |
72 |
73 |
74 |

Style

75 |
76 | 77 | 78 |
79 |
80 |
81 |

Test

82 |
83 |
84 |
Lifecycle
85 |
86 |
87 | 88 |
Performance
89 |
90 | 91 |
Styles
92 |
93 |
94 |
95 |
96 |
97 | 98 |
Decorations
99 |
100 |
101 | 102 |
Sixel Test
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | 112 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /fixture/textcursor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test cursor row, column placement after sixel image is sent. 4 | 5 | # After a sixel image is displayed, the text cursor is moved to the 6 | # row of the last sixel cursor position, but the column stays the same 7 | # as it was before the sixel image was sent. 8 | # 9 | # This can be thought of as sixel images always ending with an 10 | # implicit Graphics Carriage Return (`$`). 11 | 12 | # ADDENDUM: It is not as simple as I thought. When a row of sixels 13 | # straddles two rows of text, the text cursor can be left on the upper row. 14 | # It seems up to three lines of pixels may be beneath any words printed. 15 | # 16 | # The rule for when this happens is not obvious to me, but can be seen 17 | # with images of height: 21, 22, 23, 24, 41, 42, 81, 82, 83, 84... 18 | # 19 | # My guess: 20 | # for a sixel image of height h, let a=(h-1)%6 and b=(h-1)%20, 21 | # then, the text will overlap the image when a>b. 22 | # 23 | # If that is the case, then the entire list of heights for which this 24 | # will happen on the VT340's 480 pixel high screen is: 25 | # 26 | # 21 22 23 24 41 42 81 82 83 84 27 | # 101 102 141 142 143 144 161 162 28 | # 201 202 203 204 221 222 261 262 263 264 281 282 29 | # 321 322 323 324 341 342 381 382 383 384 30 | # 401 402 441 442 443 444 461 462 31 | # 32 | # Note that there are 48 entries, so that means there's a 10% chance 33 | # if heights are chosen randomly from 1 to 480. However, if one were 34 | # to always pick heights which are a multiple of the character cell 35 | # height (20px), then the chances are 0% as there are no problematic 36 | # heights divisible by 20. 37 | 38 | 39 | # Sixel images often do *not* end with a `-` (Graphics New Line = GNL) 40 | # which sends the sixel cursor down 6 pixels. Any text printed next 41 | # will potentially overlap the last row of sixels! 42 | 43 | # I am not yet positive, but I believe that, in general, applications 44 | # should send sixel images without a GNL but then send `^J`, a text 45 | # newline (NL), before displaying more text or graphics. 46 | 47 | # IMPORTANT: sometimes neither a graphics nor a text newline is wanted. 48 | # For example, if an image is full screen, either newline would cause 49 | # the top line to scroll off the screen. 50 | 51 | # | Text cursor column | Text cursor row 52 | # --------|--------------------|------------------------------------- 53 | # !GNL !NL| Unchanged | Overlapping last line of graphics 54 | # !GNL NL| Column=1 | First line immediately after graphic (usually) 55 | # GNL !NL| Unchanged | _Sometimes_ overlapping graphics 56 | # GNL NL| Column=1 | First *or* second line after graphic 57 | 58 | 59 | CSI=$'\e[' # Control Sequence Introducer 60 | DCS=$'\eP' # Device Control String 61 | ST=$'\e\\' # String Terminator 62 | 63 | set_cursor_pos() { 64 | # Home, top left is row 1, col 1. 65 | local row=$1 col=$2 66 | echo -n ${CSI}${row}';'${col}'H' 67 | } 68 | 69 | reset_palette() { 70 | # Send DECRSTS to load colors from a Color Table Report 71 | echo -n ${DCS}'2$p' 72 | 73 | echo -n "0;2;0;0;0/" # VT color #0 is black and BG text color 74 | 75 | echo -n "1;2;20;20;79/" # VT color #1 is blue 76 | echo -n "2;2;79;13;13/" # VT color #2 is red 77 | echo -n "3;2;20;79;20/" # VT color #3 is green 78 | 79 | echo -n "4;2;79;20;79/" # VT color #4 is magenta 80 | echo -n "5;2;20;79;79/" # VT color #5 is cyan 81 | echo -n "6;2;79;79;20/" # VT color #6 is yellow 82 | 83 | echo -n "7;2;46;46;46/" # VT color #7 is gray 50% and FG text color 84 | echo -n "8;2;26;26;26/" # VT color #8 is gray 25% 85 | 86 | echo -n "9;2;33;33;59/" # VT color #9 is pastel blue 87 | echo -n "10;2;59;26;26/" # VT color #10 is pastel red 88 | echo -n "11;2;33;59;33/" # VT color #11 is pastel green 89 | 90 | echo -n "12;2;59;33;59/" # VT color #12 is pastel magenta 91 | echo -n "13;2;33;59;59/" # VT color #13 is pastel cyan 92 | echo -n "14;2;59;59;33/" # VT color #14 is pastel yellow 93 | 94 | echo -n "15;2;79;79;79" # VT color #15 is gray 75% and BOLD text color 95 | 96 | echo -n ${ST} # String Terminator 97 | } 98 | 99 | # Generate square of size w with final graphics new line removed 100 | square() { 101 | # Given a color index number and (optionally) a size, row, and column, 102 | # draw a square with top left corner at (row, column) and of size×size px. 103 | # Default size 100×100px (10cols, 5 rows) 104 | 105 | local -i color=${1:-1} # Default is color index 1 (blue) 106 | local -i size=${2:-100} # Size in pixels (defaults to 100) 107 | local -i row=$3 column=$4 # If set to 0, cursor is not moved 108 | 109 | if [[ row -ne 0 && column -ne 0 ]]; then 110 | set_cursor_pos $row $column 111 | fi 112 | 113 | # Draw a square of the right color & size 114 | squaresize $color $size 115 | } 116 | 117 | squaresize() { 118 | # Helper for square() that uses convert to return a sixel square of 119 | # the right color ($1) and size ($2). 120 | 121 | # Similar to this but with variable size squares: 122 | # echo -n ${DCS}'0;0;0q"1;1;100;100#'${color}'!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100~-!100N'${ST} 123 | 124 | 125 | local color=${1:-1} # Default color index is 1 (blue) 126 | local size=${2:-100} # Default size is 100x100 127 | 128 | # Get a sixel string 129 | local sq=$(convert -geometry ${size}x${size} xc:black sixel:-) 130 | 131 | # Remove ImageMagick's extraneous Graphic New Line at end of image. 132 | sq=${sq%-??}$'\e\\' 133 | 134 | # VT340s always used the same color register for the first sixel 135 | # color defined no matter what number it was assigned. That means, 136 | # each time we send a new sixel image, the previous one's color 137 | # palette gets changed. We don't want squares of all the same 138 | # color, so remove the color definition and just use the defaults. 139 | sq=${sq/\#0;2;0;0;0/} 140 | 141 | # And finally, switch to the proper index for the color we want. 142 | echo -n ${sq//\#0/#${color}} 143 | } 144 | 145 | squaregnl() { 146 | # Same as square(), but sends a graphics newline at the end of the sixels. 147 | # (Sticks a `-` before the String Terminator, "Esc \") 148 | sq=$(square "${@}") 149 | echo -n ${sq%??}$'-\e\\' 150 | } 151 | 152 | 153 | main() { 154 | clear 155 | reset_palette 156 | show_labels 157 | neither_graphic_nor_text 96 4 4 31 # size, color, row, column 158 | text_newline_only 96 9 4 1 159 | text_newline_only 84 9 4 14 160 | graphics_new_line_only 100 1 4 51 161 | graphics_new_line_only 96 1 4 64 162 | set_cursor_pos 1000 1 163 | } 164 | 165 | 166 | neither_graphic_nor_text() { 167 | # Typically sixel images should not end with a Graphics New Line (GNL) 168 | # However, if a text newline isn't sent, there will be overlap. 169 | 170 | local -i size color row column 171 | read size color row column <<<"$@" 172 | 173 | set_cursor_pos $((row++)) $column 174 | echo -n "Height $size" 175 | set_cursor_pos $((row++)) $column 176 | 177 | # Three squares sent as separate sixel images, indented +1 178 | for i in {1..3}; do 179 | square $((color++)) $size 180 | tput cuf 1 181 | done 182 | 183 | echo -n "overlap?" 184 | } 185 | 186 | 187 | text_newline_only() { 188 | # USING A TEXT NEWLINE (NL) after an sixel image that does NOT 189 | # have GNL is probably the best way to be on the text line 190 | # immediately below the image. However, the text will still 191 | # occasionally overlap the last four rows of pixels. 192 | 193 | # Also, if multiple images are intended to be shown, there will 194 | # usually be a gap between them when using a text newline. 195 | 196 | # Overlap happens because the height of a text cell is 20 pixels 197 | # and the height of a sixel is 6. 198 | # for (h=0; h<480; h++) if ((h-1)%6 > (h-1)%20 ) { h } 199 | 200 | # Let the pixel position of the top of the graphics cursor be 'Yg' 201 | # and let the pixel position of the top of the corresponding cell 202 | # of text which the text cursor will be placed on be 'Yt'. Note 203 | # that Yg is evenly divisible by 6 and Yt, by 20. Taking the 204 | # remainder, r, after dividing Yg by 20 tells us how many pixels 205 | # down into a row of text the last line of sixels started. When 206 | # r==0, the sixels started at the top of the text row. 207 | 208 | # When r = 14, the sixels covered the bottom six pixels on the row 209 | # of text. When 14 < r < 20, the sixel line impinged by r - 14 210 | # pixels into the text row below and there is a chance the next 211 | # text printed will overlap. 212 | 213 | 214 | local -i size color row column 215 | read size color row column <<<"$@" 216 | 217 | local -i offset 218 | offset=$((column-1)) 219 | 220 | set_cursor_pos $row 1 221 | 222 | if ((offset)); then tput cuf $((offset)); fi 223 | echo "Height $size" 224 | 225 | # Three squares, separated by text new lines and indented +1 226 | for i in {1..3}; do 227 | if ((offset)); then tput cuf $offset; fi 228 | square $((color++)) $size 229 | offset=offset+1 230 | echo 231 | done 232 | 233 | tput cuf $((offset)) 234 | echo -n "overlap?" 235 | } 236 | 237 | graphics_new_line_only() { 238 | # However, some sixel images end with a `-`, a Graphics New Line. 239 | # This can be useful for writing another image starting at the same 240 | # column without having to reposition the cursor. 241 | # 242 | # However, this runs the risk of having occasional overlap. 243 | 244 | local -i size color row column 245 | read size color row column <<<"$@" 246 | 247 | set_cursor_pos $((row++)) $column 248 | echo -n "Height $size" 249 | set_cursor_pos $((row++)) $column 250 | 251 | # Three squares, separated by graphics new lines and indented +1 252 | for i in {1..3}; do 253 | squaregnl $((color++)) $size 254 | tput cuf 1 255 | done 256 | 257 | echo -n "overlap?" 258 | } 259 | 260 | 261 | show_labels() { 262 | set_cursor_pos 1 10 263 | echo -n "Should sixel images include a GNL ('-') at the end?" 264 | 265 | set_cursor_pos 3 29 266 | echo -n "Neither NL nor GNL" 267 | set_cursor_pos 3 1 268 | echo -n "Text New Line only" 269 | set_cursor_pos 3 51 270 | echo -n "Graphics New Line only" 271 | 272 | set_cursor_pos 22 29 273 | echo -n "Always overlaps" # Neither NL nor GNL 274 | 275 | set_cursor_pos 22 3 276 | echo -n "Overlaps a little" # NL only 277 | set_cursor_pos 23 3 278 | echo -n " Gaps a little" # NL only 279 | 280 | set_cursor_pos 22 54 281 | echo -n "Overlaps badly" # GNL only 282 | set_cursor_pos 23 54 283 | echo -n "Never gaps" # GNL only 284 | } 285 | 286 | 287 | main 288 | 289 | -------------------------------------------------------------------------------- /src/ImageAddon.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { IIPHandler } from './IIPHandler'; 7 | import { ITerminalAddon, IDisposable } from 'xterm'; 8 | import { ImageRenderer } from './ImageRenderer'; 9 | import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; 10 | import { SixelHandler } from './SixelHandler'; 11 | import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; 12 | 13 | 14 | // default values of addon ctor options 15 | const DEFAULT_OPTIONS: IImageAddonOptions = { 16 | enableSizeReports: true, 17 | pixelLimit: 16777216, // limit to 4096 * 4096 pixels 18 | sixelSupport: true, 19 | sixelScrolling: true, 20 | sixelPaletteLimit: 256, 21 | sixelSizeLimit: 25000000, 22 | storageLimit: 128, 23 | showPlaceholder: true, 24 | iipSupport: true, 25 | iipSizeLimit: 20000000 26 | }; 27 | 28 | // max palette size supported by the sixel lib (compile time setting) 29 | const MAX_SIXEL_PALETTE_SIZE = 4096; 30 | 31 | // definitions for _xtermGraphicsAttributes sequence 32 | const enum GaItem { 33 | COLORS = 1, 34 | SIXEL_GEO = 2, 35 | REGIS_GEO = 3 36 | } 37 | const enum GaAction { 38 | READ = 1, 39 | SET_DEFAULT = 2, 40 | SET = 3, 41 | READ_MAX = 4 42 | } 43 | const enum GaStatus { 44 | SUCCESS = 0, 45 | ITEM_ERROR = 1, 46 | ACTION_ERROR = 2, 47 | FAILURE = 3 48 | } 49 | 50 | 51 | export class ImageAddon implements ITerminalAddon { 52 | private _opts: IImageAddonOptions; 53 | private _defaultOpts: IImageAddonOptions; 54 | private _storage: ImageStorage | undefined; 55 | private _renderer: ImageRenderer | undefined; 56 | private _disposables: IDisposable[] = []; 57 | private _terminal: ITerminalExt | undefined; 58 | private _handlers: Map = new Map(); 59 | 60 | constructor(opts?: Partial) { 61 | this._opts = Object.assign({}, DEFAULT_OPTIONS, opts); 62 | this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts); 63 | } 64 | 65 | public dispose(): void { 66 | for (const obj of this._disposables) { 67 | obj.dispose(); 68 | } 69 | this._disposables.length = 0; 70 | this._handlers.clear(); 71 | } 72 | 73 | private _disposeLater(...args: IDisposable[]): void { 74 | for (const obj of args) { 75 | this._disposables.push(obj); 76 | } 77 | } 78 | 79 | public activate(terminal: ITerminalExt): void { 80 | this._terminal = terminal; 81 | 82 | // internal data structures 83 | this._renderer = new ImageRenderer(terminal); 84 | this._storage = new ImageStorage(terminal, this._renderer, this._opts); 85 | 86 | // enable size reports 87 | if (this._opts.enableSizeReports) { 88 | // const windowOptions = terminal.getOption('windowOptions'); 89 | // windowOptions.getWinSizePixels = true; 90 | // windowOptions.getCellSizePixels = true; 91 | // windowOptions.getWinSizeChars = true; 92 | // terminal.setOption('windowOptions', windowOptions); 93 | const windowOps = terminal.options.windowOptions || {}; 94 | windowOps.getWinSizePixels = true; 95 | windowOps.getCellSizePixels = true; 96 | windowOps.getWinSizeChars = true; 97 | terminal.options.windowOptions = windowOps; 98 | } 99 | 100 | this._disposeLater( 101 | this._renderer, 102 | this._storage, 103 | 104 | // DECSET/DECRST/DA1/XTSMGRAPHICS handlers 105 | terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)), 106 | terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)), 107 | terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)), 108 | terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)), 109 | 110 | // render hook 111 | terminal.onRender(range => this._storage?.render(range)), 112 | 113 | /** 114 | * reset handlers covered: 115 | * - DECSTR 116 | * - RIS 117 | * - Terminal.reset() 118 | */ 119 | terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()), 120 | terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()), 121 | terminal._core._inputHandler.onRequestReset(() => this.reset()), 122 | 123 | // wipe canvas and delete alternate images on buffer switch 124 | terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()), 125 | 126 | // extend images to the right on resize 127 | terminal.onResize(metrics => this._storage?.viewportResize(metrics)) 128 | ); 129 | 130 | // SIXEL handler 131 | if (this._opts.sixelSupport) { 132 | const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal); 133 | this._handlers.set('sixel', sixelHandler); 134 | this._disposeLater( 135 | terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler) 136 | ); 137 | } 138 | 139 | // iTerm IIP handler 140 | if (this._opts.iipSupport) { 141 | const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal); 142 | this._handlers.set('iip', iipHandler); 143 | this._disposeLater( 144 | terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler) 145 | ); 146 | } 147 | } 148 | 149 | // Note: storageLimit is skipped here to not intoduce a surprising side effect. 150 | public reset(): boolean { 151 | // reset options customizable by sequences to defaults 152 | this._opts.sixelScrolling = this._defaultOpts.sixelScrolling; 153 | this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit; 154 | // also clear image storage 155 | this._storage?.reset(); 156 | // reset protocol handlers 157 | for (const handler of this._handlers.values()) { 158 | handler.reset(); 159 | } 160 | return false; 161 | } 162 | 163 | public get storageLimit(): number { 164 | return this._storage?.getLimit() || -1; 165 | } 166 | 167 | public set storageLimit(limit: number) { 168 | this._storage?.setLimit(limit); 169 | this._opts.storageLimit = limit; 170 | } 171 | 172 | public get storageUsage(): number { 173 | if (this._storage) { 174 | return this._storage.getUsage(); 175 | } 176 | return -1; 177 | } 178 | 179 | public get showPlaceholder(): boolean { 180 | return this._opts.showPlaceholder; 181 | } 182 | 183 | public set showPlaceholder(value: boolean) { 184 | this._opts.showPlaceholder = value; 185 | this._renderer?.showPlaceholder(value); 186 | } 187 | 188 | public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { 189 | return this._storage?.getImageAtBufferCell(x, y); 190 | } 191 | 192 | public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { 193 | return this._storage?.extractTileAtBufferCell(x, y); 194 | } 195 | 196 | private _report(s: string): void { 197 | this._terminal?._core.coreService.triggerDataEvent(s); 198 | } 199 | 200 | private _decset(params: (number | number[])[]): boolean { 201 | for (let i = 0; i < params.length; ++i) { 202 | switch (params[i]) { 203 | case 80: 204 | this._opts.sixelScrolling = false; 205 | break; 206 | } 207 | } 208 | return false; 209 | } 210 | 211 | private _decrst(params: (number | number[])[]): boolean { 212 | for (let i = 0; i < params.length; ++i) { 213 | switch (params[i]) { 214 | case 80: 215 | this._opts.sixelScrolling = true; 216 | break; 217 | } 218 | } 219 | return false; 220 | } 221 | 222 | // overload DA to return something more appropriate 223 | private _da1(params: (number | number[])[]): boolean { 224 | if (params[0]) { 225 | return true; 226 | } 227 | // reported features: 228 | // 62 - VT220 229 | // 4 - SIXEL support 230 | // 9 - charsets 231 | // 22 - ANSI colors 232 | if (this._opts.sixelSupport) { 233 | this._report(`\x1b[?62;4;9;22c`); 234 | return true; 235 | } 236 | return false; 237 | } 238 | 239 | /** 240 | * Implementation of xterm's graphics attribute sequence. 241 | * 242 | * Supported features: 243 | * - read/change palette limits (max 4096 by sixel lib) 244 | * - read SIXEL canvas geometry (reports current window canvas or 245 | * squared pixelLimit if canvas > pixel limit) 246 | * 247 | * Everything else is deactivated. 248 | */ 249 | private _xtermGraphicsAttributes(params: (number | number[])[]): boolean { 250 | if (params.length < 2) { 251 | return true; 252 | } 253 | if (params[0] === GaItem.COLORS) { 254 | switch (params[1]) { 255 | case GaAction.READ: 256 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); 257 | return true; 258 | case GaAction.SET_DEFAULT: 259 | this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit; 260 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); 261 | // also reset protocol handlers for now 262 | for (const handler of this._handlers.values()) { 263 | handler.reset(); 264 | } 265 | return true; 266 | case GaAction.SET: 267 | if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) { 268 | this._opts.sixelPaletteLimit = params[2]; 269 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); 270 | } else { 271 | this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); 272 | } 273 | return true; 274 | case GaAction.READ_MAX: 275 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`); 276 | return true; 277 | default: 278 | this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); 279 | return true; 280 | } 281 | } 282 | if (params[0] === GaItem.SIXEL_GEO) { 283 | switch (params[1]) { 284 | // we only implement read and read_max here 285 | case GaAction.READ: 286 | let width = this._renderer?.dimensions?.css.canvas.width; 287 | let height = this._renderer?.dimensions?.css.canvas.height; 288 | if (!width || !height) { 289 | // for some reason we have no working image renderer 290 | // --> fallback to default cell size 291 | const cellSize = CELL_SIZE_DEFAULT; 292 | width = (this._terminal?.cols || 80) * cellSize.width; 293 | height = (this._terminal?.rows || 24) * cellSize.height; 294 | } 295 | if (width * height < this._opts.pixelLimit) { 296 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`); 297 | } else { 298 | // if we overflow pixelLimit report that squared instead 299 | const x = Math.floor(Math.sqrt(this._opts.pixelLimit)); 300 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`); 301 | } 302 | return true; 303 | case GaAction.READ_MAX: 304 | // read_max returns pixelLimit as square area 305 | const x = Math.floor(Math.sqrt(this._opts.pixelLimit)); 306 | this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`); 307 | return true; 308 | default: 309 | this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); 310 | return true; 311 | } 312 | } 313 | // exit with error on ReGIS or any other requests 314 | this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`); 315 | return true; 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /test/ImageAddon.api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { assert } from 'chai'; 7 | import { openTerminal, launchBrowser } from '../../../out-test/api/TestUtils'; 8 | import { Browser, Page } from 'playwright'; 9 | import { IImageAddonOptions } from '../src/Types'; 10 | import { FINALIZER, introducer, sixelEncode } from 'sixel'; 11 | import { readFileSync } from 'fs'; 12 | 13 | const APP = 'http://127.0.0.1:3001/test'; 14 | 15 | let browser: Browser; 16 | let page: Page; 17 | const width = 800; 18 | const height = 600; 19 | 20 | // eslint-disable-next-line 21 | declare const ImageAddon: { 22 | new(options?: Partial): any; 23 | }; 24 | 25 | interface ITestData { 26 | width: number; 27 | height: number; 28 | bytes: Uint8Array; 29 | palette: number[]; 30 | sixel: string; 31 | } 32 | 33 | interface IDimensions { 34 | cellWidth: number; 35 | cellHeight: number; 36 | width: number; 37 | height: number; 38 | } 39 | 40 | // image: 640 x 80, 512 color 41 | const TESTDATA: ITestData = (() => { 42 | const data8 = readFileSync('./addons/xterm-addon-image/fixture/palette.blob'); 43 | const data32 = new Uint32Array(data8.buffer); 44 | const palette = new Set(); 45 | for (let i = 0; i < data32.length; ++i) palette.add(data32[i]); 46 | const sixel = sixelEncode(data8, 640, 80, [...palette]); 47 | return { 48 | width: 640, 49 | height: 80, 50 | bytes: data8, 51 | palette: [...palette], 52 | sixel 53 | }; 54 | })(); 55 | const SIXEL_SEQ_0 = introducer(0) + TESTDATA.sixel + FINALIZER; 56 | // const SIXEL_SEQ_1 = introducer(1) + TESTDATA.sixel + FINALIZER; 57 | // const SIXEL_SEQ_2 = introducer(2) + TESTDATA.sixel + FINALIZER; 58 | 59 | // NOTE: the data is loaded as string for easier transport through playwright 60 | const TESTDATA_IIP: [string, [number, number]][] = [ 61 | [readFileSync('./addons/xterm-addon-image/fixture/iip/palette.iip', { encoding: 'utf-8' }), [640, 80]], 62 | [readFileSync('./addons/xterm-addon-image/fixture/iip/spinfox.iip', { encoding: 'utf-8' }), [148, 148]], 63 | [readFileSync('./addons/xterm-addon-image/fixture/iip/w3c_gif.iip', { encoding: 'utf-8' }), [72, 48]], 64 | [readFileSync('./addons/xterm-addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]], 65 | [readFileSync('./addons/xterm-addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]] 66 | ]; 67 | 68 | describe.only('ImageAddon', () => { 69 | before(async () => { 70 | browser = await launchBrowser(); 71 | page = await (await browser.newContext()).newPage(); 72 | await page.setViewportSize({ width, height }); 73 | }); 74 | 75 | after(async () => { 76 | await browser.close(); 77 | }); 78 | 79 | beforeEach(async () => { 80 | await page.goto(APP); 81 | await openTerminal(page); 82 | await page.evaluate(opts => { 83 | (window as any).imageAddon = new ImageAddon(opts.opts); 84 | (window as any).term.loadAddon((window as any).imageAddon); 85 | }, { opts: { sixelPaletteLimit: 512 } }); 86 | }); 87 | 88 | it('test for private accessors', async () => { 89 | // terminal privates 90 | const accessors = [ 91 | '_core', 92 | '_core._renderService', 93 | '_core._inputHandler', 94 | '_core._inputHandler._parser', 95 | '_core._inputHandler._curAttrData', 96 | '_core._inputHandler._dirtyRowTracker', 97 | '_core._themeService.colors', 98 | '_core._coreBrowserService' 99 | ]; 100 | for (const prop of accessors) { 101 | assert.equal( 102 | await page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'), 103 | true, `problem at ${prop}` 104 | ); 105 | } 106 | // bufferline privates 107 | assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true); 108 | assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true); 109 | // inputhandler privates 110 | assert.equal(await page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), 'AttributeData'); 111 | assert.equal(await page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser'); 112 | }); 113 | 114 | describe('ctor options', () => { 115 | it('empty settings should load defaults', async () => { 116 | const DEFAULT_OPTIONS: IImageAddonOptions = { 117 | enableSizeReports: true, 118 | pixelLimit: 16777216, 119 | sixelSupport: true, 120 | sixelScrolling: true, 121 | sixelPaletteLimit: 512, // set to 512 to get example image working 122 | sixelSizeLimit: 25000000, 123 | storageLimit: 128, 124 | showPlaceholder: true, 125 | iipSupport: true, 126 | iipSizeLimit: 20000000 127 | }; 128 | assert.deepEqual(await page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS); 129 | }); 130 | it('custom settings should overload defaults', async () => { 131 | const customSettings: IImageAddonOptions = { 132 | enableSizeReports: false, 133 | pixelLimit: 5, 134 | sixelSupport: false, 135 | sixelScrolling: false, 136 | sixelPaletteLimit: 1024, 137 | sixelSizeLimit: 1000, 138 | storageLimit: 10, 139 | showPlaceholder: false, 140 | iipSupport: false, 141 | iipSizeLimit: 1000 142 | }; 143 | await page.evaluate(opts => { 144 | (window as any).imageAddonCustom = new ImageAddon(opts.opts); 145 | (window as any).term.loadAddon((window as any).imageAddonCustom); 146 | }, { opts: customSettings }); 147 | assert.deepEqual(await page.evaluate(`window.imageAddonCustom._opts`), customSettings); 148 | }); 149 | }); 150 | 151 | describe('scrolling & cursor modes', () => { 152 | it('testdata default (scrolling with VT240 cursor pos)', async () => { 153 | const dim = await getDimensions(); 154 | await writeToTerminal(SIXEL_SEQ_0); 155 | assert.deepEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]); 156 | // moved to right by 10 cells 157 | await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0); 158 | assert.deepEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]); 159 | }); 160 | it('write testdata noScrolling', async () => { 161 | await writeToTerminal('\x1b[?80h' + SIXEL_SEQ_0); 162 | assert.deepEqual(await getCursor(), [0, 0]); 163 | // second draw does not change anything 164 | await writeToTerminal(SIXEL_SEQ_0); 165 | assert.deepEqual(await getCursor(), [0, 0]); 166 | }); 167 | it('testdata cursor always at VT240 pos', async () => { 168 | const dim = await getDimensions(); 169 | // offset 0 170 | await writeToTerminal(SIXEL_SEQ_0); 171 | assert.deepEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]); 172 | // moved to right by 10 cells 173 | await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0); 174 | assert.deepEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]); 175 | // moved by 30 cells (+10 prev) 176 | await writeToTerminal('#'.repeat(30) + SIXEL_SEQ_0); 177 | assert.deepEqual(await getCursor(), [10 + 30, Math.floor(TESTDATA.height/dim.cellHeight) * 3]); 178 | }); 179 | }); 180 | 181 | describe('image lifecycle & eviction', () => { 182 | it('delete image once scrolled off', async () => { 183 | await writeToTerminal(SIXEL_SEQ_0); 184 | assert.equal(await getImageStorageLength(), 1); 185 | // scroll to scrollback + rows - 1 186 | await page.evaluate( 187 | scrollback => new Promise(res => (window as any).term.write('\n'.repeat(scrollback), res)), 188 | (await getScrollbackPlusRows() - 1) 189 | ); 190 | assert.equal(await getImageStorageLength(), 1); 191 | // scroll one further should delete the image 192 | await page.evaluate(() => new Promise(res => (window as any).term.write('\n', res))); 193 | assert.equal(await getImageStorageLength(), 0); 194 | }); 195 | it('get storageUsage', async () => { 196 | assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); 197 | await writeToTerminal(SIXEL_SEQ_0); 198 | assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05); 199 | }); 200 | it('get/set storageLimit', async () => { 201 | assert.equal(await page.evaluate('imageAddon.storageLimit'), 128); 202 | assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1); 203 | assert.equal(await page.evaluate('imageAddon.storageLimit'), 1); 204 | }); 205 | it('remove images by storage limit pressure', async () => { 206 | assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1); 207 | // never go beyond storage limit 208 | await writeToTerminal(SIXEL_SEQ_0); 209 | await writeToTerminal(SIXEL_SEQ_0); 210 | await writeToTerminal(SIXEL_SEQ_0); 211 | await writeToTerminal(SIXEL_SEQ_0); 212 | const usage = await page.evaluate('imageAddon.storageUsage'); 213 | await writeToTerminal(SIXEL_SEQ_0); 214 | await writeToTerminal(SIXEL_SEQ_0); 215 | await writeToTerminal(SIXEL_SEQ_0); 216 | await writeToTerminal(SIXEL_SEQ_0); 217 | assert.equal(await page.evaluate('imageAddon.storageUsage'), usage); 218 | assert.equal(usage as number < 1, true); 219 | }); 220 | it('set storageLimit removes images synchronously', async () => { 221 | await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); 222 | const usage: number = await page.evaluate('imageAddon.storageUsage'); 223 | const newUsage: number = await page.evaluate('imageAddon.storageLimit = 1; imageAddon.storageUsage'); 224 | assert.equal(newUsage < usage, true); 225 | assert.equal(newUsage < 1, true); 226 | }); 227 | it('clear alternate images on buffer change', async () => { 228 | assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); 229 | await writeToTerminal('\x1b[?1049h' + SIXEL_SEQ_0); 230 | assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05); 231 | await writeToTerminal('\x1b[?1049l'); 232 | assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); 233 | }); 234 | it('evict tiles by in-place overwrites (only full overwrite tested)', async () => { 235 | await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); 236 | const usage = await page.evaluate('imageAddon.storageUsage'); 237 | await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); 238 | await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); 239 | await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); 240 | assert.equal(await page.evaluate('imageAddon.storageUsage'), usage); 241 | }); 242 | it('manual eviction on alternate buffer must not miss images', async () => { 243 | await writeToTerminal('\x1b[?1049h'); 244 | await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); 245 | const usage: number = await page.evaluate('imageAddon.storageUsage'); 246 | await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); 247 | await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); 248 | const newUsage: number = await page.evaluate('imageAddon.storageUsage'); 249 | assert.equal(newUsage, usage); 250 | }); 251 | }); 252 | 253 | describe('IIP support - testimages', () => { 254 | it('palette.png', async () => { 255 | await writeToTerminal(TESTDATA_IIP[0][0]); 256 | assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[0][1]); 257 | }); 258 | it('spinfox.png', async () => { 259 | await writeToTerminal(TESTDATA_IIP[1][0]); 260 | assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[1][1]); 261 | }); 262 | it('w3c gif', async () => { 263 | await writeToTerminal(TESTDATA_IIP[2][0]); 264 | assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[2][1]); 265 | }); 266 | it('w3c jpeg', async () => { 267 | await writeToTerminal(TESTDATA_IIP[3][0]); 268 | assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[3][1]); 269 | }); 270 | it('w3c png', async () => { 271 | await writeToTerminal(TESTDATA_IIP[4][0]); 272 | assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); 273 | }); 274 | }); 275 | }); 276 | 277 | /** 278 | * terminal access helpers. 279 | */ 280 | async function getDimensions(): Promise { 281 | const dimensions: any = await page.evaluate(`term._core._renderService.dimensions`); 282 | return { 283 | cellWidth: Math.round(dimensions.css.cell.width), 284 | cellHeight: Math.round(dimensions.css.cell.height), 285 | width: Math.round(dimensions.css.canvas.width), 286 | height: Math.round(dimensions.css.canvas.height) 287 | }; 288 | } 289 | 290 | async function getCursor(): Promise<[number, number]> { 291 | return page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]'); 292 | } 293 | 294 | async function getImageStorageLength(): Promise { 295 | return page.evaluate('window.imageAddon._storage._images.size'); 296 | } 297 | 298 | async function getScrollbackPlusRows(): Promise { 299 | return page.evaluate('window.term.options.scrollback + window.term.rows'); 300 | } 301 | 302 | async function writeToTerminal(d: string): Promise { 303 | return page.evaluate(data => new Promise(res => (window as any).term.write(data, res)), d); 304 | } 305 | 306 | async function getOrigSize(id: number): Promise<[number, number]> { 307 | return page.evaluate(`[ 308 | window.imageAddon._storage._images.get(${id}).orig.width, 309 | window.imageAddon._storage._images.get(${id}).orig.height 310 | ]`); 311 | } 312 | -------------------------------------------------------------------------------- /src/base64.wasm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { InWasm, IWasmInstance, OutputMode, OutputType } from 'inwasm'; 7 | 8 | 9 | // memory addresses in uint32 10 | const enum P32 { 11 | D0 = 256, 12 | D1 = 512, 13 | D2 = 768, 14 | D3 = 1024, 15 | STATE = 1280, 16 | STATE_WP = 1280, 17 | STATE_SP = 1281, 18 | STATE_DP = 1282, 19 | STATE_ESIZE = 1283, 20 | STATE_BSIZE = 1284, 21 | STATE_DATA = 1288 // 16 aligned 22 | } 23 | 24 | /** 25 | * wasm base64 decoder. 26 | */ 27 | const wasmDecode = InWasm({ 28 | name: 'decode', 29 | type: OutputType.INSTANCE, 30 | mode: OutputMode.SYNC, 31 | srctype: 'Clang-C', 32 | imports: { 33 | env: { memory: new WebAssembly.Memory({ initial: 1 }) } 34 | }, 35 | exports: { 36 | dec: () => 0, 37 | end: () => 0 38 | }, 39 | compile: { 40 | switches: ['-Wl,-z,stack-size=0', '-Wl,--stack-first'] 41 | }, 42 | code: ` 43 | typedef struct { 44 | unsigned int wp; 45 | unsigned int sp; 46 | unsigned int dp; 47 | unsigned int e_size; 48 | unsigned int b_size; 49 | unsigned int dummy[3]; 50 | unsigned char data[0]; 51 | } State; 52 | 53 | unsigned int *D0 = (unsigned int *) ${P32.D0*4}; 54 | unsigned int *D1 = (unsigned int *) ${P32.D1*4}; 55 | unsigned int *D2 = (unsigned int *) ${P32.D2*4}; 56 | unsigned int *D3 = (unsigned int *) ${P32.D3*4}; 57 | State *state = (State *) ${P32.STATE*4}; 58 | 59 | __attribute__((noinline)) int dec() { 60 | unsigned int nsp = (state->wp - 1) & ~3; 61 | unsigned char *src = state->data + state->sp; 62 | unsigned char *end = state->data + nsp; 63 | unsigned char *dst = state->data + state->dp; 64 | unsigned int accu; 65 | 66 | while (src < end) { 67 | if ((accu = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]]) >> 24) return 1; 68 | *((unsigned int *) dst) = accu; 69 | dst += 3; 70 | src += 4; 71 | } 72 | state->sp = nsp; 73 | state->dp = dst - state->data; 74 | return 0; 75 | } 76 | 77 | int end() { 78 | int rem = state->wp - state->sp; 79 | if (rem > 4 && dec()) return 1; 80 | rem = state->wp - state->sp; 81 | if (rem < 2) return 1; 82 | 83 | unsigned char *src = state->data + state->sp; 84 | unsigned int accu = D0[src[0]] | D1[src[1]]; 85 | int dp = 1; 86 | if (rem > 2 && src[2] != 61) { 87 | accu |= D2[src[2]]; 88 | dp++; 89 | } 90 | if (rem == 4 && src[3] != 61) { 91 | accu |= D3[src[3]]; 92 | dp++; 93 | } 94 | if (accu >> 24) return 1; 95 | *((unsigned int *) (state->data + state->dp)) = accu; 96 | state->dp += dp; 97 | return state->dp != state->b_size; 98 | } 99 | ` 100 | }); 101 | 102 | // SIMD version - commented out for now due to missing Safari support 103 | // const wasmDecode = InWasm({ 104 | // name: 'decode', 105 | // type: OutputType.INSTANCE, 106 | // mode: OutputMode.SYNC, 107 | // srctype: 'Clang-C', 108 | // imports: { 109 | // env: { memory: new WebAssembly.Memory({ initial: 1 }) } 110 | // }, 111 | // exports: { 112 | // dec: () => 0, 113 | // end: () => 0 114 | // }, 115 | // compile: { 116 | // switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first'] 117 | // }, 118 | // code: ` 119 | // #include 120 | // typedef struct { 121 | // unsigned int wp; 122 | // unsigned int sp; 123 | // unsigned int dp; 124 | // unsigned int e_size; 125 | // unsigned int b_size; 126 | // unsigned int dummy[3]; 127 | // unsigned char data[0]; 128 | // } State; 129 | // 130 | // unsigned int *D0 = (unsigned int *) ${P32.D0*4}; 131 | // unsigned int *D1 = (unsigned int *) ${P32.D1*4}; 132 | // unsigned int *D2 = (unsigned int *) ${P32.D2*4}; 133 | // unsigned int *D3 = (unsigned int *) ${P32.D3*4}; 134 | // State *state = (State *) ${P32.STATE*4}; 135 | // 136 | // #define packed_byte(x) wasm_i8x16_splat((char) x) 137 | // #define packed_dword(x) wasm_i32x4_splat(x) 138 | // #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask)) 139 | // 140 | // int dec4() { 141 | // unsigned int nsp = (state->wp - 1) & ~3; 142 | // unsigned char *src = state->data + state->sp; 143 | // unsigned char *end = state->data + nsp; 144 | // unsigned char *dst = state->data + state->dp; 145 | // unsigned int accu; 146 | // 147 | // while (src < end) { 148 | // if ((accu = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]]) >> 24) return 1; 149 | // *((unsigned int *) dst) = accu; 150 | // dst += 3; 151 | // src += 4; 152 | // } 153 | // state->sp = nsp; 154 | // state->dp = dst - state->data; 155 | // return 0; 156 | // } 157 | // 158 | // int dec() { 159 | // unsigned int nsp = (state->wp - 1) & ~15; 160 | // unsigned char *src = state->data + state->sp; 161 | // unsigned char *end = state->data + nsp; 162 | // unsigned char *dst = state->data + state->dp; 163 | // unsigned int accu; 164 | // 165 | // v128_t err = wasm_i8x16_splat(0); 166 | // 167 | // while (src < end) { 168 | // v128_t data = wasm_v128_load((v128_t *) src); 169 | // 170 | // // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb 171 | // const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f); 172 | // const char linv = 1; 173 | // const char hinv = 0; 174 | // 175 | // const v128_t lower_bound_LUT = wasm_i8x16_make( 176 | // /* 0 */ linv, /* 1 */ linv, /* 2 */ 0x2b, /* 3 */ 0x30, 177 | // /* 4 */ 0x41, /* 5 */ 0x50, /* 6 */ 0x61, /* 7 */ 0x70, 178 | // /* 8 */ linv, /* 9 */ linv, /* a */ linv, /* b */ linv, 179 | // /* c */ linv, /* d */ linv, /* e */ linv, /* f */ linv 180 | // ); 181 | // const v128_t upper_bound_LUT = wasm_i8x16_make( 182 | // /* 0 */ hinv, /* 1 */ hinv, /* 2 */ 0x2b, /* 3 */ 0x39, 183 | // /* 4 */ 0x4f, /* 5 */ 0x5a, /* 6 */ 0x6f, /* 7 */ 0x7a, 184 | // /* 8 */ hinv, /* 9 */ hinv, /* a */ hinv, /* b */ hinv, 185 | // /* c */ hinv, /* d */ hinv, /* e */ hinv, /* f */ hinv 186 | // ); 187 | // // the difference between the shift and lower bound 188 | // const v128_t shift_LUT = wasm_i8x16_make( 189 | // /* 0 */ 0x00, /* 1 */ 0x00, /* 2 */ 0x3e - 0x2b, /* 3 */ 0x34 - 0x30, 190 | // /* 4 */ 0x00 - 0x41, /* 5 */ 0x0f - 0x50, /* 6 */ 0x1a - 0x61, /* 7 */ 0x29 - 0x70, 191 | // /* 8 */ 0x00, /* 9 */ 0x00, /* a */ 0x00, /* b */ 0x00, 192 | // /* c */ 0x00, /* d */ 0x00, /* e */ 0x00, /* f */ 0x00 193 | // ); 194 | // 195 | // const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble); 196 | // const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble); 197 | // 198 | // const v128_t below = wasm_i8x16_lt(data, lower_bound); 199 | // const v128_t above = wasm_i8x16_gt(data, upper_bound); 200 | // const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f)); 201 | // 202 | // // in_range = not (below or above) or eq_2f 203 | // // outside = not in_range = below or above and not eq_2f (from deMorgan law) 204 | // const v128_t outside = wasm_v128_andnot(eq_2f, above | below); 205 | // err = wasm_v128_or(err, outside); 206 | // 207 | // const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble); 208 | // const v128_t t0 = wasm_i8x16_add(data, shift); 209 | // v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3))); 210 | // 211 | // // pack bytes 212 | // const v128_t ca = masked(v, 0x003f003f); 213 | // const v128_t db = masked(v, 0x3f003f00); 214 | // const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6)); 215 | // v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12)); 216 | // res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16)); 217 | // 218 | // wasm_v128_store((v128_t *) dst, res); 219 | // dst += 12; 220 | // src += 16; 221 | // } 222 | // 223 | // if (wasm_i8x16_bitmask(err) != 0) return 1; 224 | // 225 | // state->sp = nsp; 226 | // state->dp = dst - state->data; 227 | // return 0; 228 | // } 229 | // 230 | // int end() { 231 | // int rem = state->wp - state->sp; 232 | // if (rem > 4 && dec4()) return 1; 233 | // rem = state->wp - state->sp; 234 | // if (rem < 2) return 1; 235 | // 236 | // unsigned char *src = state->data + state->sp; 237 | // unsigned int accu = D0[src[0]] | D1[src[1]]; 238 | // int dp = 1; 239 | // if (rem > 2 && src[2] != 61) { 240 | // accu |= D2[src[2]]; 241 | // dp++; 242 | // } 243 | // if (rem == 4 && src[3] != 61) { 244 | // accu |= D3[src[3]]; 245 | // dp++; 246 | // } 247 | // if (accu >> 24) return 1; 248 | // *((unsigned int *) (state->data + state->dp)) = accu; 249 | // state->dp += dp; 250 | // return state->dp != state->b_size; 251 | // } 252 | // ` 253 | // }); 254 | 255 | // FIXME: currently broken in inwasm 256 | type ExtractDefinition = Type extends () => IWasmInstance ? X : never; 257 | type DecodeDefinition = ExtractDefinition; 258 | 259 | // base64 map 260 | const MAP = new Uint8Array( 261 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 262 | .split('') 263 | .map(el => el.charCodeAt(0)) 264 | ); 265 | 266 | // init decoder maps in LE order 267 | const D = new Uint32Array(1024); 268 | D.fill(0xFF000000); 269 | for (let i = 0; i < MAP.length; ++i) D[MAP[i]] = i << 2; 270 | for (let i = 0; i < MAP.length; ++i) D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8; 271 | for (let i = 0; i < MAP.length; ++i) D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16; 272 | for (let i = 0; i < MAP.length; ++i) D[768 + MAP[i]] = i << 16; 273 | 274 | const EMPTY = new Uint8Array(0); 275 | 276 | /** 277 | * base64 streamline inplace decoder. 278 | * 279 | * Features / assumptions: 280 | * - optimized uint32 read/write (only LE support!) 281 | * - lazy chunkwise decoding 282 | * - errors out on any non base64 chars (no support for NL formatted base64) 283 | * - decodes in wasm 284 | * - inplace decoding to save memory 285 | * - supports a keepSize for lazy memory release 286 | */ 287 | export class Base64Decoder { 288 | private _d!: Uint8Array; 289 | private _m32!: Uint32Array; 290 | private _inst!: IWasmInstance; 291 | private _mem!: WebAssembly.Memory; 292 | 293 | constructor(public keepSize: number) {} 294 | 295 | /** 296 | * Currently decoded bytes (borrowed). 297 | * Must be accessed before calling `release` or `init`. 298 | */ 299 | public get data8(): Uint8Array { 300 | return this._inst ? this._d.subarray(0, this._m32[P32.STATE_DP]) : EMPTY; 301 | } 302 | 303 | /** 304 | * Release memory conditionally based on `keepSize`. 305 | * If memory gets released, also the wasm instance will be freed and recreated on next `init`, 306 | * otherwise the instance will be reused. 307 | */ 308 | public release(): void { 309 | if (!this._inst) return; 310 | if (this._mem.buffer.byteLength > this.keepSize) { 311 | this._inst = this._m32 = this._d = this._mem = null!; 312 | } else { 313 | this._m32[P32.STATE_WP] = 0; 314 | this._m32[P32.STATE_SP] = 0; 315 | this._m32[P32.STATE_DP] = 0; 316 | } 317 | } 318 | 319 | /** 320 | * Initializes the decoder for new base64 data. 321 | * Must be called before doing any decoding attempts. 322 | * `size` is the amount of decoded bytes to be expected. 323 | * The method will either spawn a new wasm instance or grow 324 | * the needed memory of an existing instance. 325 | */ 326 | public init(size: number): void { 327 | let m = this._m32; 328 | const bytes = (Math.ceil(size / 3) + P32.STATE_DATA) * 4; 329 | if (!this._inst) { 330 | this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) }); 331 | this._inst = wasmDecode({ env: { memory: this._mem } }); 332 | m = new Uint32Array(this._mem.buffer, 0); 333 | m.set(D, P32.D0); 334 | this._d = new Uint8Array(this._mem.buffer, P32.STATE_DATA * 4); 335 | } else if (this._mem.buffer.byteLength < bytes) { 336 | this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536)); 337 | m = new Uint32Array(this._mem.buffer, 0); 338 | this._d = new Uint8Array(this._mem.buffer, P32.STATE_DATA * 4); 339 | } 340 | m[P32.STATE_BSIZE] = size; 341 | m[P32.STATE_ESIZE] = Math.ceil(size / 3) * 4; 342 | m[P32.STATE_WP] = 0; 343 | m[P32.STATE_SP] = 0; 344 | m[P32.STATE_DP] = 0; 345 | this._m32 = m; 346 | } 347 | 348 | /** 349 | * Put bytes in `data` from `start` to `end` (exclusive) into the decoder. 350 | * Also decodes base64 data inplace once the payload exceeds 2^17 bytes. 351 | * Returns 1 on error, else 0. 352 | */ 353 | public put(data: Uint8Array | Uint16Array | Uint32Array, start: number, end: number): number { 354 | if (!this._inst) return 1; 355 | const m = this._m32; 356 | if (end - start + m[P32.STATE_WP] > m[P32.STATE_ESIZE]) return 1; 357 | this._d.set(data.subarray(start, end), m[P32.STATE_WP]); 358 | m[P32.STATE_WP] += end - start; 359 | // max chunk in input handler is 2^17, try to run in "tandem mode" 360 | // also assures that we dont run into illegal offsets in the wasm part 361 | return m[P32.STATE_WP] - m[P32.STATE_SP] >= 131072 ? this._inst.exports.dec() : 0; 362 | } 363 | 364 | /** 365 | * End the current decoding. 366 | * Decodes leftover payload and finally checks for the correct amount of 367 | * decoded bytes by comparing to the value given to `init`. 368 | * Returns 1 on error, else 0. 369 | */ 370 | public end(): number { 371 | return this._inst ? this._inst.exports.end() : 1; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/ImageRenderer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | 6 | import { toRGBA8888 } from 'sixel/lib/Colors'; 7 | import { IDisposable } from 'xterm'; 8 | import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types'; 9 | 10 | 11 | const PLACEHOLDER_LENGTH = 4096; 12 | const PLACEHOLDER_HEIGHT = 24; 13 | 14 | /** 15 | * ImageRenderer - terminal frontend extension: 16 | * - provide primitives for canvas, ImageData, Bitmap (static) 17 | * - add canvas layer to DOM (browser only for now) 18 | * - draw image tiles onRender 19 | */ 20 | export class ImageRenderer implements IDisposable { 21 | public canvas: HTMLCanvasElement | undefined; 22 | private _ctx: CanvasRenderingContext2D | null | undefined; 23 | private _placeholder: HTMLCanvasElement | undefined; 24 | private _placeholderBitmap: ImageBitmap | undefined; 25 | private _optionsRefresh: IDisposable | undefined; 26 | private _oldOpen: ((parent: HTMLElement) => void) | undefined; 27 | private _renderService: IRenderService | undefined; 28 | private _oldSetRenderer: ((renderer: any) => void) | undefined; 29 | 30 | // drawing primitive - canvas 31 | public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement { 32 | /** 33 | * NOTE: We normally dont care, from which document the canvas 34 | * gets created, so we can fall back to global document, 35 | * if the terminal has no document associated yet. 36 | * This way early image loads before calling .open keep working 37 | * (still discouraged though, as the metrics will be screwed up). 38 | * Only the DOM output canvas should be on the terminal's document, 39 | * which gets explicitly checked in `insertLayerToDom`. 40 | */ 41 | const canvas = (localDocument || document).createElement('canvas'); 42 | canvas.width = width | 0; 43 | canvas.height = height | 0; 44 | return canvas; 45 | } 46 | 47 | // drawing primitive - ImageData with optional buffer 48 | public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData { 49 | if (typeof ImageData !== 'function') { 50 | const imgData = ctx.createImageData(width, height); 51 | if (buffer) { 52 | imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4)); 53 | } 54 | return imgData; 55 | } 56 | return buffer 57 | ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height) 58 | : new ImageData(width, height); 59 | } 60 | 61 | // drawing primitive - ImageBitmap 62 | public static createImageBitmap(img: ImageBitmapSource): Promise { 63 | if (typeof createImageBitmap !== 'function') { 64 | return Promise.resolve(undefined); 65 | } 66 | return createImageBitmap(img); 67 | } 68 | 69 | 70 | constructor(private _terminal: ITerminalExt) { 71 | this._oldOpen = this._terminal._core.open; 72 | this._terminal._core.open = (parent: HTMLElement): void => { 73 | this._oldOpen?.call(this._terminal._core, parent); 74 | this._open(); 75 | }; 76 | if (this._terminal._core.screenElement) { 77 | this._open(); 78 | } 79 | // hack to spot fontSize changes 80 | this._optionsRefresh = this._terminal._core.optionsService.onOptionChange(option => { 81 | if (option === 'fontSize') { 82 | this.rescaleCanvas(); 83 | this._renderService?.refreshRows(0, this._terminal.rows); 84 | } 85 | }); 86 | } 87 | 88 | 89 | public dispose(): void { 90 | this._optionsRefresh?.dispose(); 91 | this.removeLayerFromDom(); 92 | if (this._terminal._core && this._oldOpen) { 93 | this._terminal._core.open = this._oldOpen; 94 | this._oldOpen = undefined; 95 | } 96 | if (this._renderService && this._oldSetRenderer) { 97 | this._renderService.setRenderer = this._oldSetRenderer; 98 | this._oldSetRenderer = undefined; 99 | } 100 | this._renderService = undefined; 101 | this.canvas = undefined; 102 | this._ctx = undefined; 103 | this._placeholderBitmap?.close(); 104 | this._placeholderBitmap = undefined; 105 | this._placeholder = undefined; 106 | } 107 | 108 | /** 109 | * Enable the placeholder. 110 | */ 111 | public showPlaceholder(value: boolean): void { 112 | if (value) { 113 | if (!this._placeholder && this.cellSize.height !== -1) { 114 | this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT)); 115 | } 116 | } else { 117 | this._placeholderBitmap?.close(); 118 | this._placeholderBitmap = undefined; 119 | this._placeholder = undefined; 120 | } 121 | this._renderService?.refreshRows(0, this._terminal.rows); 122 | } 123 | 124 | /** 125 | * Dimensions of the terminal. 126 | * Forwarded from internal render service. 127 | */ 128 | public get dimensions(): IRenderDimensions | undefined { 129 | return this._renderService?.dimensions; 130 | } 131 | 132 | /** 133 | * Current cell size (float). 134 | */ 135 | public get cellSize(): ICellSize { 136 | return { 137 | width: this.dimensions?.css.cell.width || -1, 138 | height: this.dimensions?.css.cell.height || -1 139 | }; 140 | } 141 | 142 | /** 143 | * Clear a region of the image layer canvas. 144 | */ 145 | public clearLines(start: number, end: number): void { 146 | this._ctx?.clearRect( 147 | 0, 148 | start * (this.dimensions?.css.cell.height || 0), 149 | this.dimensions?.css.canvas.width || 0, 150 | (++end - start) * (this.dimensions?.css.cell.height || 0) 151 | ); 152 | } 153 | 154 | /** 155 | * Clear whole image canvas. 156 | */ 157 | public clearAll(): void { 158 | this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0); 159 | } 160 | 161 | /** 162 | * Draw neighboring tiles on the image layer canvas. 163 | */ 164 | public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void { 165 | if (!this._ctx) { 166 | return; 167 | } 168 | const { width, height } = this.cellSize; 169 | 170 | // Don't try to draw anything, if we cannot get valid renderer metrics. 171 | if (width === -1 || height === -1) { 172 | return; 173 | } 174 | 175 | this._rescaleImage(imgSpec, width, height); 176 | const img = imgSpec.actual!; 177 | const cols = Math.ceil(img.width / width); 178 | 179 | const sx = (tileId % cols) * width; 180 | const sy = Math.floor(tileId / cols) * height; 181 | const dx = col * width; 182 | const dy = row * height; 183 | 184 | // safari bug: never access image source out of bounds 185 | const finalWidth = count * width + sx > img.width ? img.width - sx : count * width; 186 | const finalHeight = sy + height > img.height ? img.height - sy : height; 187 | 188 | // Floor all pixel offsets to get stable tile mapping without any overflows. 189 | // Note: For not pixel perfect aligned cells like in the DOM renderer 190 | // this will move a tile slightly to the top/left (subpixel range, thus ignore it). 191 | // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width 192 | this._ctx.drawImage( 193 | img, 194 | Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight), 195 | Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight) 196 | ); 197 | } 198 | 199 | /** 200 | * Extract a single tile from an image. 201 | */ 202 | public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined { 203 | const { width, height } = this.cellSize; 204 | // Don't try to draw anything, if we cannot get valid renderer metrics. 205 | if (width === -1 || height === -1) { 206 | return; 207 | } 208 | this._rescaleImage(imgSpec, width, height); 209 | const img = imgSpec.actual!; 210 | const cols = Math.ceil(img.width / width); 211 | const sx = (tileId % cols) * width; 212 | const sy = Math.floor(tileId / cols) * height; 213 | const finalWidth = width + sx > img.width ? img.width - sx : width; 214 | const finalHeight = sy + height > img.height ? img.height - sy : height; 215 | 216 | const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight); 217 | const ctx = canvas.getContext('2d'); 218 | if (ctx) { 219 | ctx.drawImage( 220 | img, 221 | Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight), 222 | 0, 0, Math.floor(finalWidth), Math.floor(finalHeight) 223 | ); 224 | return canvas; 225 | } 226 | } 227 | 228 | /** 229 | * Draw a line with placeholder on the image layer canvas. 230 | */ 231 | public drawPlaceholder(col: number, row: number, count: number = 1): void { 232 | if (this._ctx) { 233 | const { width, height } = this.cellSize; 234 | 235 | // Don't try to draw anything, if we cannot get valid renderer metrics. 236 | if (width === -1 || height === -1) { 237 | return; 238 | } 239 | 240 | if (!this._placeholder) { 241 | this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT)); 242 | } else if (height >= this._placeholder!.height) { 243 | this._createPlaceHolder(height + 1); 244 | } 245 | if (!this._placeholder) return; 246 | this._ctx.drawImage( 247 | this._placeholderBitmap || this._placeholder!, 248 | col * width, 249 | (row * height) % 2 ? 0 : 1, // needs %2 offset correction 250 | width * count, 251 | height, 252 | col * width, 253 | row * height, 254 | width * count, 255 | height 256 | ); 257 | } 258 | } 259 | 260 | /** 261 | * Rescale image layer canvas if needed. 262 | * Checked once from `ImageStorage.render`. 263 | */ 264 | public rescaleCanvas(): void { 265 | if (!this.canvas) { 266 | return; 267 | } 268 | if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) { 269 | this.canvas.width = this.dimensions!.css.canvas.width || 0; 270 | this.canvas.height = this.dimensions!.css.canvas.height || 0; 271 | } 272 | } 273 | 274 | /** 275 | * Rescale image in storage if needed. 276 | */ 277 | private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void { 278 | if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) { 279 | return; 280 | } 281 | const { width: originalWidth, height: originalHeight } = spec.origCellSize; 282 | if (currentWidth === originalWidth && currentHeight === originalHeight) { 283 | spec.actual = spec.orig; 284 | spec.actualCellSize.width = originalWidth; 285 | spec.actualCellSize.height = originalHeight; 286 | return; 287 | } 288 | const canvas = ImageRenderer.createCanvas( 289 | this.document, 290 | Math.ceil(spec.orig!.width * currentWidth / originalWidth), 291 | Math.ceil(spec.orig!.height * currentHeight / originalHeight) 292 | ); 293 | const ctx = canvas.getContext('2d'); 294 | if (ctx) { 295 | ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height); 296 | spec.actual = canvas; 297 | spec.actualCellSize.width = currentWidth; 298 | spec.actualCellSize.height = currentHeight; 299 | } 300 | } 301 | 302 | /** 303 | * Lazy init for the renderer. 304 | */ 305 | private _open(): void { 306 | this._renderService = this._terminal._core._renderService; 307 | this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService); 308 | this._renderService.setRenderer = (renderer: any) => { 309 | this.removeLayerFromDom(); 310 | this._oldSetRenderer?.call(this._renderService, renderer); 311 | }; 312 | } 313 | 314 | public insertLayerToDom(): void { 315 | // make sure that the terminal is attached to a document and to DOM 316 | if (this.document && this._terminal._core.screenElement) { 317 | if (!this.canvas) { 318 | this.canvas = ImageRenderer.createCanvas( 319 | this.document, this.dimensions?.css.canvas.width || 0, 320 | this.dimensions?.css.canvas.height || 0 321 | ); 322 | this.canvas.classList.add('xterm-image-layer'); 323 | this._terminal._core.screenElement.appendChild(this.canvas); 324 | this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true }); 325 | this.clearAll(); 326 | } 327 | } else { 328 | console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement'); 329 | } 330 | } 331 | 332 | public removeLayerFromDom(): void { 333 | if (this.canvas) { 334 | this._ctx = undefined; 335 | this.canvas.remove(); 336 | this.canvas = undefined; 337 | } 338 | } 339 | 340 | private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void { 341 | this._placeholderBitmap?.close(); 342 | this._placeholderBitmap = undefined; 343 | 344 | // create blueprint to fill placeholder with 345 | const bWidth = 32; // must be 2^n 346 | const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height); 347 | const ctx = blueprint.getContext('2d', { alpha: false }); 348 | if (!ctx) return; 349 | const imgData = ImageRenderer.createImageData(ctx, bWidth, height); 350 | const d32 = new Uint32Array(imgData.data.buffer); 351 | const black = toRGBA8888(0, 0, 0); 352 | const white = toRGBA8888(255, 255, 255); 353 | d32.fill(black); 354 | for (let y = 0; y < height; ++y) { 355 | const shift = y % 2; 356 | const offset = y * bWidth; 357 | for (let x = 0; x < bWidth; x += 2) { 358 | d32[offset + x + shift] = white; 359 | } 360 | } 361 | ctx.putImageData(imgData, 0, 0); 362 | 363 | // create placeholder line, width aligned to blueprint width 364 | const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH; 365 | this._placeholder = ImageRenderer.createCanvas(this.document, width, height); 366 | const ctx2 = this._placeholder.getContext('2d', { alpha: false }); 367 | if (!ctx2) { 368 | this._placeholder = undefined; 369 | return; 370 | } 371 | for (let i = 0; i < width; i += bWidth) { 372 | ctx2.drawImage(blueprint, i, 0); 373 | } 374 | ImageRenderer.createImageBitmap(this._placeholder!).then(bitmap => this._placeholderBitmap = bitmap); 375 | } 376 | 377 | public get document(): Document | undefined { 378 | return this._terminal._core._coreBrowserService?.window.document; 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## xterm-addon-image 2 | 3 | Image output in xterm.js. 4 | 5 | 6 | ![](fixture/example.png) 7 | 8 | 9 | ### Important note 10 | 11 | Version 0.4.x will be the last version from this single repo. 12 | Future versions will reside as addon in the xterm.js main repo. 13 | 14 | 15 | ### Install from npm 16 | 17 | ```bash 18 | npm install --save xterm-addon-image 19 | ``` 20 | 21 | ### Release Compatibility 22 | 23 | - 0.4.3 - compatible to xterm.js 5.2.0 24 | - 0.4.2 - compatible to xterm.js 5.2.0 25 | - 0.4.1 - compatible to xterm.js 5.2.0 26 | - 0.4.0 - compatible to xterm.js 5.1.0 27 | - 0.3.1 - compatible to xterm.js 5.1.0 28 | - 0.3.0 - compatible to xterm.js 5.0.0 29 | - 0.2.0 - compatible to xterm.js 5.0.0 30 | - 0.1.x - compatible to xterm.js 4.16.0 - 4.19.0 31 | 32 | 33 | ### Clone & Build 34 | 35 | The addon integrates tightly with the xterm.js base repo, esp. for tests and the demo. 36 | To properly set up all needed resources see `bootstrap.sh` or run it directly with 37 | 38 | ```bash 39 | curl -s https://raw.githubusercontent.com/jerch/xterm-addon-image/master/bootstrap.sh | XTERMJS=5.2.0 IMAGEADDON=master bash 40 | ``` 41 | 42 | The addon sources and npm package definition reside under `addons/xterm-addon-image`. 43 | 44 | 45 | ### Usage 46 | 47 | ```ts 48 | import { Terminal } from 'xterm'; 49 | import { ImageAddon, IImageAddonOptions } from 'xterm-addon-image'; 50 | 51 | // customize as needed (showing addon defaults) 52 | const customSettings: IImageAddonOptions = { 53 | enableSizeReports: true, // whether to enable CSI t reports (see below) 54 | pixelLimit: 16777216, // max. pixel size of a single image 55 | sixelSupport: true, // enable sixel support 56 | sixelScrolling: true, // whether to scroll on image output 57 | sixelPaletteLimit: 256, // initial sixel palette size 58 | sixelSizeLimit: 25000000, // size limit of a single sixel sequence 59 | storageLimit: 128, // FIFO storage limit in MB 60 | showPlaceholder: true, // whether to show a placeholder for evicted images 61 | iipSupport: true, // enable iTerm IIP support 62 | iipSizeLimit: 20000000 // size limit of a single IIP sequence 63 | } 64 | 65 | // initialization 66 | const terminal = new Terminal(); 67 | const imageAddon = new ImageAddon(customSettings); 68 | terminal.loadAddon(imageAddon); 69 | ``` 70 | 71 | ### General Notes 72 | 73 | - *IMPORTANT:* The worker approach as done in previous releases got removed. 74 | The addon contructor no longer expects a worker path as first argument. 75 | 76 | - By default the addon will activate these `windowOptions` reports on the terminal: 77 | - getWinSizePixels (CSI 14 t) 78 | - getCellSizePixels (CSI 16 t) 79 | - getWinSizeChars (CSI 18 t) 80 | 81 | to help applications getting useful terminal metrics for their image preparations. Set `enableSizeReports` in the constructor options to `false`, if you dont want the addon to alter these terminal settings. This is especially useful, if you have very strict security needs not allowing any terminal reports, or deal with `windowOptions` by other means. 82 | 83 | 84 | ### Operation Modes 85 | 86 | - **SIXEL Support** 87 | Set by default, change it with `{sixelSupport: true}`. 88 | 89 | - **Scrolling On | Off** 90 | By default scrolling is on, thus an image will advance the cursor at the bottom if needed. 91 | (see cursor positioning). 92 | 93 | If scrolling is off, the image gets painted from the top left of the current viewport 94 | and might be truncated if the image exceeds the viewport size. 95 | The cursor position does not change. 96 | 97 | You can customize this behavior with the constructor option `{sixelScrolling: false}` 98 | or with `DECSET 80` (off, binary: `\x1b [ ? 80 h`) and 99 | `DECRST 80` (on, binary: `\x1b [ ? 80 l`) during runtime. 100 | 101 | - **Cursor Positioning** 102 | If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode). 103 | Other cursor positioning modes as used by xterm or mintty are not supported. 104 | 105 | - **SIXEL Palette Handling** 106 | By default the addon limits the palette size to 256 registers (as demanded by the DEC specification). 107 | The limit can be increased to a maximum of 4096 registers (via `sixelPaletteLimit`). 108 | 109 | The default palette is a mixture of VT340 colors (lower 16 registers), xterm colors (up to 256) and zeros (up to 4096). 110 | There is no private/shared palette distinction, palette colors are always carried over from a previous sixel image. 111 | Restoring the default palette size and colors is possible with `XTSMGRAPHICS 1 ; 2` (binary: `\x1b[?1;2S`). 112 | It gets also restored automatically on RIS and DECSTR. 113 | 114 | Other than on older terminals, the underlying SIXEL library applies colors immediately to individual pixels 115 | (*printer mode*), thus it is technically possible to use more colors in one image than the palette has color slots. 116 | This feature is called *high-color* in libsixel. 117 | 118 | A terminal wide shared palette mode with late indexed coloring of the output is not supported, 119 | therefore palette animations cannot be used. 120 | 121 | - **SIXEL Raster Attributes Handling** 122 | If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification. 123 | 124 | Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel. 125 | 126 | - **IIP Support (iTerm's Inline Image Protocol)** 127 | Set by default, change it with `{iipSupport: true}`. 128 | 129 | The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions): 130 | - Supported formats: PNG, JPEG and GIF 131 | - No animation support. 132 | - Image type hinting is not supported (always deducted from data header). 133 | - File download is not supported. 134 | - Filename gets parsed but not used. 135 | - Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed). 136 | - Payload size may not exceed CEIL(sizeParameter * 4 / 3). 137 | - Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`). 138 | - Image pixel size is restricted by `pixelLimit` (pre- and post resizing). 139 | - Size parameter is restricted by `iipSizeLimit`. 140 | - Cursor positioning behaves the same as for sixel (see above). 141 | 142 | 143 | ### Storage and Drawing Settings 144 | 145 | The internal storage holds images up to `storageLimit` (in MB, calculated as 4-channel RBGA unpacked, default 100 MB). Once hit images get evicted by FIFO rules. Furthermore images on the alternate buffer will always be erased on buffer changes. 146 | 147 | The addon exposes two properties to interact with the storage limits at runtime: 148 | - `storageLimit` 149 | Change the value to your needs at runtime. This is especially useful, if you have multiple terminal 150 | instances running, that all add to one upper memory limit. 151 | - `storageUsage` 152 | Inspect the current memory usage of the image storage. 153 | 154 | By default the addon will show a placeholder pattern for evicted images that are still part 155 | of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`. 156 | 157 | ### Image Data Retrieval 158 | 159 | The addon provides the following API endpoints to retrieve raw image data as canvas: 160 | 161 | - `getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined` 162 | Returns the canvas containing the original image data (not resized) at the given buffer position. 163 | The buffer position is the 0-based absolute index (including scrollback at top). 164 | 165 | - `extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined` 166 | Returns a canvas containing the actual single tile image data (maybe resized) at the given buffer position. 167 | The buffer position is the 0-based absolute index (including scrollback at top). 168 | Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions. 169 | 170 | ### Memory Usage 171 | 172 | The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps: 173 | - Incomming image data chunk at `term.write` (terminal) 174 | `term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time. 175 | - Sequence Parser (terminal) 176 | The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB). 177 | - Sequence Handler - Chunk Decoding (addon) 178 | Image data chunks are processed immediately by the SIXEL decoder (streamlined). The decoder allocates memory for image 179 | pixels as needed. The allowed image size is restricted by `pixelLimit` (default 16M pixels), the decoder holds 2 pixel buffers at maximum during decoding (RGBA, ~128 MB for 16M pixels). 180 | - Sequence Handler - Image Finalization (addon) 181 | After decoding the final pixel buffer is grabbed by the sequence handler and written to a canvas of the same size (~64 MB for 16M pixels) and added to the storage. 182 | - Image Storage (addon) 183 | The image storage implements a FIFO cache, that will remove old images, if a new one arrives and `storageLimit` is hit (default 128 MB). The storage holds a canvas with the original image, and may additionally hold resized versions of images after a font rescaling. Both are accounted in `storageUsage` as a rough estimation of _pixels x 4 channels_. 184 | 185 | Following the steps above, a rough estimation of maximum memory usage by the addon can be calculated with these formulas (in bytes): 186 | ```typescript 187 | // storage alone 188 | const storageBytes = storageUsage * storageLimit * 1024 * 1024; 189 | // decoding alone 190 | const decodingBytes = sixelSizeLimit + 2 * (pixelLimit * 4); 191 | 192 | // totals 193 | // inactive decoding 194 | const totalInactive = storageBytes; 195 | // active decoding 196 | const totalActive = storageBytes + decodingBytes; 197 | ``` 198 | 199 | Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions. 200 | Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%). 201 | 202 | _Why should I care about memory usage at all?_ 203 | Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure. 204 | 205 | _How can I adjust the memory usage?_ 206 | - `pixelLimit` 207 | A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page. 208 | - `storageLimit` 209 | A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding. 210 | - `sixelSizeLimit` 211 | A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data. 212 | 213 | 214 | ### Terminal Interaction 215 | 216 | - Images already on the terminal screen will reshape on font-rescaling to keep the terminal cell coverage intact. 217 | This behavior might diverge from other terminals, but is in general the more desired one. 218 | - On terminal resize images may expand to the right automatically, if they were right-truncated before. 219 | They never expand to the bottom, if they were bottom-truncated before (e.g. from scrolling-off). 220 | - Text autowrapping from terminal resize may break and wrap images into multiple parts. This is unfortunate, 221 | but cannot be avoided, while sticking to the stronger terminal text-grid mechanics. 222 | (Yes images are a second class citizen on a mainly text-driven interface.) 223 | - Characters written over an image will erase the image information for affected cells. 224 | - Images are painted above BG/FG data not erasing it. More advanced "composition tricks" like painting images 225 | below FG/BG data are not possible. (We currently dont hook into BG/FG rendering itself.) 226 | - Previous image data at a cell will be erased on new image data. (We currently have no transparency composition.) 227 | 228 | 229 | ### Performance & Latency 230 | 231 | - Performance should be good enough for occasional SIXEL output from REPLs, up to downscaled movies 232 | from `mpv` with its SIXEL renderer (tested in the demo). For 3rd party xterm.js integrations this 233 | furthermore depends highly on the overall incoming data throughput. 234 | - Image processing has a high latency. Most of the latency though is inherited from xterm.js' incoming data route 235 | (PTY -> server process -> websocket -> xterm.js async parsing), where every step creates more waiting time. 236 | Since we cannot do much about that "long line", keep that in mind when you try to run more demanding applications with realtime drawing and interactive response needs. 237 | 238 | 239 | ### Status 240 | 241 | - Sixel support and image handling in xterm.js is considered beta quality. 242 | - IIP support is in alpha stage. Please file a bug for any awkwardities. 243 | 244 | 245 | ### Changelog 246 | 247 | - 0.4.3 defer canvas creation 248 | - 0.4.2 fix image canvas resize 249 | - 0.4.1 compat release for xterm.js 5.2.0 250 | - 0.4.0 IIP support 251 | - 0.3.1 compat release for xterm.js 5.1.0 252 | - 0.3.0 important change: worker removed from addon 253 | - 0.2.0 compat release for xterm.js 5.0.0 254 | - 0.1.3 bugfix: avoid striping 255 | - 0.1.2 bugfix: reset clear flag 256 | - 0.1.1 bugfixes: 257 | - clear sticky image tiles on render 258 | - create xterm-addon-image folder from bootstrap.sh 259 | - fix peer dependency in package.json 260 | -------------------------------------------------------------------------------- /src/ImageStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Joerg Breitbart. 3 | * @license MIT 4 | */ 5 | import { IDisposable } from 'xterm'; 6 | import { ImageRenderer } from './ImageRenderer'; 7 | import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types'; 8 | 9 | 10 | // fallback default cell size 11 | export const CELL_SIZE_DEFAULT: ICellSize = { 12 | width: 7, 13 | height: 14 14 | }; 15 | 16 | /** 17 | * Extend extended attribute to also hold image tile information. 18 | * 19 | * Object definition is copied from base repo to fully mimick its behavior. 20 | * Image data is added as additional public properties `imageId` and `tileId`. 21 | */ 22 | class ExtendedAttrsImage implements IExtendedAttrsImage { 23 | private _ext: number = 0; 24 | public get ext(): number { 25 | if (this._urlId) { 26 | return ( 27 | (this._ext & ~ExtFlags.UNDERLINE_STYLE) | 28 | (this.underlineStyle << 26) 29 | ); 30 | } 31 | return this._ext; 32 | } 33 | public set ext(value: number) { this._ext = value; } 34 | 35 | public get underlineStyle(): UnderlineStyle { 36 | // Always return the URL style if it has one 37 | if (this._urlId) { 38 | return UnderlineStyle.DASHED; 39 | } 40 | return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26; 41 | } 42 | public set underlineStyle(value: UnderlineStyle) { 43 | this._ext &= ~ExtFlags.UNDERLINE_STYLE; 44 | this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE; 45 | } 46 | 47 | public get underlineColor(): number { 48 | return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); 49 | } 50 | public set underlineColor(value: number) { 51 | this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); 52 | this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); 53 | } 54 | 55 | private _urlId: number = 0; 56 | public get urlId(): number { 57 | return this._urlId; 58 | } 59 | public set urlId(value: number) { 60 | this._urlId = value; 61 | } 62 | 63 | constructor( 64 | ext: number = 0, 65 | urlId: number = 0, 66 | public imageId = -1, 67 | public tileId = -1 68 | ) { 69 | this._ext = ext; 70 | this._urlId = urlId; 71 | } 72 | 73 | public clone(): IExtendedAttrsImage { 74 | /** 75 | * Technically we dont need a clone variant of ExtendedAttrsImage, 76 | * as we never clone a cell holding image data. 77 | * Note: Clone is only meant to be used by the InputHandler for 78 | * sticky attributes, which is never the case for image data. 79 | * We still provide a proper clone method to reflect the full ext attr 80 | * state in case there are future use cases for clone. 81 | */ 82 | return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId); 83 | } 84 | 85 | public isEmpty(): boolean { 86 | return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1; 87 | } 88 | } 89 | const EMPTY_ATTRS = new ExtendedAttrsImage(); 90 | 91 | 92 | /** 93 | * ImageStorage - extension of CoreTerminal: 94 | * - hold image data 95 | * - write/read image data to/from buffer 96 | * 97 | * TODO: image composition for overwrites 98 | */ 99 | export class ImageStorage implements IDisposable { 100 | // storage 101 | private _images: Map = new Map(); 102 | // last used id 103 | private _lastId = 0; 104 | // last evicted id 105 | private _lowestId = 0; 106 | // whether a full clear happened before 107 | private _fullyCleared = false; 108 | // whether render should do a full clear 109 | private _needsFullClear = false; 110 | // hard limit of stored pixels (fallback limit of 10 MB) 111 | private _pixelLimit: number = 2500000; 112 | 113 | private _viewportMetrics: { cols: number, rows: number }; 114 | 115 | constructor( 116 | private _terminal: ITerminalExt, 117 | private _renderer: ImageRenderer, 118 | private _opts: IImageAddonOptions 119 | ) { 120 | try { 121 | this.setLimit(this._opts.storageLimit); 122 | } catch (e: any) { 123 | console.error(e.message); 124 | console.warn(`storageLimit is set to ${this.getLimit()} MB`); 125 | } 126 | this._viewportMetrics = { 127 | cols: this._terminal.cols, 128 | rows: this._terminal.rows 129 | }; 130 | } 131 | 132 | public dispose(): void { 133 | this.reset(); 134 | } 135 | 136 | public reset(): void { 137 | for (const spec of this._images.values()) { 138 | spec.marker?.dispose(); 139 | } 140 | // NOTE: marker.dispose above already calls ImageBitmap.close 141 | // therefore we can just wipe the map here 142 | this._images.clear(); 143 | this._renderer.clearAll(); 144 | } 145 | 146 | public getLimit(): number { 147 | return this._pixelLimit * 4 / 1000000; 148 | } 149 | 150 | public setLimit(value: number): void { 151 | if (value < 1 || value > 1000) { 152 | throw RangeError('invalid storageLimit, should be at least 1 MB and not exceed 1G'); 153 | } 154 | this._pixelLimit = (value / 4 * 1000000) >>> 0; 155 | this._evictOldest(0); 156 | } 157 | 158 | public getUsage(): number { 159 | return this._getStoredPixels() * 4 / 1000000; 160 | } 161 | 162 | private _getStoredPixels(): number { 163 | let storedPixels = 0; 164 | for (const spec of this._images.values()) { 165 | if (spec.orig) { 166 | storedPixels += spec.orig.width * spec.orig.height; 167 | if (spec.actual && spec.actual !== spec.orig) { 168 | storedPixels += spec.actual.width * spec.actual.height; 169 | } 170 | } 171 | } 172 | return storedPixels; 173 | } 174 | 175 | private _delImg(id: number): void { 176 | const spec = this._images.get(id); 177 | this._images.delete(id); 178 | // FIXME: really ugly workaround to get bitmaps deallocated :( 179 | if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) { 180 | spec.orig.close(); 181 | } 182 | } 183 | 184 | /** 185 | * Wipe canvas and images on alternate buffer. 186 | */ 187 | public wipeAlternate(): void { 188 | // remove all alternate tagged images 189 | const zero = []; 190 | for (const [id, spec] of this._images.entries()) { 191 | if (spec.bufferType === 'alternate') { 192 | spec.marker?.dispose(); 193 | zero.push(id); 194 | } 195 | } 196 | for (const id of zero) { 197 | this._delImg(id); 198 | } 199 | // mark canvas to be wiped on next render 200 | this._needsFullClear = true; 201 | this._fullyCleared = false; 202 | } 203 | 204 | /** 205 | * Only advance text cursor. 206 | * This is an edge case from empty sixels carrying only a height but no pixels. 207 | * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37. 208 | */ 209 | public advanceCursor(height: number): void { 210 | if (this._opts.sixelScrolling) { 211 | let cellSize = this._renderer.cellSize; 212 | if (cellSize.width === -1 || cellSize.height === -1) { 213 | cellSize = CELL_SIZE_DEFAULT; 214 | } 215 | const rows = Math.ceil(height / cellSize.height); 216 | for (let i = 1; i < rows; ++i) { 217 | this._terminal._core._inputHandler.lineFeed(); 218 | } 219 | } 220 | } 221 | 222 | /** 223 | * Method to add an image to the storage. 224 | */ 225 | public addImage(img: HTMLCanvasElement | ImageBitmap): void { 226 | // never allow storage to exceed memory limit 227 | this._evictOldest(img.width * img.height); 228 | 229 | // calc rows x cols needed to display the image 230 | let cellSize = this._renderer.cellSize; 231 | if (cellSize.width === -1 || cellSize.height === -1) { 232 | cellSize = CELL_SIZE_DEFAULT; 233 | } 234 | const cols = Math.ceil(img.width / cellSize.width); 235 | const rows = Math.ceil(img.height / cellSize.height); 236 | 237 | const imageId = ++this._lastId; 238 | 239 | const buffer = this._terminal._core.buffer; 240 | const termCols = this._terminal.cols; 241 | const termRows = this._terminal.rows; 242 | const originX = buffer.x; 243 | const originY = buffer.y; 244 | let offset = originX; 245 | let tileCount = 0; 246 | 247 | if (!this._opts.sixelScrolling) { 248 | buffer.x = 0; 249 | buffer.y = 0; 250 | offset = 0; 251 | } 252 | 253 | this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); 254 | for (let row = 0; row < rows; ++row) { 255 | const line = buffer.lines.get(buffer.y + buffer.ybase); 256 | for (let col = 0; col < cols; ++col) { 257 | if (offset + col >= termCols) break; 258 | this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col); 259 | tileCount++; 260 | } 261 | if (this._opts.sixelScrolling) { 262 | if (row < rows - 1) this._terminal._core._inputHandler.lineFeed(); 263 | } else { 264 | if (++buffer.y >= termRows) break; 265 | } 266 | buffer.x = offset; 267 | } 268 | this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); 269 | 270 | // cursor positioning modes 271 | if (this._opts.sixelScrolling) { 272 | buffer.x = offset; 273 | } else { 274 | buffer.x = originX; 275 | buffer.y = originY; 276 | } 277 | 278 | // deleted images with zero tile count 279 | const zero = []; 280 | for (const [id, spec] of this._images.entries()) { 281 | if (spec.tileCount < 1) { 282 | spec.marker?.dispose(); 283 | zero.push(id); 284 | } 285 | } 286 | for (const id of zero) { 287 | this._delImg(id); 288 | } 289 | 290 | // eviction marker: 291 | // delete the image when the marker gets disposed 292 | const endMarker = this._terminal.registerMarker(0); 293 | endMarker?.onDispose(() => { 294 | const spec = this._images.get(imageId); 295 | if (spec) { 296 | this._delImg(imageId); 297 | } 298 | }); 299 | 300 | // since markers do not work on alternate for some reason, 301 | // we evict images here manually 302 | if (this._terminal.buffer.active.type === 'alternate') { 303 | this._evictOnAlternate(); 304 | } 305 | 306 | // create storage entry 307 | const imgSpec: IImageSpec = { 308 | orig: img, 309 | origCellSize: cellSize, 310 | actual: img, 311 | actualCellSize: { ...cellSize }, // clone needed, since later modified 312 | marker: endMarker || undefined, 313 | tileCount, 314 | bufferType: this._terminal.buffer.active.type 315 | }; 316 | 317 | // finally add the image 318 | this._images.set(imageId, imgSpec); 319 | } 320 | 321 | 322 | /** 323 | * Render method. Collects buffer information and triggers 324 | * canvas updates. 325 | */ 326 | // TODO: Should we move this to the ImageRenderer? 327 | public render(range: { start: number, end: number }): void { 328 | // setup image canvas in case we have none yet, but have images in store 329 | if (!this._renderer.canvas && this._images.size) { 330 | this._renderer.insertLayerToDom(); 331 | // safety measure - in case we cannot spawn a canvas at all, just exit 332 | if (!this._renderer.canvas) { 333 | return; 334 | } 335 | } 336 | // rescale if needed 337 | this._renderer.rescaleCanvas(); 338 | // exit early if we dont have any images to test for 339 | if (!this._images.size) { 340 | if (!this._fullyCleared) { 341 | this._renderer.clearAll(); 342 | this._fullyCleared = true; 343 | this._needsFullClear = false; 344 | } 345 | if (this._renderer.canvas) { 346 | this._renderer.removeLayerFromDom(); 347 | } 348 | return; 349 | } 350 | 351 | // buffer switches force a full clear 352 | if (this._needsFullClear) { 353 | this._renderer.clearAll(); 354 | this._fullyCleared = true; 355 | this._needsFullClear = false; 356 | } 357 | 358 | const { start, end } = range; 359 | const buffer = this._terminal._core.buffer; 360 | const cols = this._terminal._core.cols; 361 | 362 | // clear drawing area 363 | this._renderer.clearLines(start, end); 364 | 365 | // walk all cells in viewport and draw tiles found 366 | for (let row = start; row <= end; ++row) { 367 | const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt; 368 | if (!line) return; 369 | for (let col = 0; col < cols; ++col) { 370 | if (line.getBg(col) & BgFlags.HAS_EXTENDED) { 371 | let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS; 372 | const imageId = e.imageId; 373 | if (imageId === undefined || imageId === -1) { 374 | continue; 375 | } 376 | const imgSpec = this._images.get(imageId); 377 | if (e.tileId !== -1) { 378 | const startTile = e.tileId; 379 | const startCol = col; 380 | let count = 1; 381 | /** 382 | * merge tiles to the right into a single draw call, if: 383 | * - not at end of line 384 | * - cell has same image id 385 | * - cell has consecutive tile id 386 | */ 387 | while ( 388 | ++col < cols 389 | && (line.getBg(col) & BgFlags.HAS_EXTENDED) 390 | && (e = line._extendedAttrs[col] || EMPTY_ATTRS) 391 | && (e.imageId === imageId) 392 | && (e.tileId === startTile + count) 393 | ) { 394 | count++; 395 | } 396 | col--; 397 | if (imgSpec) { 398 | if (imgSpec.actual) { 399 | this._renderer.draw(imgSpec, startTile, startCol, row, count); 400 | } 401 | } else if (this._opts.showPlaceholder) { 402 | this._renderer.drawPlaceholder(startCol, row, count); 403 | } 404 | this._fullyCleared = false; 405 | } 406 | } 407 | } 408 | } 409 | } 410 | 411 | public viewportResize(metrics: { cols: number, rows: number }): void { 412 | // exit early if we have nothing in storage 413 | if (!this._images.size) { 414 | this._viewportMetrics = metrics; 415 | return; 416 | } 417 | 418 | // handle only viewport width enlargements, exit all other cases 419 | // TODO: needs patch for tile counter 420 | if (this._viewportMetrics.cols >= metrics.cols) { 421 | this._viewportMetrics = metrics; 422 | return; 423 | } 424 | 425 | // walk scrollbuffer at old col width to find all possible expansion matches 426 | const buffer = this._terminal._core.buffer; 427 | const rows = buffer.lines.length; 428 | const oldCol = this._viewportMetrics.cols - 1; 429 | for (let row = 0; row < rows; ++row) { 430 | const line = buffer.lines.get(row) as IBufferLineExt; 431 | if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) { 432 | const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS; 433 | const imageId = e.imageId; 434 | if (imageId === undefined || imageId === -1) { 435 | continue; 436 | } 437 | const imgSpec = this._images.get(imageId); 438 | if (!imgSpec) { 439 | continue; 440 | } 441 | // found an image tile at oldCol, check if it qualifies for right exapansion 442 | const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width); 443 | if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) { 444 | continue; 445 | } 446 | // expand only if right side is empty (nothing got wrapped from below) 447 | let hasData = false; 448 | for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) { 449 | if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) { 450 | hasData = true; 451 | break; 452 | } 453 | } 454 | if (hasData) { 455 | continue; 456 | } 457 | // do right expansion on terminal buffer 458 | const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); 459 | let lastTile = e.tileId; 460 | for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { 461 | this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile); 462 | imgSpec.tileCount++; 463 | } 464 | } 465 | } 466 | // store new viewport metrics 467 | this._viewportMetrics = metrics; 468 | } 469 | 470 | /** 471 | * Retrieve original canvas at buffer position. 472 | */ 473 | public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { 474 | const buffer = this._terminal._core.buffer; 475 | const line = buffer.lines.get(y) as IBufferLineExt; 476 | if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { 477 | const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS; 478 | if (e.imageId && e.imageId !== -1) { 479 | const orig = this._images.get(e.imageId)?.orig; 480 | if (window.ImageBitmap && orig instanceof ImageBitmap) { 481 | const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height); 482 | canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height); 483 | return canvas; 484 | } 485 | return orig as HTMLCanvasElement; 486 | } 487 | } 488 | } 489 | 490 | /** 491 | * Extract active single tile at buffer position. 492 | */ 493 | public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { 494 | const buffer = this._terminal._core.buffer; 495 | const line = buffer.lines.get(y) as IBufferLineExt; 496 | if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { 497 | const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS; 498 | if (e.imageId && e.imageId !== -1 && e.tileId !== -1) { 499 | const spec = this._images.get(e.imageId); 500 | if (spec) { 501 | return this._renderer.extractTile(spec, e.tileId); 502 | } 503 | } 504 | } 505 | } 506 | 507 | // TODO: Do we need some blob offloading tricks here to avoid early eviction? 508 | // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size 509 | private _evictOldest(room: number): number { 510 | const used = this._getStoredPixels(); 511 | let current = used; 512 | while (this._pixelLimit < current + room && this._images.size) { 513 | const spec = this._images.get(++this._lowestId); 514 | if (spec && spec.orig) { 515 | current -= spec.orig.width * spec.orig.height; 516 | if (spec.actual && spec.orig !== spec.actual) { 517 | current -= spec.actual.width * spec.actual.height; 518 | } 519 | spec.marker?.dispose(); 520 | this._delImg(this._lowestId); 521 | } 522 | } 523 | return used - current; 524 | } 525 | 526 | private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void { 527 | if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { 528 | const old = line._extendedAttrs[x]; 529 | if (old) { 530 | if (old.imageId !== undefined) { 531 | // found an old ExtendedAttrsImage, since we know that 532 | // they are always isolated instances (single cell usage), 533 | // we can re-use it and just update their id entries 534 | const oldSpec = this._images.get(old.imageId); 535 | if (oldSpec) { 536 | // early eviction for in-viewport overwrites 537 | oldSpec.tileCount--; 538 | } 539 | old.imageId = imageId; 540 | old.tileId = tileId; 541 | return; 542 | } 543 | // found a plain ExtendedAttrs instance, clone it to new entry 544 | line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId); 545 | return; 546 | } 547 | } 548 | // fall-through: always create new ExtendedAttrsImage entry 549 | line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED; 550 | line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId); 551 | } 552 | 553 | private _evictOnAlternate(): void { 554 | // nullify tile count of all images on alternate buffer 555 | for (const spec of this._images.values()) { 556 | if (spec.bufferType === 'alternate') { 557 | spec.tileCount = 0; 558 | } 559 | } 560 | // re-count tiles on whole buffer 561 | const buffer = this._terminal._core.buffer; 562 | for (let y = 0; y < this._terminal.rows; ++y) { 563 | const line = buffer.lines.get(y) as IBufferLineExt; 564 | if (!line) { 565 | continue; 566 | } 567 | for (let x = 0; x < this._terminal.cols; ++x) { 568 | if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { 569 | const imgId = line._extendedAttrs[x]?.imageId; 570 | if (imgId) { 571 | const spec = this._images.get(imgId); 572 | if (spec) { 573 | spec.tileCount++; 574 | } 575 | } 576 | } 577 | } 578 | } 579 | // deleted images with zero tile count 580 | const zero = []; 581 | for (const [id, spec] of this._images.entries()) { 582 | if (spec.bufferType === 'alternate' && !spec.tileCount) { 583 | spec.marker?.dispose(); 584 | zero.push(id); 585 | } 586 | } 587 | for (const id of zero) { 588 | this._delImg(id); 589 | } 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /fixture/palette.sixel: -------------------------------------------------------------------------------- 1 | P0;0;q"1;1;640;80#0;2;0;0;0#1;2;0;13;0#2;2;0;25;0#3;2;0;38;0#4;2;0;50;0#5;2;0;63;0#6;2;0;75;0#7;2;0;88;0#8;2;13;0;0#9;2;13;13;0#10;2;13;25;0#11;2;13;38;0#12;2;13;50;0#13;2;13;63;0#14;2;13;75;0#15;2;13;88;0#16;2;25;0;0#17;2;25;13;0#18;2;25;25;0#19;2;25;38;0#20;2;25;50;0#21;2;25;63;0#22;2;25;75;0#23;2;25;88;0#24;2;38;0;0#25;2;38;13;0#26;2;38;25;0#27;2;38;38;0#28;2;38;50;0#29;2;38;63;0#30;2;38;75;0#31;2;38;88;0#32;2;50;0;0#33;2;50;13;0#34;2;50;25;0#35;2;50;38;0#36;2;50;50;0#37;2;50;63;0#38;2;50;75;0#39;2;50;88;0#40;2;63;0;0#41;2;63;13;0#42;2;63;25;0#43;2;63;38;0#44;2;63;50;0#45;2;63;63;0#46;2;63;75;0#47;2;63;88;0#48;2;75;0;0#49;2;75;13;0#50;2;75;25;0#51;2;75;38;0#52;2;75;50;0#53;2;75;63;0#54;2;75;75;0#55;2;75;88;0#56;2;88;0;0#57;2;88;13;0#58;2;88;25;0#59;2;88;38;0#60;2;88;50;0#61;2;88;63;0#62;2;88;75;0#63;2;88;88;0#64;2;0;0;13#65;2;0;13;13#66;2;0;25;13#67;2;0;38;13#68;2;0;50;13#69;2;0;63;13#70;2;0;75;13#71;2;0;88;13#72;2;13;0;13#73;2;13;13;13#74;2;13;25;13#75;2;13;38;13#76;2;13;50;13#77;2;13;63;13#78;2;13;75;13#79;2;13;88;13#80;2;25;0;13#81;2;25;13;13#82;2;25;25;13#83;2;25;38;13#84;2;25;50;13#85;2;25;63;13#86;2;25;75;13#87;2;25;88;13#88;2;38;0;13#89;2;38;13;13#90;2;38;25;13#91;2;38;38;13#92;2;38;50;13#93;2;38;63;13#94;2;38;75;13#95;2;38;88;13#96;2;50;0;13#97;2;50;13;13#98;2;50;25;13#99;2;50;38;13#100;2;50;50;13#101;2;50;63;13#102;2;50;75;13#103;2;50;88;13#104;2;63;0;13#105;2;63;13;13#106;2;63;25;13#107;2;63;38;13#108;2;63;50;13#109;2;63;63;13#110;2;63;75;13#111;2;63;88;13#112;2;75;0;13#113;2;75;13;13#114;2;75;25;13#115;2;75;38;13#116;2;75;50;13#117;2;75;63;13#118;2;75;75;13#119;2;75;88;13#120;2;88;0;13#121;2;88;13;13#122;2;88;25;13#123;2;88;38;13#124;2;88;50;13#125;2;88;63;13#126;2;88;75;13#127;2;88;88;13#128;2;0;0;25#129;2;0;13;25#130;2;0;25;25#131;2;0;38;25#132;2;0;50;25#133;2;0;63;25#134;2;0;75;25#135;2;0;88;25#136;2;13;0;25#137;2;13;13;25#138;2;13;25;25#139;2;13;38;25#140;2;13;50;25#141;2;13;63;25#142;2;13;75;25#143;2;13;88;25#144;2;25;0;25#145;2;25;13;25#146;2;25;25;25#147;2;25;38;25#148;2;25;50;25#149;2;25;63;25#150;2;25;75;25#151;2;25;88;25#152;2;38;0;25#153;2;38;13;25#154;2;38;25;25#155;2;38;38;25#156;2;38;50;25#157;2;38;63;25#158;2;38;75;25#159;2;38;88;25#160;2;50;0;25#161;2;50;13;25#162;2;50;25;25#163;2;50;38;25#164;2;50;50;25#165;2;50;63;25#166;2;50;75;25#167;2;50;88;25#168;2;63;0;25#169;2;63;13;25#170;2;63;25;25#171;2;63;38;25#172;2;63;50;25#173;2;63;63;25#174;2;63;75;25#175;2;63;88;25#176;2;75;0;25#177;2;75;13;25#178;2;75;25;25#179;2;75;38;25#180;2;75;50;25#181;2;75;63;25#182;2;75;75;25#183;2;75;88;25#184;2;88;0;25#185;2;88;13;25#186;2;88;25;25#187;2;88;38;25#188;2;88;50;25#189;2;88;63;25#190;2;88;75;25#191;2;88;88;25#192;2;0;0;38#193;2;0;13;38#194;2;0;25;38#195;2;0;38;38#196;2;0;50;38#197;2;0;63;38#198;2;0;75;38#199;2;0;88;38#200;2;13;0;38#201;2;13;13;38#202;2;13;25;38#203;2;13;38;38#204;2;13;50;38#205;2;13;63;38#206;2;13;75;38#207;2;13;88;38#208;2;25;0;38#209;2;25;13;38#210;2;25;25;38#211;2;25;38;38#212;2;25;50;38#213;2;25;63;38#214;2;25;75;38#215;2;25;88;38#216;2;38;0;38#217;2;38;13;38#218;2;38;25;38#219;2;38;38;38#220;2;38;50;38#221;2;38;63;38#222;2;38;75;38#223;2;38;88;38#224;2;50;0;38#225;2;50;13;38#226;2;50;25;38#227;2;50;38;38#228;2;50;50;38#229;2;50;63;38#230;2;50;75;38#231;2;50;88;38#232;2;63;0;38#233;2;63;13;38#234;2;63;25;38#235;2;63;38;38#236;2;63;50;38#237;2;63;63;38#238;2;63;75;38#239;2;63;88;38#240;2;75;0;38#241;2;75;13;38#242;2;75;25;38#243;2;75;38;38#244;2;75;50;38#245;2;75;63;38#246;2;75;75;38#247;2;75;88;38#248;2;88;0;38#249;2;88;13;38#250;2;88;25;38#251;2;88;38;38#252;2;88;50;38#253;2;88;63;38#254;2;88;75;38#255;2;88;88;38#256;2;0;0;50#257;2;0;13;50#258;2;0;25;50#259;2;0;38;50#260;2;0;50;50#261;2;0;63;50#262;2;0;75;50#263;2;0;88;50#264;2;13;0;50#265;2;13;13;50#266;2;13;25;50#267;2;13;38;50#268;2;13;50;50#269;2;13;63;50#270;2;13;75;50#271;2;13;88;50#272;2;25;0;50#273;2;25;13;50#274;2;25;25;50#275;2;25;38;50#276;2;25;50;50#277;2;25;63;50#278;2;25;75;50#279;2;25;88;50#280;2;38;0;50#281;2;38;13;50#282;2;38;25;50#283;2;38;38;50#284;2;38;50;50#285;2;38;63;50#286;2;38;75;50#287;2;38;88;50#288;2;50;0;50#289;2;50;13;50#290;2;50;25;50#291;2;50;38;50#292;2;50;50;50#293;2;50;63;50#294;2;50;75;50#295;2;50;88;50#296;2;63;0;50#297;2;63;13;50#298;2;63;25;50#299;2;63;38;50#300;2;63;50;50#301;2;63;63;50#302;2;63;75;50#303;2;63;88;50#304;2;75;0;50#305;2;75;13;50#306;2;75;25;50#307;2;75;38;50#308;2;75;50;50#309;2;75;63;50#310;2;75;75;50#311;2;75;88;50#312;2;88;0;50#313;2;88;13;50#314;2;88;25;50#315;2;88;38;50#316;2;88;50;50#317;2;88;63;50#318;2;88;75;50#319;2;88;88;50#320;2;0;0;63#321;2;0;13;63#322;2;0;25;63#323;2;0;38;63#324;2;0;50;63#325;2;0;63;63#326;2;0;75;63#327;2;0;88;63#328;2;13;0;63#329;2;13;13;63#330;2;13;25;63#331;2;13;38;63#332;2;13;50;63#333;2;13;63;63#334;2;13;75;63#335;2;13;88;63#336;2;25;0;63#337;2;25;13;63#338;2;25;25;63#339;2;25;38;63#340;2;25;50;63#341;2;25;63;63#342;2;25;75;63#343;2;25;88;63#344;2;38;0;63#345;2;38;13;63#346;2;38;25;63#347;2;38;38;63#348;2;38;50;63#349;2;38;63;63#350;2;38;75;63#351;2;38;88;63#352;2;50;0;63#353;2;50;13;63#354;2;50;25;63#355;2;50;38;63#356;2;50;50;63#357;2;50;63;63#358;2;50;75;63#359;2;50;88;63#360;2;63;0;63#361;2;63;13;63#362;2;63;25;63#363;2;63;38;63#364;2;63;50;63#365;2;63;63;63#366;2;63;75;63#367;2;63;88;63#368;2;75;0;63#369;2;75;13;63#370;2;75;25;63#371;2;75;38;63#372;2;75;50;63#373;2;75;63;63#374;2;75;75;63#375;2;75;88;63#376;2;88;0;63#377;2;88;13;63#378;2;88;25;63#379;2;88;38;63#380;2;88;50;63#381;2;88;63;63#382;2;88;75;63#383;2;88;88;63#384;2;0;0;75#385;2;0;13;75#386;2;0;25;75#387;2;0;38;75#388;2;0;50;75#389;2;0;63;75#390;2;0;75;75#391;2;0;88;75#392;2;13;0;75#393;2;13;13;75#394;2;13;25;75#395;2;13;38;75#396;2;13;50;75#397;2;13;63;75#398;2;13;75;75#399;2;13;88;75#400;2;25;0;75#401;2;25;13;75#402;2;25;25;75#403;2;25;38;75#404;2;25;50;75#405;2;25;63;75#406;2;25;75;75#407;2;25;88;75#408;2;38;0;75#409;2;38;13;75#410;2;38;25;75#411;2;38;38;75#412;2;38;50;75#413;2;38;63;75#414;2;38;75;75#415;2;38;88;75#416;2;50;0;75#417;2;50;13;75#418;2;50;25;75#419;2;50;38;75#420;2;50;50;75#421;2;50;63;75#422;2;50;75;75#423;2;50;88;75#424;2;63;0;75#425;2;63;13;75#426;2;63;25;75#427;2;63;38;75#428;2;63;50;75#429;2;63;63;75#430;2;63;75;75#431;2;63;88;75#432;2;75;0;75#433;2;75;13;75#434;2;75;25;75#435;2;75;38;75#436;2;75;50;75#437;2;75;63;75#438;2;75;75;75#439;2;75;88;75#440;2;88;0;75#441;2;88;13;75#442;2;88;25;75#443;2;88;38;75#444;2;88;50;75#445;2;88;63;75#446;2;88;75;75#447;2;88;88;75#448;2;0;0;88#449;2;0;13;88#450;2;0;25;88#451;2;0;38;88#452;2;0;50;88#453;2;0;63;88#454;2;0;75;88#455;2;0;88;88#456;2;13;0;88#457;2;13;13;88#458;2;13;25;88#459;2;13;38;88#460;2;13;50;88#461;2;13;63;88#462;2;13;75;88#463;2;13;88;88#464;2;25;0;88#465;2;25;13;88#466;2;25;25;88#467;2;25;38;88#468;2;25;50;88#469;2;25;63;88#470;2;25;75;88#471;2;25;88;88#472;2;38;0;88#473;2;38;13;88#474;2;38;25;88#475;2;38;38;88#476;2;38;50;88#477;2;38;63;88#478;2;38;75;88#479;2;38;88;88#480;2;50;0;88#481;2;50;13;88#482;2;50;25;88#483;2;50;38;88#484;2;50;50;88#485;2;50;63;88#486;2;50;75;88#487;2;50;88;88#488;2;63;0;88#489;2;63;13;88#490;2;63;25;88#491;2;63;38;88#492;2;63;50;88#493;2;63;63;88#494;2;63;75;88#495;2;63;88;88#496;2;75;0;88#497;2;75;13;88#498;2;75;25;88#499;2;75;38;88#500;2;75;50;88#501;2;75;63;88#502;2;75;75;88#503;2;75;88;88#504;2;88;0;88#505;2;88;13;88#506;2;88;25;88#507;2;88;38;88#508;2;88;50;88#509;2;88;63;88#510;2;88;75;88#511;2;88;88;88#0!10~$#1!10?!10~$#2!20?!10~$#3!30?!10~$#4!40?!10~$#5!50?!10~$#6!60?!10~$#7!70?!10~$#8!80?!10~$#9!90?!10~$#10!100?!10~$#11!110?!10~$#12!120?!10~$#13!130?!10~$#14!140?!10~$#15!150?!10~$#16!160?!10~$#17!170?!10~$#18!180?!10~$#19!190?!10~$#20!200?!10~$#21!210?!10~$#22!220?!10~$#23!230?!10~$#24!240?!10~$#25!250?!10~$#26!260?!10~$#27!270?!10~$#28!280?!10~$#29!290?!10~$#30!300?!10~$#31!310?!10~$#32!320?!10~$#33!330?!10~$#34!340?!10~$#35!350?!10~$#36!360?!10~$#37!370?!10~$#38!380?!10~$#39!390?!10~$#40!400?!10~$#41!410?!10~$#42!420?!10~$#43!430?!10~$#44!440?!10~$#45!450?!10~$#46!460?!10~$#47!470?!10~$#48!480?!10~$#49!490?!10~$#50!500?!10~$#51!510?!10~$#52!520?!10~$#53!530?!10~$#54!540?!10~$#55!550?!10~$#56!560?!10~$#57!570?!10~$#58!580?!10~$#59!590?!10~$#60!600?!10~$#61!610?!10~$#62!620?!10~$#63!630?!10~$- 2 | #0!10N$#64!10o$#1!10?!10N$#65!10?!10o$#2!20?!10N$#66!20?!10o$#3!30?!10N$#67!30?!10o$#4!40?!10N$#68!40?!10o$#5!50?!10N$#69!50?!10o$#6!60?!10N$#70!60?!10o$#7!70?!10N$#71!70?!10o$#8!80?!10N$#72!80?!10o$#9!90?!10N$#73!90?!10o$#10!100?!10N$#74!100?!10o$#11!110?!10N$#75!110?!10o$#12!120?!10N$#76!120?!10o$#13!130?!10N$#77!130?!10o$#14!140?!10N$#78!140?!10o$#15!150?!10N$#79!150?!10o$#16!160?!10N$#80!160?!10o$#17!170?!10N$#81!170?!10o$#18!180?!10N$#82!180?!10o$#19!190?!10N$#83!190?!10o$#20!200?!10N$#84!200?!10o$#21!210?!10N$#85!210?!10o$#22!220?!10N$#86!220?!10o$#23!230?!10N$#87!230?!10o$#24!240?!10N$#88!240?!10o$#25!250?!10N$#89!250?!10o$#26!260?!10N$#90!260?!10o$#27!270?!10N$#91!270?!10o$#28!280?!10N$#92!280?!10o$#29!290?!10N$#93!290?!10o$#30!300?!10N$#94!300?!10o$#31!310?!10N$#95!310?!10o$#32!320?!10N$#96!320?!10o$#33!330?!10N$#97!330?!10o$#34!340?!10N$#98!340?!10o$#35!350?!10N$#99!350?!10o$#36!360?!10N$#100!360?!10o$#37!370?!10N$#101!370?!10o$#38!380?!10N$#102!380?!10o$#39!390?!10N$#103!390?!10o$#40!400?!10N$#104!400?!10o$#41!410?!10N$#105!410?!10o$#42!420?!10N$#106!420?!10o$#43!430?!10N$#107!430?!10o$#44!440?!10N$#108!440?!10o$#45!450?!10N$#109!450?!10o$#46!460?!10N$#110!460?!10o$#47!470?!10N$#111!470?!10o$#48!480?!10N$#112!480?!10o$#49!490?!10N$#113!490?!10o$#50!500?!10N$#114!500?!10o$#51!510?!10N$#115!510?!10o$#52!520?!10N$#116!520?!10o$#53!530?!10N$#117!530?!10o$#54!540?!10N$#118!540?!10o$#55!550?!10N$#119!550?!10o$#56!560?!10N$#120!560?!10o$#57!570?!10N$#121!570?!10o$#58!580?!10N$#122!580?!10o$#59!590?!10N$#123!590?!10o$#60!600?!10N$#124!600?!10o$#61!610?!10N$#125!610?!10o$#62!620?!10N$#126!620?!10o$#63!630?!10N$#127!630?!10o$- 3 | #64!10~$#65!10?!10~$#66!20?!10~$#67!30?!10~$#68!40?!10~$#69!50?!10~$#70!60?!10~$#71!70?!10~$#72!80?!10~$#73!90?!10~$#74!100?!10~$#75!110?!10~$#76!120?!10~$#77!130?!10~$#78!140?!10~$#79!150?!10~$#80!160?!10~$#81!170?!10~$#82!180?!10~$#83!190?!10~$#84!200?!10~$#85!210?!10~$#86!220?!10~$#87!230?!10~$#88!240?!10~$#89!250?!10~$#90!260?!10~$#91!270?!10~$#92!280?!10~$#93!290?!10~$#94!300?!10~$#95!310?!10~$#96!320?!10~$#97!330?!10~$#98!340?!10~$#99!350?!10~$#100!360?!10~$#101!370?!10~$#102!380?!10~$#103!390?!10~$#104!400?!10~$#105!410?!10~$#106!420?!10~$#107!430?!10~$#108!440?!10~$#109!450?!10~$#110!460?!10~$#111!470?!10~$#112!480?!10~$#113!490?!10~$#114!500?!10~$#115!510?!10~$#116!520?!10~$#117!530?!10~$#118!540?!10~$#119!550?!10~$#120!560?!10~$#121!570?!10~$#122!580?!10~$#123!590?!10~$#124!600?!10~$#125!610?!10~$#126!620?!10~$#127!630?!10~$- 4 | #64!10B$#128!10{$#65!10?!10B$#129!10?!10{$#66!20?!10B$#130!20?!10{$#67!30?!10B$#131!30?!10{$#68!40?!10B$#132!40?!10{$#69!50?!10B$#133!50?!10{$#70!60?!10B$#134!60?!10{$#71!70?!10B$#135!70?!10{$#72!80?!10B$#136!80?!10{$#73!90?!10B$#137!90?!10{$#74!100?!10B$#138!100?!10{$#75!110?!10B$#139!110?!10{$#76!120?!10B$#140!120?!10{$#77!130?!10B$#141!130?!10{$#78!140?!10B$#142!140?!10{$#79!150?!10B$#143!150?!10{$#80!160?!10B$#144!160?!10{$#81!170?!10B$#145!170?!10{$#82!180?!10B$#146!180?!10{$#83!190?!10B$#147!190?!10{$#84!200?!10B$#148!200?!10{$#85!210?!10B$#149!210?!10{$#86!220?!10B$#150!220?!10{$#87!230?!10B$#151!230?!10{$#88!240?!10B$#152!240?!10{$#89!250?!10B$#153!250?!10{$#90!260?!10B$#154!260?!10{$#91!270?!10B$#155!270?!10{$#92!280?!10B$#156!280?!10{$#93!290?!10B$#157!290?!10{$#94!300?!10B$#158!300?!10{$#95!310?!10B$#159!310?!10{$#96!320?!10B$#160!320?!10{$#97!330?!10B$#161!330?!10{$#98!340?!10B$#162!340?!10{$#99!350?!10B$#163!350?!10{$#100!360?!10B$#164!360?!10{$#101!370?!10B$#165!370?!10{$#102!380?!10B$#166!380?!10{$#103!390?!10B$#167!390?!10{$#104!400?!10B$#168!400?!10{$#105!410?!10B$#169!410?!10{$#106!420?!10B$#170!420?!10{$#107!430?!10B$#171!430?!10{$#108!440?!10B$#172!440?!10{$#109!450?!10B$#173!450?!10{$#110!460?!10B$#174!460?!10{$#111!470?!10B$#175!470?!10{$#112!480?!10B$#176!480?!10{$#113!490?!10B$#177!490?!10{$#114!500?!10B$#178!500?!10{$#115!510?!10B$#179!510?!10{$#116!520?!10B$#180!520?!10{$#117!530?!10B$#181!530?!10{$#118!540?!10B$#182!540?!10{$#119!550?!10B$#183!550?!10{$#120!560?!10B$#184!560?!10{$#121!570?!10B$#185!570?!10{$#122!580?!10B$#186!580?!10{$#123!590?!10B$#187!590?!10{$#124!600?!10B$#188!600?!10{$#125!610?!10B$#189!610?!10{$#126!620?!10B$#190!620?!10{$#127!630?!10B$#191!630?!10{$- 5 | #128!10~$#129!10?!10~$#130!20?!10~$#131!30?!10~$#132!40?!10~$#133!50?!10~$#134!60?!10~$#135!70?!10~$#136!80?!10~$#137!90?!10~$#138!100?!10~$#139!110?!10~$#140!120?!10~$#141!130?!10~$#142!140?!10~$#143!150?!10~$#144!160?!10~$#145!170?!10~$#146!180?!10~$#147!190?!10~$#148!200?!10~$#149!210?!10~$#150!220?!10~$#151!230?!10~$#152!240?!10~$#153!250?!10~$#154!260?!10~$#155!270?!10~$#156!280?!10~$#157!290?!10~$#158!300?!10~$#159!310?!10~$#160!320?!10~$#161!330?!10~$#162!340?!10~$#163!350?!10~$#164!360?!10~$#165!370?!10~$#166!380?!10~$#167!390?!10~$#168!400?!10~$#169!410?!10~$#170!420?!10~$#171!430?!10~$#172!440?!10~$#173!450?!10~$#174!460?!10~$#175!470?!10~$#176!480?!10~$#177!490?!10~$#178!500?!10~$#179!510?!10~$#180!520?!10~$#181!530?!10~$#182!540?!10~$#183!550?!10~$#184!560?!10~$#185!570?!10~$#186!580?!10~$#187!590?!10~$#188!600?!10~$#189!610?!10~$#190!620?!10~$#191!630?!10~$- 6 | #192!10~$#193!10?!10~$#194!20?!10~$#195!30?!10~$#196!40?!10~$#197!50?!10~$#198!60?!10~$#199!70?!10~$#200!80?!10~$#201!90?!10~$#202!100?!10~$#203!110?!10~$#204!120?!10~$#205!130?!10~$#206!140?!10~$#207!150?!10~$#208!160?!10~$#209!170?!10~$#210!180?!10~$#211!190?!10~$#212!200?!10~$#213!210?!10~$#214!220?!10~$#215!230?!10~$#216!240?!10~$#217!250?!10~$#218!260?!10~$#219!270?!10~$#220!280?!10~$#221!290?!10~$#222!300?!10~$#223!310?!10~$#224!320?!10~$#225!330?!10~$#226!340?!10~$#227!350?!10~$#228!360?!10~$#229!370?!10~$#230!380?!10~$#231!390?!10~$#232!400?!10~$#233!410?!10~$#234!420?!10~$#235!430?!10~$#236!440?!10~$#237!450?!10~$#238!460?!10~$#239!470?!10~$#240!480?!10~$#241!490?!10~$#242!500?!10~$#243!510?!10~$#244!520?!10~$#245!530?!10~$#246!540?!10~$#247!550?!10~$#248!560?!10~$#249!570?!10~$#250!580?!10~$#251!590?!10~$#252!600?!10~$#253!610?!10~$#254!620?!10~$#255!630?!10~$- 7 | #192!10N$#256!10o$#193!10?!10N$#257!10?!10o$#194!20?!10N$#258!20?!10o$#195!30?!10N$#259!30?!10o$#196!40?!10N$#260!40?!10o$#197!50?!10N$#261!50?!10o$#198!60?!10N$#262!60?!10o$#199!70?!10N$#263!70?!10o$#200!80?!10N$#264!80?!10o$#201!90?!10N$#265!90?!10o$#202!100?!10N$#266!100?!10o$#203!110?!10N$#267!110?!10o$#204!120?!10N$#268!120?!10o$#205!130?!10N$#269!130?!10o$#206!140?!10N$#270!140?!10o$#207!150?!10N$#271!150?!10o$#208!160?!10N$#272!160?!10o$#209!170?!10N$#273!170?!10o$#210!180?!10N$#274!180?!10o$#211!190?!10N$#275!190?!10o$#212!200?!10N$#276!200?!10o$#213!210?!10N$#277!210?!10o$#214!220?!10N$#278!220?!10o$#215!230?!10N$#279!230?!10o$#216!240?!10N$#280!240?!10o$#217!250?!10N$#281!250?!10o$#218!260?!10N$#282!260?!10o$#219!270?!10N$#283!270?!10o$#220!280?!10N$#284!280?!10o$#221!290?!10N$#285!290?!10o$#222!300?!10N$#286!300?!10o$#223!310?!10N$#287!310?!10o$#224!320?!10N$#288!320?!10o$#225!330?!10N$#289!330?!10o$#226!340?!10N$#290!340?!10o$#227!350?!10N$#291!350?!10o$#228!360?!10N$#292!360?!10o$#229!370?!10N$#293!370?!10o$#230!380?!10N$#294!380?!10o$#231!390?!10N$#295!390?!10o$#232!400?!10N$#296!400?!10o$#233!410?!10N$#297!410?!10o$#234!420?!10N$#298!420?!10o$#235!430?!10N$#299!430?!10o$#236!440?!10N$#300!440?!10o$#237!450?!10N$#301!450?!10o$#238!460?!10N$#302!460?!10o$#239!470?!10N$#303!470?!10o$#240!480?!10N$#304!480?!10o$#241!490?!10N$#305!490?!10o$#242!500?!10N$#306!500?!10o$#243!510?!10N$#307!510?!10o$#244!520?!10N$#308!520?!10o$#245!530?!10N$#309!530?!10o$#246!540?!10N$#310!540?!10o$#247!550?!10N$#311!550?!10o$#248!560?!10N$#312!560?!10o$#249!570?!10N$#313!570?!10o$#250!580?!10N$#314!580?!10o$#251!590?!10N$#315!590?!10o$#252!600?!10N$#316!600?!10o$#253!610?!10N$#317!610?!10o$#254!620?!10N$#318!620?!10o$#255!630?!10N$#319!630?!10o$- 8 | #256!10~$#257!10?!10~$#258!20?!10~$#259!30?!10~$#260!40?!10~$#261!50?!10~$#262!60?!10~$#263!70?!10~$#264!80?!10~$#265!90?!10~$#266!100?!10~$#267!110?!10~$#268!120?!10~$#269!130?!10~$#270!140?!10~$#271!150?!10~$#272!160?!10~$#273!170?!10~$#274!180?!10~$#275!190?!10~$#276!200?!10~$#277!210?!10~$#278!220?!10~$#279!230?!10~$#280!240?!10~$#281!250?!10~$#282!260?!10~$#283!270?!10~$#284!280?!10~$#285!290?!10~$#286!300?!10~$#287!310?!10~$#288!320?!10~$#289!330?!10~$#290!340?!10~$#291!350?!10~$#292!360?!10~$#293!370?!10~$#294!380?!10~$#295!390?!10~$#296!400?!10~$#297!410?!10~$#298!420?!10~$#299!430?!10~$#300!440?!10~$#301!450?!10~$#302!460?!10~$#303!470?!10~$#304!480?!10~$#305!490?!10~$#306!500?!10~$#307!510?!10~$#308!520?!10~$#309!530?!10~$#310!540?!10~$#311!550?!10~$#312!560?!10~$#313!570?!10~$#314!580?!10~$#315!590?!10~$#316!600?!10~$#317!610?!10~$#318!620?!10~$#319!630?!10~$- 9 | #256!10B$#320!10{$#257!10?!10B$#321!10?!10{$#258!20?!10B$#322!20?!10{$#259!30?!10B$#323!30?!10{$#260!40?!10B$#324!40?!10{$#261!50?!10B$#325!50?!10{$#262!60?!10B$#326!60?!10{$#263!70?!10B$#327!70?!10{$#264!80?!10B$#328!80?!10{$#265!90?!10B$#329!90?!10{$#266!100?!10B$#330!100?!10{$#267!110?!10B$#331!110?!10{$#268!120?!10B$#332!120?!10{$#269!130?!10B$#333!130?!10{$#270!140?!10B$#334!140?!10{$#271!150?!10B$#335!150?!10{$#272!160?!10B$#336!160?!10{$#273!170?!10B$#337!170?!10{$#274!180?!10B$#338!180?!10{$#275!190?!10B$#339!190?!10{$#276!200?!10B$#340!200?!10{$#277!210?!10B$#341!210?!10{$#278!220?!10B$#342!220?!10{$#279!230?!10B$#343!230?!10{$#280!240?!10B$#344!240?!10{$#281!250?!10B$#345!250?!10{$#282!260?!10B$#346!260?!10{$#283!270?!10B$#347!270?!10{$#284!280?!10B$#348!280?!10{$#285!290?!10B$#349!290?!10{$#286!300?!10B$#350!300?!10{$#287!310?!10B$#351!310?!10{$#288!320?!10B$#352!320?!10{$#289!330?!10B$#353!330?!10{$#290!340?!10B$#354!340?!10{$#291!350?!10B$#355!350?!10{$#292!360?!10B$#356!360?!10{$#293!370?!10B$#357!370?!10{$#294!380?!10B$#358!380?!10{$#295!390?!10B$#359!390?!10{$#296!400?!10B$#360!400?!10{$#297!410?!10B$#361!410?!10{$#298!420?!10B$#362!420?!10{$#299!430?!10B$#363!430?!10{$#300!440?!10B$#364!440?!10{$#301!450?!10B$#365!450?!10{$#302!460?!10B$#366!460?!10{$#303!470?!10B$#367!470?!10{$#304!480?!10B$#368!480?!10{$#305!490?!10B$#369!490?!10{$#306!500?!10B$#370!500?!10{$#307!510?!10B$#371!510?!10{$#308!520?!10B$#372!520?!10{$#309!530?!10B$#373!530?!10{$#310!540?!10B$#374!540?!10{$#311!550?!10B$#375!550?!10{$#312!560?!10B$#376!560?!10{$#313!570?!10B$#377!570?!10{$#314!580?!10B$#378!580?!10{$#315!590?!10B$#379!590?!10{$#316!600?!10B$#380!600?!10{$#317!610?!10B$#381!610?!10{$#318!620?!10B$#382!620?!10{$#319!630?!10B$#383!630?!10{$- 10 | #320!10~$#321!10?!10~$#322!20?!10~$#323!30?!10~$#324!40?!10~$#325!50?!10~$#326!60?!10~$#327!70?!10~$#328!80?!10~$#329!90?!10~$#330!100?!10~$#331!110?!10~$#332!120?!10~$#333!130?!10~$#334!140?!10~$#335!150?!10~$#336!160?!10~$#337!170?!10~$#338!180?!10~$#339!190?!10~$#340!200?!10~$#341!210?!10~$#342!220?!10~$#343!230?!10~$#344!240?!10~$#345!250?!10~$#346!260?!10~$#347!270?!10~$#348!280?!10~$#349!290?!10~$#350!300?!10~$#351!310?!10~$#352!320?!10~$#353!330?!10~$#354!340?!10~$#355!350?!10~$#356!360?!10~$#357!370?!10~$#358!380?!10~$#359!390?!10~$#360!400?!10~$#361!410?!10~$#362!420?!10~$#363!430?!10~$#364!440?!10~$#365!450?!10~$#366!460?!10~$#367!470?!10~$#368!480?!10~$#369!490?!10~$#370!500?!10~$#371!510?!10~$#372!520?!10~$#373!530?!10~$#374!540?!10~$#375!550?!10~$#376!560?!10~$#377!570?!10~$#378!580?!10~$#379!590?!10~$#380!600?!10~$#381!610?!10~$#382!620?!10~$#383!630?!10~$- 11 | #384!10~$#385!10?!10~$#386!20?!10~$#387!30?!10~$#388!40?!10~$#389!50?!10~$#390!60?!10~$#391!70?!10~$#392!80?!10~$#393!90?!10~$#394!100?!10~$#395!110?!10~$#396!120?!10~$#397!130?!10~$#398!140?!10~$#399!150?!10~$#400!160?!10~$#401!170?!10~$#402!180?!10~$#403!190?!10~$#404!200?!10~$#405!210?!10~$#406!220?!10~$#407!230?!10~$#408!240?!10~$#409!250?!10~$#410!260?!10~$#411!270?!10~$#412!280?!10~$#413!290?!10~$#414!300?!10~$#415!310?!10~$#416!320?!10~$#417!330?!10~$#418!340?!10~$#419!350?!10~$#420!360?!10~$#421!370?!10~$#422!380?!10~$#423!390?!10~$#424!400?!10~$#425!410?!10~$#426!420?!10~$#427!430?!10~$#428!440?!10~$#429!450?!10~$#430!460?!10~$#431!470?!10~$#432!480?!10~$#433!490?!10~$#434!500?!10~$#435!510?!10~$#436!520?!10~$#437!530?!10~$#438!540?!10~$#439!550?!10~$#440!560?!10~$#441!570?!10~$#442!580?!10~$#443!590?!10~$#444!600?!10~$#445!610?!10~$#446!620?!10~$#447!630?!10~$- 12 | #384!10N$#448!10o$#385!10?!10N$#449!10?!10o$#386!20?!10N$#450!20?!10o$#387!30?!10N$#451!30?!10o$#388!40?!10N$#452!40?!10o$#389!50?!10N$#453!50?!10o$#390!60?!10N$#454!60?!10o$#391!70?!10N$#455!70?!10o$#392!80?!10N$#456!80?!10o$#393!90?!10N$#457!90?!10o$#394!100?!10N$#458!100?!10o$#395!110?!10N$#459!110?!10o$#396!120?!10N$#460!120?!10o$#397!130?!10N$#461!130?!10o$#398!140?!10N$#462!140?!10o$#399!150?!10N$#463!150?!10o$#400!160?!10N$#464!160?!10o$#401!170?!10N$#465!170?!10o$#402!180?!10N$#466!180?!10o$#403!190?!10N$#467!190?!10o$#404!200?!10N$#468!200?!10o$#405!210?!10N$#469!210?!10o$#406!220?!10N$#470!220?!10o$#407!230?!10N$#471!230?!10o$#408!240?!10N$#472!240?!10o$#409!250?!10N$#473!250?!10o$#410!260?!10N$#474!260?!10o$#411!270?!10N$#475!270?!10o$#412!280?!10N$#476!280?!10o$#413!290?!10N$#477!290?!10o$#414!300?!10N$#478!300?!10o$#415!310?!10N$#479!310?!10o$#416!320?!10N$#480!320?!10o$#417!330?!10N$#481!330?!10o$#418!340?!10N$#482!340?!10o$#419!350?!10N$#483!350?!10o$#420!360?!10N$#484!360?!10o$#421!370?!10N$#485!370?!10o$#422!380?!10N$#486!380?!10o$#423!390?!10N$#487!390?!10o$#424!400?!10N$#488!400?!10o$#425!410?!10N$#489!410?!10o$#426!420?!10N$#490!420?!10o$#427!430?!10N$#491!430?!10o$#428!440?!10N$#492!440?!10o$#429!450?!10N$#493!450?!10o$#430!460?!10N$#494!460?!10o$#431!470?!10N$#495!470?!10o$#432!480?!10N$#496!480?!10o$#433!490?!10N$#497!490?!10o$#434!500?!10N$#498!500?!10o$#435!510?!10N$#499!510?!10o$#436!520?!10N$#500!520?!10o$#437!530?!10N$#501!530?!10o$#438!540?!10N$#502!540?!10o$#439!550?!10N$#503!550?!10o$#440!560?!10N$#504!560?!10o$#441!570?!10N$#505!570?!10o$#442!580?!10N$#506!580?!10o$#443!590?!10N$#507!590?!10o$#444!600?!10N$#508!600?!10o$#445!610?!10N$#509!610?!10o$#446!620?!10N$#510!620?!10o$#447!630?!10N$#511!630?!10o$- 13 | #448!10~$#449!10?!10~$#450!20?!10~$#451!30?!10~$#452!40?!10~$#453!50?!10~$#454!60?!10~$#455!70?!10~$#456!80?!10~$#457!90?!10~$#458!100?!10~$#459!110?!10~$#460!120?!10~$#461!130?!10~$#462!140?!10~$#463!150?!10~$#464!160?!10~$#465!170?!10~$#466!180?!10~$#467!190?!10~$#468!200?!10~$#469!210?!10~$#470!220?!10~$#471!230?!10~$#472!240?!10~$#473!250?!10~$#474!260?!10~$#475!270?!10~$#476!280?!10~$#477!290?!10~$#478!300?!10~$#479!310?!10~$#480!320?!10~$#481!330?!10~$#482!340?!10~$#483!350?!10~$#484!360?!10~$#485!370?!10~$#486!380?!10~$#487!390?!10~$#488!400?!10~$#489!410?!10~$#490!420?!10~$#491!430?!10~$#492!440?!10~$#493!450?!10~$#494!460?!10~$#495!470?!10~$#496!480?!10~$#497!490?!10~$#498!500?!10~$#499!510?!10~$#500!520?!10~$#501!530?!10~$#502!540?!10~$#503!550?!10~$#504!560?!10~$#505!570?!10~$#506!580?!10~$#507!590?!10~$#508!600?!10~$#509!610?!10~$#510!620?!10~$#511!630?!10~$- 14 | #448!10B$#449!10?!10B$#450!20?!10B$#451!30?!10B$#452!40?!10B$#453!50?!10B$#454!60?!10B$#455!70?!10B$#456!80?!10B$#457!90?!10B$#458!100?!10B$#459!110?!10B$#460!120?!10B$#461!130?!10B$#462!140?!10B$#463!150?!10B$#464!160?!10B$#465!170?!10B$#466!180?!10B$#467!190?!10B$#468!200?!10B$#469!210?!10B$#470!220?!10B$#471!230?!10B$#472!240?!10B$#473!250?!10B$#474!260?!10B$#475!270?!10B$#476!280?!10B$#477!290?!10B$#478!300?!10B$#479!310?!10B$#480!320?!10B$#481!330?!10B$#482!340?!10B$#483!350?!10B$#484!360?!10B$#485!370?!10B$#486!380?!10B$#487!390?!10B$#488!400?!10B$#489!410?!10B$#490!420?!10B$#491!430?!10B$#492!440?!10B$#493!450?!10B$#494!460?!10B$#495!470?!10B$#496!480?!10B$#497!490?!10B$#498!500?!10B$#499!510?!10B$#500!520?!10B$#501!530?!10B$#502!540?!10B$#503!550?!10B$#504!560?!10B$#505!570?!10B$#506!580?!10B$#507!590?!10B$#508!600?!10B$#509!610?!10B$#510!620?!10B$#511!630?!10B$\ --------------------------------------------------------------------------------