Skip to content

Commit 0e35104

Browse files
committed
Re-factor ANSI color handling
The implementation is based on Python code from nbconvert.filters.ansi2html(). Among other things, this fixes #988.
1 parent 3c4f6e9 commit 0e35104

File tree

4 files changed

+226
-144
lines changed

4 files changed

+226
-144
lines changed

notebook/static/base/js/utils.js

+199-141
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
define([
55
'codemirror/lib/codemirror',
66
'moment',
7+
'underscore',
78
// silently upgrades CodeMirror
89
'codemirror/mode/meta',
9-
], function(CodeMirror, moment){
10+
], function(CodeMirror, moment, _){
1011
"use strict";
1112

1213
// keep track of which extensions have been loaded already
@@ -192,165 +193,222 @@ define([
192193
return uuid;
193194
};
194195

195-
196-
//Fix raw text to parse correctly in crazy XML
197-
function xmlencode(string) {
198-
return string.replace(/\&/g,'&'+'amp;')
199-
.replace(/</g,'&'+'lt;')
200-
.replace(/>/g,'&'+'gt;')
201-
.replace(/\'/g,'&'+'apos;')
202-
.replace(/\"/g,'&'+'quot;')
203-
.replace(/`/g,'&'+'#96;');
196+
var _ANSI_COLORS = [
197+
"ansi-black",
198+
"ansi-red",
199+
"ansi-green",
200+
"ansi-yellow",
201+
"ansi-blue",
202+
"ansi-magenta",
203+
"ansi-cyan",
204+
"ansi-white",
205+
"ansi-black-intense",
206+
"ansi-red-intense",
207+
"ansi-green-intense",
208+
"ansi-yellow-intense",
209+
"ansi-blue-intense",
210+
"ansi-magenta-intense",
211+
"ansi-cyan-intense",
212+
"ansi-white-intense",
213+
];
214+
215+
function _parseNumbers(text) {
216+
var numbers = text.split(";");
217+
numbers = numbers.map(text => text ? Number.parseInt(text) : 0);
218+
if (numbers.some(Number.isNaN)) {
219+
numbers = []; // Ignored: Invalid color specification
220+
}
221+
return numbers;
204222
}
205223

206-
207-
//Map from terminal commands to CSS classes
208-
var ansi_colormap = {
209-
"01":"ansibold",
210-
211-
"30":"ansiblack",
212-
"31":"ansired",
213-
"32":"ansigreen",
214-
"33":"ansiyellow",
215-
"34":"ansiblue",
216-
"35":"ansipurple",
217-
"36":"ansicyan",
218-
"37":"ansigray",
219-
220-
"40":"ansibgblack",
221-
"41":"ansibgred",
222-
"42":"ansibggreen",
223-
"43":"ansibgyellow",
224-
"44":"ansibgblue",
225-
"45":"ansibgpurple",
226-
"46":"ansibgcyan",
227-
"47":"ansibggray"
228-
};
229-
230-
function _process_numbers(attrs, numbers) {
231-
// process ansi escapes
224+
function _getExtendedColors(numbers) {
225+
var r, g, b;
232226
var n = numbers.shift();
233-
if (ansi_colormap[n]) {
234-
if ( ! attrs["class"] ) {
235-
attrs["class"] = ansi_colormap[n];
236-
} else {
237-
attrs["class"] += " " + ansi_colormap[n];
238-
}
239-
} else if (n == "38" || n == "48") {
240-
// VT100 256 color or 24 bit RGB
241-
if (numbers.length < 2) {
242-
console.log("Not enough fields for VT100 color", numbers);
243-
return;
227+
if (n === 2 && numbers.length >= 3) {
228+
// 24-bit RGB
229+
r = numbers.shift();
230+
g = numbers.shift();
231+
b = numbers.shift();
232+
if ([r, g, b].some(c => c < 0 || 255 < c)) {
233+
throw new RangeError();
244234
}
245-
246-
var index_or_rgb = numbers.shift();
247-
var r,g,b;
248-
if (index_or_rgb == "5") {
249-
// 256 color
250-
var idx = parseInt(numbers.shift(), 10);
251-
if (idx < 16) {
252-
// indexed ANSI
253-
// ignore bright / non-bright distinction
254-
idx = idx % 8;
255-
var ansiclass = ansi_colormap[n[0] + (idx % 8).toString()];
256-
if ( ! attrs["class"] ) {
257-
attrs["class"] = ansiclass;
258-
} else {
259-
attrs["class"] += " " + ansiclass;
260-
}
261-
return;
262-
} else if (idx < 232) {
263-
// 216 color 6x6x6 RGB
264-
idx = idx - 16;
265-
b = idx % 6;
266-
g = Math.floor(idx / 6) % 6;
267-
r = Math.floor(idx / 36) % 6;
268-
// convert to rgb
269-
r = (r * 51);
270-
g = (g * 51);
271-
b = (b * 51);
272-
} else {
273-
// grayscale
274-
idx = idx - 231;
275-
// it's 1-24 and should *not* include black or white,
276-
// so a 26 point scale
277-
r = g = b = Math.floor(idx * 256 / 26);
278-
}
279-
} else if (index_or_rgb == "2") {
280-
// Simple 24 bit RGB
281-
if (numbers.length > 3) {
282-
console.log("Not enough fields for RGB", numbers);
283-
return;
284-
}
285-
r = numbers.shift();
286-
g = numbers.shift();
287-
b = numbers.shift();
235+
} else if (n === 5 && numbers.length >= 1) {
236+
// 256 colors
237+
var idx = numbers.shift();
238+
if (idx < 0) {
239+
throw new RangeError();
240+
} else if (idx < 16) {
241+
// 16 default terminal colors
242+
return idx;
243+
} else if (idx < 232) {
244+
// 6x6x6 color cube, see http://stackoverflow.com/a/27165165/500098
245+
r = Math.floor((idx - 16) / 36);
246+
r = r > 0 ? 55 + r * 40 : 0;
247+
g = Math.floor(((idx - 16) % 36) / 6);
248+
g = g > 0 ? 55 + g * 40 : 0;
249+
b = (idx - 16) % 6;
250+
b = b > 0 ? 55 + b * 40 : 0;
251+
} else if (idx < 256) {
252+
// grayscale, see http://stackoverflow.com/a/27165165/500098
253+
r = g = b = (idx - 232) * 10 + 8;
288254
} else {
289-
console.log("unrecognized control", numbers);
290-
return;
291-
}
292-
if (r !== undefined) {
293-
// apply the rgb color
294-
var line;
295-
if (n == "38") {
296-
line = "color: ";
297-
} else {
298-
line = "background-color: ";
299-
}
300-
line = line + "rgb(" + r + "," + g + "," + b + ");";
301-
if ( !attrs.style ) {
302-
attrs.style = line;
303-
} else {
304-
attrs.style += " " + line;
305-
}
255+
throw new RangeError();
306256
}
257+
} else {
258+
throw new RangeError();
307259
}
260+
return [r, g, b];
308261
}
309262

310263
function _ansispan(str) {
311-
// ansispan function adapted from github.com/mmalecki/ansispan (MIT License)
312-
// regular ansi escapes (using the table above)
313-
var is_open = false;
314-
return str.replace(/\033\[(0?[01]|22|39)?([;\d]+)?m/g, function(match, prefix, pattern) {
315-
if (!pattern || prefix === '39') {
316-
// [(01|22|39|)m close spans
317-
if (is_open) {
318-
is_open = false;
319-
return "</span>";
320-
} else {
321-
return "";
322-
}
264+
var ansi_re = /\x1b\[(.*?)([@-~])/g;
265+
var fg = [];
266+
var bg = [];
267+
var bold = false;
268+
var match;
269+
var out = [];
270+
var numbers = [];
271+
var start = 0;
272+
273+
str += "\x1b[m"; // Ensure markup for trailing text
274+
while ((match = ansi_re.exec(str))) {
275+
if (match[2] === "m") {
276+
numbers = _parseNumbers(match[1]);
323277
} else {
324-
is_open = true;
278+
// Ignored: Not a color code
279+
}
280+
var chunk = str.substring(start, match.index);
281+
if (chunk) {
282+
if (bold && 0 <= fg && fg < 8) {
283+
fg += 8; // Bold text uses "intense" colors
284+
}
285+
var classes = [];
286+
var styles = [];
325287

326-
// consume sequence of color escapes
327-
var numbers = pattern.match(/\d+/g);
328-
var attrs = {};
329-
while (numbers.length > 0) {
330-
_process_numbers(attrs, numbers);
288+
if (typeof fg === "number") {
289+
classes.push(_ANSI_COLORS[fg] + "-fg");
290+
} else if (fg.length) {
291+
styles.push(`color: rgb(${fg})`);
331292
}
332293

333-
var span = "<span ";
334-
Object.keys(attrs).map(function (attr) {
335-
span = span + " " + attr + '="' + attrs[attr] + '"';
336-
});
337-
return span + ">";
294+
if (typeof bg === "number") {
295+
classes.push(_ANSI_COLORS[bg] + "-bg");
296+
} else if (bg.length) {
297+
styles.push(`background-color: rgb(${bg})`);
298+
}
299+
300+
if (bold) {
301+
classes.push("ansi-bold");
302+
}
303+
304+
if (classes.length || styles.length) {
305+
out.push("<span");
306+
if (classes.length) {
307+
out.push(` class="${classes.join(" ")}"`);
308+
}
309+
if (styles.length) {
310+
out.push(` style="${styles.join("; ")}"`);
311+
}
312+
out.push(">");
313+
out.push(chunk);
314+
out.push("</span>");
315+
} else {
316+
out.push(chunk);
317+
}
338318
}
339-
});
319+
start = ansi_re.lastIndex;
320+
321+
while (numbers.length) {
322+
var n = numbers.shift();
323+
switch (n) {
324+
case 0:
325+
fg = bg = [];
326+
bold = false;
327+
break;
328+
case 1:
329+
case 5:
330+
bold = true;
331+
break;
332+
case 21:
333+
case 22:
334+
bold = false;
335+
break;
336+
case 30:
337+
case 31:
338+
case 32:
339+
case 33:
340+
case 34:
341+
case 35:
342+
case 36:
343+
case 37:
344+
fg = n - 30;
345+
break;
346+
case 38:
347+
try {
348+
fg = _getExtendedColors(numbers);
349+
} catch(e) {
350+
numbers.length = 0;
351+
}
352+
break;
353+
case 39:
354+
fg = [];
355+
break;
356+
case 40:
357+
case 41:
358+
case 42:
359+
case 43:
360+
case 44:
361+
case 45:
362+
case 46:
363+
case 47:
364+
bg = n - 40;
365+
break;
366+
case 48:
367+
try {
368+
bg = _getExtendedColors(numbers);
369+
} catch(e) {
370+
numbers.length = 0;
371+
}
372+
break;
373+
case 49:
374+
bg = [];
375+
break;
376+
case 90:
377+
case 91:
378+
case 92:
379+
case 93:
380+
case 94:
381+
case 95:
382+
case 96:
383+
case 97:
384+
fg = n - 90 + 8;
385+
break;
386+
case 100:
387+
case 101:
388+
case 102:
389+
case 103:
390+
case 104:
391+
case 105:
392+
case 106:
393+
case 107:
394+
bg = n - 100 + 8;
395+
break;
396+
default:
397+
// Unknown codes are ignored
398+
}
399+
}
400+
}
401+
return out.join("");
340402
}
341403

342-
// Transform ANSI color escape codes into HTML <span> tags with css
343-
// classes listed in the above ansi_colormap object. The actual color used
344-
// are set in the css file.
404+
// Transform ANSI color escape codes into HTML <span> tags with CSS
405+
// classes such as "ansi-green-intense-fg".
406+
// The actual colors used are set in the CSS file.
407+
// This is supposed to have the same behavior as nbconvert.filters.ansi2html()
345408
function fixConsole(txt) {
346-
txt = xmlencode(txt);
409+
txt = _.escape(txt);
347410

348-
// Strip all ANSI codes that are not color related. Matches
349-
// all ANSI codes that do not end with "m".
350-
var ignored_re = /(?=(\033\[[?\d;=]*[a-ln-zA-Z]{1}))\1(?!m)/g;
351-
txt = txt.replace(ignored_re, "");
352-
353-
// color ansi codes
411+
// color ansi codes (and remove non-color escape sequences)
354412
txt = _ansispan(txt);
355413
return txt;
356414
}

notebook/static/notebook/js/outputarea.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -659,9 +659,9 @@ define([
659659
var append_text = function (data, md, element) {
660660
var type = 'text/plain';
661661
var toinsert = this.create_output_subarea(md, "output_text", type);
662+
data = utils.fixCarriageReturn(data);
662663
// escape ANSI & HTML specials in plaintext:
663664
data = utils.fixConsole(data);
664-
data = utils.fixCarriageReturn(data);
665665
data = utils.autoLinkUrls(data);
666666
// The only user content injected with this HTML call is
667667
// escaped by the fixConsole() method.

notebook/static/notebook/js/pager.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ define([
157157
* The only user content injected with this HTML call is escaped by
158158
* the fixConsole() method.
159159
*/
160-
this.pager_element.find(".container").append($('<pre/>').html(utils.fixCarriageReturn(utils.fixConsole(text))));
160+
this.pager_element.find(".container").append($('<pre/>').html(utils.fixConsole(utils.fixCarriageReturn(text))));
161161
};
162162

163163
Pager.prototype.append = function (htm) {

0 commit comments

Comments
 (0)