enigma-bbs/core/art.js

674 lines
16 KiB
JavaScript

/* jslint node: true */
'use strict';
var fs = require('fs');
var paths = require('path');
var assert = require('assert');
var iconv = require('iconv-lite');
var conf = require('./config.js');
var miscUtil = require('./misc_util.js');
var binary = require('binary');
var events = require('events');
var util = require('util');
var ansi = require('./ansi_term.js');
var aep = require('./ansi_escape_parser.js');
exports.getArt = getArt;
exports.getArtFromPath = getArtFromPath;
exports.display = display;
exports.defaultEncodingFromExtension = defaultEncodingFromExtension;
exports.ArtDisplayer = ArtDisplayer;
var SAUCE_SIZE = 128;
var SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE'
var COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT'
// :TODO: Return MCI code information
// :TODO: process SAUCE comments
// :TODO: return font + font mapped information from SAUCE
var SUPPORTED_ART_TYPES = {
// :TODO: the defualt encoding are really useless if they are all the same ...
// perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf
'.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a },
'.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a },
'.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a },
'.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a },
'.txt' : { name : 'Text', defaultEncoding : 'cp437', eof : 0x1a }, // :TODO: think about this more...
// :TODO: extentions for wwiv, renegade, celerity, syncronet, ...
// :TODO: extension for atari
// :TODO: extension for topaz ansi/ascii.
};
//
// See
// http://www.acid.org/info/sauce/sauce.htm
//
// :TODO: Move all SAUCE stuff to sauce.js
function readSAUCE(data, cb) {
if(data.length < SAUCE_SIZE) {
cb(new Error('No SAUCE record present'));
return;
}
var offset = data.length - SAUCE_SIZE;
var sauceRec = data.slice(offset);
binary.parse(sauceRec)
.buffer('id', 5)
.buffer('version', 2)
.buffer('title', 35)
.buffer('author', 20)
.buffer('group', 20)
.buffer('date', 8)
.word32lu('fileSize')
.word8('dataType')
.word8('fileType')
.word16lu('tinfo1')
.word16lu('tinfo2')
.word16lu('tinfo3')
.word16lu('tinfo4')
.word8('numComments')
.word8('flags')
.buffer('tinfos', 22) // SAUCE 00.5
.tap(function onVars(vars) {
if(!SAUCE_ID.equals(vars.id)) {
cb(new Error('No SAUCE record present'));
return;
}
var ver = vars.version.toString('cp437');
if('00' !== ver) {
cb(new Error('Unsupported SAUCE version: ' + ver));
return;
}
var sauce = {
id : vars.id.toString('cp437'),
version : vars.version.toString('cp437'),
title : vars.title.toString('cp437').trim(),
author : vars.author.toString('cp437').trim(),
group : vars.group.toString('cp437').trim(),
date : vars.date.toString('cp437').trim(),
fileSize : vars.fileSize,
dataType : vars.dataType,
fileType : vars.fileType,
tinfo1 : vars.tinfo1,
tinfo2 : vars.tinfo2,
tinfo3 : vars.tinfo3,
tinfo4 : vars.tinfo4,
numComments : vars.numComments,
flags : vars.flags,
tinfos : vars.tinfos,
};
var dt = SAUCE_DATA_TYPES[sauce.dataType];
if(dt && dt.parser) {
sauce[dt.name] = dt.parser(sauce);
}
cb(null, sauce);
});
}
// :TODO: These need completed:
var SAUCE_DATA_TYPES = {};
SAUCE_DATA_TYPES[0] = { name : 'None' };
SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE };
SAUCE_DATA_TYPES[2] = 'Bitmap';
SAUCE_DATA_TYPES[3] = 'Vector';
SAUCE_DATA_TYPES[4] = 'Audio';
SAUCE_DATA_TYPES[5] = 'BinaryText';
SAUCE_DATA_TYPES[6] = 'XBin';
SAUCE_DATA_TYPES[7] = 'Archive';
SAUCE_DATA_TYPES[8] = 'Executable';
var SAUCE_CHARACTER_FILE_TYPES = {};
SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII';
SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi';
SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation';
SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script';
SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard';
SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar';
SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML';
SAUCE_CHARACTER_FILE_TYPES[7] = 'Source';
SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw';
//
// Map of SAUCE font -> encoding hint
//
// Note that this is the same mapping that x84 uses. Be compatible!
//
var SAUCE_FONT_TO_ENCODING_HINT = {
'Amiga MicroKnight' : 'amiga',
'Amiga MicroKnight+' : 'amiga',
'Amiga mOsOul' : 'amiga',
'Amiga P0T-NOoDLE' : 'amiga',
'Amiga Topaz 1' : 'amiga',
'Amiga Topaz 1+' : 'amiga',
'Amiga Topaz 2' : 'amiga',
'Amiga Topaz 2+' : 'amiga',
'Atari ATASCII' : 'atari',
'IBM EGA43' : 'cp437',
'IBM EGA' : 'cp437',
'IBM VGA25G' : 'cp437',
'IBM VGA50' : 'cp437',
'IBM VGA' : 'cp437',
};
['437', '720', '737', '775', '819', '850', '852', '855', '857', '858',
'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) {
var codec = 'cp' + page;
SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec;
SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec;
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec;
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec;
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec;
});
function parseCharacterSAUCE(sauce) {
var result = {};
result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown';
if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) {
var i = 0;
while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) {
++i;
}
var fontName = sauce.tinfos.slice(0, i).toString('cp437');
if(fontName.length > 0) {
result.fontName = fontName;
}
}
return result;
}
function sliceAtEOF(data, eofMarker) {
var eof = data.length;
// :TODO: max scan back or other beter way of doing this?!
for(var i = data.length - 1; i > 0; i--) {
if(data[i] === eofMarker) {
eof = i;
break;
}
}
return data.slice(0, eof);
}
function getArtFromPath(path, options, cb) {
fs.readFile(path, function onData(err, data) {
if(err) {
cb(err);
return;
}
//
// Convert from encodedAs -> j
//
var ext = paths.extname(path).toLowerCase();
var encoding = options.encodedAs || defaultEncodingFromExtension(ext);
// :TODO: how are BOM's currently handled if present? Are they removed? Do we need to?
function sliceOfData() {
if(options.fullFile === true) {
return iconv.decode(data, encoding);
} else {
var eofMarker = defaultEofFromExtension(ext);
return iconv.decode(sliceAtEOF(data, eofMarker), encoding);
}
}
function getResult(sauce) {
var result = {
data : sliceOfData(),
fromPath : path,
};
if(sauce) {
result.sauce = sauce;
}
return result;
}
if(options.readSauce === true) {
readSAUCE(data, function onSauce(err, sauce) {
if(err) {
cb(null, getResult());
} else {
//
// If a encoding was not provided & we have a mapping from
// the information provided by SAUCE, use that.
//
if(!options.encodedAs) {
if(sauce.Character && sauce.Character.fontName) {
var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName];
if(enc) {
encoding = enc;
}
}
}
cb(null, getResult(sauce));
}
});
} else {
cb(null, getResult());
}
});
}
function getArt(name, options, cb) {
var ext = paths.extname(name);
options.basePath = miscUtil.valueWithDefault(options.basePath, conf.config.paths.art);
options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true);
// :TODO: make use of asAnsi option and convert from supported -> ansi
if('' !== ext) {
options.types = [ ext.toLowerCase() ];
} else {
if(typeof options.types === 'undefined') {
options.types = Object.keys(SUPPORTED_ART_TYPES);
} else if(typeof options.types === 'string') {
options.types = [ options.types.toLowerCase() ];
}
}
// If an extension is provided, just read the file now
if('' !== ext) {
var directPath = paths.join(options.basePath, name);
getArtFromPath(directPath, options, cb);
return;
}
fs.readdir(options.basePath, function onFiles(err, files) {
if(err) {
cb(err);
return;
}
var filtered = files.filter(function onFile(file) {
//
// Ignore anything not allowed in |options.types|
//
var fext = paths.extname(file);
if(options.types.indexOf(fext.toLowerCase()) < 0) {
return false;
}
var bn = paths.basename(file, fext).toLowerCase();
if(options.random) {
var suppliedBn = paths.basename(name, fext).toLowerCase();
//
// Random selection enabled. We'll allow for
// basename1.ext, basename2.ext, ...
//
if(bn.indexOf(suppliedBn) !== 0) {
return false;
}
var num = bn.substr(suppliedBn.length);
if(num.length > 0) {
if(isNaN(parseInt(num, 10))) {
return false;
}
}
} else {
//
// We've already validated the extension (above). Must be an exact
// match to basename here
//
if(bn != paths.basename(name, fext).toLowerCase()) {
return false;
}
}
return true;
});
if(filtered.length > 0) {
//
// We should now have:
// - Exactly (1) item in |filtered| if non-random
// - 1:n items in |filtered| to choose from if random
//
var readPath;
if(options.random) {
readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]);
} else {
assert(1 === filtered.length);
readPath = paths.join(options.basePath, filtered[0]);
}
getArtFromPath(readPath, options, cb);
} else {
cb(new Error('No matching art for supplied criteria'));
}
});
}
// :TODO: need a showArt()
// - center (if term width > 81)
// - interruptable
// - pausable: by user key and/or by page size (e..g term height)
function defaultEncodingFromExtension(ext) {
return SUPPORTED_ART_TYPES[ext.toLowerCase()].defaultEncoding;
}
function defaultEofFromExtension(ext) {
return SUPPORTED_ART_TYPES[ext.toLowerCase()].eof;
}
function ArtDisplayer(client) {
if(!(this instanceof ArtDisplayer)) {
return new ArtDisplayer(client);
}
events.EventEmitter.call(this);
this.client = client;
}
util.inherits(ArtDisplayer, events.EventEmitter);
// :TODO: change to display(art, options, cb)
// cb(err, mci)
function display(art, options, cb) {
if(!art || 0 === art.length) {
cb(new Error('Missing or empty art'));
return;
}
if('undefined' === typeof options) {
cb(new Error('Missing options'));
return;
}
if('undefined' === typeof options.client) {
cb(new Error('Missing client in options'));
return;
}
var cancelKeys = miscUtil.valueWithDefault(options.cancelKeys, []);
var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []);
var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false);
var mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, '');
// :TODO: support pause/cancel & pause @ termHeight
var canceled = false;
var parser = new aep.ANSIEscapeParser({
mciReplaceChar : mciReplaceChar,
termHeight : options.client.term.termHeight,
termWidth : options.client.term.termWidth,
});
var mci = {};
var mciPosQueue = [];
var emitter = null;
var parseComplete = false;
parser.on('mci', function onMCI(mciCode, args) {
if(mci[mciCode]) {
mci[mciCode].focusColor = {
fg : parser.fgColor,
bg : parser.bgColor,
flags : parser.flags,
};
} else {
mci[mciCode] = {
args : args,
color : {
fg : parser.fgColor,
bg : parser.bgColor,
flags : parser.flags,
},
code : mciCode.substr(0, 2),
id : mciCode.substr(2, 1), // :TODO: This NEEDs to read 01-99
};
mciPosQueue.push(mciCode);
if(!emitter) {
emitter = options.client.on('onPosition', function onPosition(pos) {
if(mciPosQueue.length > 0) {
var forMciCode = mciPosQueue.shift();
mci[forMciCode].position = pos;
if(parseComplete && 0 === mciPosQueue.length) {
cb(null, mci);
}
}
});
}
options.client.term.write(ansi.queryPos());
}
});
parser.on('chunk', function onChunk(chunk) {
options.client.term.write(chunk);
});
parser.on('complete', function onComplete() {
parseComplete = true;
if(0 === mciPosQueue.length) {
cb(null, mci);
}
});
parser.parse(art);
}
ArtDisplayer.prototype.display = function(art, options) {
var client = this.client;
var self = this;
var cancelKeys = miscUtil.valueWithDefault(options.cancelKeys, []);
var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []);
var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false);
var canceled = false;
if(cancelKeys.length > 0 || pauseKeys.length > 0) {
var onDataKeyCheck = function(data) {
var key = String.fromCharCode(data[0]);
if(-1 !== cancelKeys.indexOf(key)) {
canceled = true;
removeDataListener();
}
};
client.on('data', onDataKeyCheck);
}
function removeDataListener() {
client.removeListener('data', onDataKeyCheck);
}
//
// Try to split lines supporting various linebreaks we may encounter:
// - DOS \r\n
// - *nix \n
// - Old Apple \r
// - Unicode PARAGRAPH SEPARATOR (U+2029) and LINE SEPARATOR (U+2028)
//
// See also http://stackoverflow.com/questions/5034781/js-regex-to-split-by-line
//
var lines = art.split(/\r?\n|\r|[\u2028\u2029]/);
var i = 0;
var count = lines.length;
if(0 === count) {
return;
}
var termHeight = client.term.termHeight;
var aep = require('./ansi_escape_parser.js');
var p = new aep.ANSIEscapeParser();
var currentRow = 0;
var lastRow = 0;
p.on('row update', function onRowUpdated(row) {
currentRow = row;
});
//--------
var mci = {};
var mciPosQueue = [];
var parseComplete = false;
var emitter = null;
p.on('mci', function onMCI(mciCode, args) {
if(mci[mciCode]) {
mci[mciCode].fgColorAlt = p.fgColor;
mci[mciCode].bgColorAlt = p.bgColor;
mci[mciCode].flagsAlt = p.flags;
} else {
mci[mciCode] = {
args : args,
fgColor : p.fgColor,
bgColor : p.bgColor,
flags : p.flags,
};
mciPosQueue.push(mciCode);
if(!emitter) {
emitter = client.on('onPosition', function onPosition(pos) {
if(mciPosQueue.length > 0) {
var mc = mciPosQueue.shift();
console.log('position @ ' + mc + ': ' + pos);
mci[mc].pos = pos;
if(parseComplete && 0 === mciPosQueue.length) {
//console.log(mci);
var p1 = mci['LV1'].pos;
client.term.write(ansi.sgr(['red']));
var g = ansi.goto(p1);
console.log(g);
client.term.write(ansi.goto(p1[0], p1[1]));
client.term.write('Hello, World');
}
}
});
}
}
});
p.on('chunk', function onChunk(chunk) {
client.term.write(chunk);
});
p.on('complete', function onComplete() {
//console.log(mci);
parseComplete = true;
if(0 === mciPosQueue.length) {
console.log('mci from complete');
console.log(mci);
}
});
p.parse(art);
//-----------
/*
var line;
(function nextLine() {
if(i === count) {
self.emit('complete');
removeDataListener();
return;
}
if(canceled) {
self.emit('canceled');
removeDataListener();
return;
}
line = lines[i];
client.term.write(line + '\n');
p.parse(line + '\r\n');
i++;
if(pauseAtTermHeight && currentRow !== lastRow && (0 === currentRow % termHeight)) {
lastRow = currentRow;
client.getch(function onKey(k) {
nextLine();
});
} else {
setTimeout(nextLine, 20);
}
})();
*/
/*
(function nextLine() {
if(i === count) {
client.emit('complete', true);
removeDataListener();
return;
}
if(canceled) {
console.log('canceled');
client.emit('canceled');
removeDataListener();
return;
}
client.term.write(lines[i] + '\n');
//
// :TODO: support pauseAtTermHeight:
//
// - All cursor movement should be recorded for pauseAtTermHeight support &
// handling > termWidth scenarios
// - MCI codes should be processed
// - All other ANSI/CSI ignored
// - Count normal chars
//
//setTimeout(nextLine, 20);
//i++;
if(pauseAtTermHeight && i > 0 && (0 === i % termHeight)) {
console.log('pausing @ ' + i);
client.getch(function onKey() {
i++;
nextLine();
});
} else {
i++;
// :TODO: If local, use setTimeout(nextLine, 20) or so -- allow to pause/cancel
//process.nextTick(nextLine);
setTimeout(nextLine, 20);
}
})();
*/
};
//
// ANSI parser for quick scanning & handling
// of basic ANSI sequences that can be used for output to clients:
//
function ANSIOutputParser(ansi) {
//
// cb's
// - onMCI
// - onTermHeight
// -
}