@uttori/data-tools
Version:
Tools for working with binary data.
442 lines (403 loc) • 14.8 kB
JavaScript
import DataBuffer from './data-buffer.js';
import DataStream from './data-stream.js';
let debug = (..._) => {};
/* c8 ignore next */
if (process.env.UTTORI_DATA_DEBUG) { try { const { default: d } = await import('debug'); debug = d('DataFormatting'); } catch {} }
/**
* Format an amount of bytes to a human friendly string.
* @param {number} input The number of bytes.
* @param {number} [decimals] The number of trailing decimal places to chop to, default is 2.
* @param {number} [bytes] The byte division value, alternatively could be 1000 for decimal values rather than binary values, default is 1024.
* @param {string[]} [sizes] An optional array of the various size suffixes in ascending order of size: `['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']`
* @returns {string} The human friendly representation of the number of bytes.
* @see {@link https://en.wikipedia.org/wiki/Byte#Multiple-byte_units|Multiple-byte units}
*/
export const formatBytes = (input, decimals = 2, bytes = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']) => {
if (input === 0) {
return `0 ${sizes[0]}`;
}
const i = Math.floor(Math.log(input) / Math.log(bytes));
return `${Number.parseFloat((input / bytes ** i).toFixed(decimals))} ${sizes[i]}`;
};
/**
* ASCII text formatting function.
* @param {number} value Input data to print out as a hex table.
* @param {Record<string, boolean|number|string>} asciiFlags Any flags needed by the formatter.
* @param {DataBuffer|DataStream} _data The data being processed.
* @returns {import('../dist/custom.js').FormatASCIIOutput} Returns an array with the Character to represent this value and any flags for the function.
*/
export const formatASCII = (value, asciiFlags, _data) => {
// Unprintable ASCII < 128 == ' ', > 128 == '.'
if (value < 0x20) {
return [' ', asciiFlags];
}
if (value > 0x7E) {
return ['.', asciiFlags];
}
// Alternatively: value.replace(/[^\x20-\x7E]+/g, '_')
return [String.fromCharCode(value), asciiFlags];
};
/**
* Formatting functions for all value types.
* @typedef {object} HexTableFormater
* @property {import('../dist/custom.js').FormatNumber} offset Offset formatting fuction.
* @property {import('../dist/custom.js').FormatNumber} value Byte value formating function.
* @property {import('../dist/custom.js').FormatNumberToASCII} ascii ASCII text formatting function.
*/
/**
* @type {import('../dist/custom.js').HexTableFormater}
*/
export const hexTableFormaters = {
offset: (value) => value.toString(16).padStart(8, '0'),
value: (value) => value.toString(16).padStart(2, '0').toUpperCase(),
ascii: formatASCII,
};
/**
* Header layout definitions.
* GNU poke hexTableHeader.value = ['00', '11', '22', '33', '44', '55', '66', '77', '88', '99', 'aa', 'bb', 'cc', 'dd', 'ee', 'ff']
* @typedef {object} HexTableHeader
* @property {string} offset Offset header column presentation.
* @property {string[]} value Byte value header values, grouped as defined in the provided HexTableDimensions.
* @property {string} ascii ASCII text presentation.
*/
/**
* @type {HexTableHeader}
*/
export const hexTableHeader = {
offset: '76543210',
value: ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0A', '0B', '0C', '0D', '0E', '0F'],
ascii: '0123456789ABCDEF',
};
/**
* Header layout definitions.
* @typedef {object} HexTableDimensions
* @property {number} columns The number of columns to show in the byte value section of the table.
* @property {number} grouping The number of bytes to cluster together in the byte value section of the table.
* @property {number} maxRows The maxiumum number of rows to show excluding the header & seperator rows.
*/
/**
* @type {HexTableDimensions}
*/
export const hexTableDimensions = {
columns: 16,
grouping: 4,
maxRows: 40,
};
/**
* Generate a nicely formatted hex editor style table.
* @param {DataBuffer|DataStream} input Input data to print out as a hex table.
* @param {number} offset Offset in the DataStream to start from.
* @param {HexTableDimensions} dimensions Table size parameters for columns, rows and byte grouping.
* @param {HexTableHeader} header The values for building the table header with offset, bytes and ASCII values.
* @param {HexTableFormater} format The formatting functions for displaying offset, bytes and ASCII values.
* @returns {string} The hex table ASCII.
*/
export const hexTable = (input, offset = 0, dimensions = hexTableDimensions, header = hexTableHeader, format = hexTableFormaters) => {
// Do not manipulate the input data.
const data = input.copy();
// Build the header, offset, then bytes with grouping & the dashed line seperator
// Start with determining the customizable byte area for the header and seperatpr
let headerByteValues = '';
header.value.forEach((byte, column) => {
headerByteValues += byte;
// Grouping by provided value value, add spacing every gap space, but not the last column.
if ((column + 1) !== dimensions.columns && (column + 1) % dimensions.grouping === 0) {
headerByteValues += ' ';
}
});
let output = `| ${header.offset} | ${headerByteValues} | ${header.ascii} |\n`;
output += `|-${'-'.repeat(header.offset.length)}-|-${'-'.repeat(headerByteValues.length)}-|-${'-'.repeat(header.ascii.length)}-|\n`;
// Build the actual data portion of the table, starting from the provided offset.
let ascii = '';
let asciiValue = '';
/** @type {Record<string, boolean|number|string>} */
let asciiFlags = {};
let row = 0;
let column = 0;
while (data.remainingBytes() && row !== dimensions.maxRows) {
// Update the offset when we get to a new column
if (column === 0) {
output += `| ${format.offset(offset)} | `;
}
// Read the actual value from the data and format it for the output
const value = data.readUInt8();
output += format.value(value);
[asciiValue, asciiFlags] = format.ascii(value, asciiFlags, data);
ascii += asciiValue;
// Add spacing every gap space, but not the last column.
if ((column + 1) !== dimensions.columns && (column + 1) % dimensions.grouping === 0) {
output += ' ';
}
// Update the counters.
column++;
offset++;
// Is this a new column, if so we reset
if (column >= dimensions.columns) {
output += ` | ${ascii} |\n`;
ascii = '';
column = 0;
row++;
}
}
// Fill in empty space to maintain the shape
if (column > 0) {
while (column <= dimensions.columns) {
if (column === dimensions.columns) {
output += ` | ${ascii} |`;
ascii = '';
row++;
}
ascii += ' ';
output += ' ';
// Add spacing every gap space, but not the last column.
if ((column + 1) !== dimensions.columns && (column + 1) % dimensions.grouping === 0) {
output += ' ';
}
column++;
offset++;
}
}
return output.trim();
};
/**
* Format a table line seperator for a given theme.
* @param {number[]} columnLengths An array with each columns length
* @param {string} type The type of the separator
* @param {object} options The options
* @param {TableFormatStyle} options.theme The theme to use for formatting.
* @param {number} options.padding The amount of padding to use.
* @returns {string} The seperator
*/
export const formatTableLine = (columnLengths, type, options) => {
// Separator for top bottom mid
let separator = '';
const { theme } = options;
switch (type) {
case 'top':
case 'title_top':
separator += theme.upperLeft;
break;
case 'bottom':
separator += theme.lowerLeft;
break;
case 'title_bottom':
default:
separator += theme.intersectionLeft;
}
for (let i = 0; i < columnLengths.length; i++) {
for (let l = 0; l < columnLengths[i]; l++) {
separator += theme.line; // horizontal line
}
separator += Array(options.padding * 2 + 1).join(theme.line);
if (i === columnLengths.length - 1) {
switch (type) {
case 'top':
case 'title_top':
separator += theme.upperRight;
break;
case 'bottom':
separator += theme.lowerRight;
break;
case 'title_bottom':
separator += theme.intersectionRight;
break;
default:
separator += theme.intersectionRight;
}
} else {
switch (type) {
case 'top':
case 'title_bottom':
separator += theme.intersectionTop;
break;
case 'bottom':
separator += theme.intersectionBottom;
break;
case 'title_top':
separator += theme.line;
break;
default:
separator += theme.intersection;
}
}
}
return separator;
};
/**
* Table Format Style definitions.
* @typedef {object} TableFormatStyle
* @property {boolean} topRow If true, show the top frame, if false, hide the top frame. Typically used for full framed styles.
* @property {boolean} bottomRow If true, show the bottom frame, if false, hide the top frame. Typically used for full framed styles.
* @property {string} upperLeft Top Left Character
* @property {string} upperRight Top Right Chcaracter
* @property {string} lowerLeft Bottom Left Character
* @property {string} lowerRight Bottom Right Character
* @property {string} intersection 4 Way Intersection Character
* @property {string} line Horizontal Line Character
* @property {string} wall Vertical Line Character
* @property {string} intersectionTop 2 Way Intersection from the bottom Character
* @property {string} intersectionBottom 2 Way Intersection from the top Character
* @property {string} intersectionLeft 2 Way Intersection from the right Character
* @property {string} intersectionRight 2 Way Intersection from the left Character
*/
/**
* MySQL Style Table Layout
* @type {TableFormatStyle}
*/
export const formatTableThemeMySQL = {
topRow: true,
bottomRow: true,
upperLeft: '+',
upperRight: '+',
lowerLeft: '+',
lowerRight: '+',
intersection: '+',
line: '-',
wall: '|',
intersectionTop: '+',
intersectionBottom: '+',
intersectionLeft: '+',
intersectionRight: '+',
};
/**
* Unicode Table Layout
* @type {TableFormatStyle}
*/
export const formatTableThemeUnicode = {
topRow: true,
bottomRow: true,
upperLeft: '╔',
upperRight: '╗',
lowerLeft: '╚',
lowerRight: '╝',
intersection: '╬',
line: '═',
wall: '║',
intersectionTop: '╦',
intersectionBottom: '╩',
intersectionLeft: '╠',
intersectionRight: '╣',
};
/**
* Markdown Table Layout
* @type {TableFormatStyle}
*/
export const formatTableThemeMarkdown = {
topRow: false,
bottomRow: false,
upperLeft: '|',
upperRight: '|',
lowerLeft: '|',
lowerRight: '|',
intersection: '|',
line: '-',
wall: '|',
intersectionTop: '|',
intersectionBottom: '|',
intersectionLeft: '|',
intersectionRight: '|',
};
// TODO: Emoji length is incorrect, for example:
// TODO: [...new Intl.Segmenter().segment('👩👩👧👦')].length === 1
// TODO: '👩👩👧👦'.length === 11
// TODO: From https://stackoverflow.com/questions/54369513/how-to-count-the-correct-length-of-a-string-with-emojis-in-javascript
// TODO: Add a flag to check for multibyte emoji
// TODO: See https://github.com/orling/grapheme-splitter for an indepth explination
/**
* Create an ASCII table from provided data and configuration.
* @param {string[][]} data The data to add to the table.
* @param {object} options Configuration.
* @param {string[]} options.align The alignment of each column, left or right.
* @param {number} options.padding Amount of padding to add to each cell.
* @param {TableFormatStyle} options.theme The theme to use for formatting.
* @param {string} options.title The title to display at the top of the table.
* @returns {string} The ASCII table of data.
*/
export const formatTable = (data, options) => {
// Use JSON parse & stringify to get a deep copy of the parameter array
data = structuredClone(data);
options = {
padding: 1,
theme: formatTableThemeMySQL,
title: '',
...options,
};
// Ensure all the rows have the same number of columns.
const allSameLength = data.every(({ length }) => length === data[0].length);
/* c8 ignore next 3 */
if (!allSameLength) {
debug('Uneven number of columns');
}
// Make an array with the length of each column
const columnLengths = [];
for (const row of data) {
for (const [i, column] of row.entries()) {
columnLengths[i] = Math.max(columnLengths[i] || 1, String(column).length);
}
}
// Add the title or the top line if the theme needs it
let outputString = '';
if (options.title) {
outputString += `${formatTableLine(columnLengths, 'title_top', options)}\n`;
const total_length = formatTableLine(columnLengths, '', options).length;
const rem = total_length - 2 - options.title.length;
const half = Math.floor(rem / 2);
let row = options.theme.wall;
row += Array(half + 1).join(' ');
row += options.title;
row += Array(half + 1 + (rem % 2)).join(' ');
row += options.theme.wall;
outputString += `${row}\n`;
outputString += `${formatTableLine(columnLengths, 'title_bottom', options)}\n`;
} else if (options.theme.topRow) {
outputString += `${formatTableLine(columnLengths, 'top', options)}\n`; // Add top line
}
// Fill rows
for (let i = 0; i < data.length; i++) {
let row = options.theme.wall;
for (let j = 0; j < data[i].length; j++) {
let col = Array(options.padding + 1).join(' '); // Left padding
if (options.align[j] === 'right') {
for (let l = 0; l < columnLengths[j] - String(data[i][j]).length; l++) {
col += ' ';
}
col += data[i][j];
} else {
col += data[i][j];
if (String(data[i][j]).length < columnLengths[j]) {
for (let l = 0; l < columnLengths[j] - String(data[i][j]).length; l++) {
col += ' ';
}
}
}
col += Array(options.padding + 1).join(' ');
// if its not the last col
if (j !== data[i].length - 1) {
col += options.theme.wall;
}
row += col;
}
row += options.theme.wall;
outputString += `${row}\n`;
// Header
if (i === 0) {
outputString += `${formatTableLine(columnLengths, '', options)}\n`;
}
}
if (options.theme.bottomRow) {
outputString += formatTableLine(columnLengths, 'bottom', options);
}
return outputString;
};
export default {
formatBytes,
formatASCII,
hexTable,
hexTableDimensions,
hexTableHeader,
hexTableFormaters,
formatTable,
formatTableThemeMySQL,
formatTableThemeUnicode,
formatTableThemeMarkdown,
};