molstar
Version:
A comprehensive macromolecular library.
174 lines (173 loc) • 7.1 kB
JavaScript
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
export function FormatTemplate(fstring) {
const { n, varNames, varFormats, literals } = parseFString(fstring);
let _out;
return {
fstring,
format(valueGetter) {
const out = _out !== null && _out !== void 0 ? _out : (_out = []);
out.length = 0;
for (let i = 0; i < n; i++) {
out.push(literals[i]);
const value = valueGetter(varNames[i]);
if (value === undefined)
return undefined;
out.push(formatValue(value, varFormats[i]));
}
out.push(literals[n]);
return out.join('');
},
};
}
/** Parse Python-like f-string */
function parseFString(fstring) {
const literals = [];
const varNames = [];
const varFormats = [];
/** Non-negative = where current literal started; negative = -where current tag started (excluding braces). */
let start = 0;
for (let i = 0; i < fstring.length; i++) {
const char = fstring[i];
if (start >= 0) {
// In literal
if (char === '{') {
if (fstring[i + 1] === '{') {
i++; // skip the other {
}
else {
literals.push(fstring.slice(start, i).replace(/{{/g, '{').replace(/}}/g, '}'));
start = -(i + 1); // start tag
}
}
else if (char === '}') {
if (fstring[i + 1] === '}') {
i++; // skip the other }
}
else {
throw new Error('ValueError: Invalid format template (unmatched "}")');
}
} // else do nothing
}
else {
// In tag
if (char === '{') {
throw new Error('ValueError: Invalid format template ("{" within tag)');
}
else if (char === '}') {
const [varName, formatSpec] = parseFormatTag(fstring.slice(-start, i));
varNames.push(varName);
varFormats.push(formatSpec);
start = i + 1; // start literal
} // else do nothing
}
}
if (start < 0) {
throw new Error('ValueError: Invalid format template (unmatched "{")');
}
literals.push(fstring.slice(start, fstring.length).replace(/{{/g, '{').replace(/}}/g, '}'));
return { n: varNames.length, varNames, varFormats, literals };
}
/** Parse a single f-string format tag, e.g. `age:.2f` */
function parseFormatTag(formatTag) {
// cannot use .split because format spec may contain ':'
let colonIndex = formatTag.indexOf(':');
if (colonIndex < 0)
colonIndex = formatTag.length;
const varName = formatTag.slice(0, colonIndex);
const formatSpec = formatTag.slice(colonIndex + 1, undefined);
return [varName, parseFormatSpec(formatSpec)];
}
// Python 3.13: [[fill]align][sign]["z"]["#"]["0"] [width][grouping]["." precision] [type]
const FORMAT_SPEC_RE = /^(?:(?<fill>.?)(?<align>[<>=^]))?(?<sign>[-+ ]?)(?<z>z?)(?<alt>#?)(?<zeros>0?)(?<width>\d*)(?<grouping>[,_]?)\.?(?<precision>\d*)(?<type>[bdeEfFgGnoxX%cs]?)$/;
const FORMAT_TYPES = ['b', 'd', 'e', 'E', 'f', 'F', 'g', 'G', 'n', 'o', 'x', 'X', '%', 'c', 's'];
/** Parse a single f-string format spec, e.g. `.2f` -> `{ type:'f', precision: 2, ... }` */
function parseFormatSpec(formatSpec) {
const match = formatSpec.match(FORMAT_SPEC_RE);
if (!match || !match.groups)
throw new Error(`ValueError: Invalid formatting "${formatSpec}"`);
const type = (match.groups.type || 's');
const isNumeric = type !== 's' && type !== 'c';
const sign = match.groups.sign;
const z = match.groups.z;
const alt = match.groups.alt;
const zeros = match.groups.zeros;
const width = Number(match.groups.width);
const grouping = match.groups.grouping;
const precision = match.groups.precision ? Number(match.groups.precision) : 6;
const fillChar = match.groups.fill || zeros || ' ';
const align = match.groups.align || (isNumeric ? (zeros ? '=' : '<') : '>');
return { type, sign, z, alt, zeros, width, grouping, precision, fillChar, align };
}
/** Format a value a la f-string, e.g. `formatValue('1.2', '.2f')` -> `'1.20'` */
export function formatValue(value, formatSpec) {
if (typeof formatSpec === 'string')
formatSpec = parseFormatSpec(formatSpec);
if (formatSpec.z)
throw new Error(`NotImplementedError: Formatting option "z" not supported`);
if (formatSpec.alt)
throw new Error(`NotImplementedError: Formatting option "#" not supported`);
return alignString(formatWithoutAligning(value, formatSpec.sign, formatSpec.grouping, formatSpec.precision, formatSpec.type), formatSpec.width, formatSpec.align, formatSpec.fillChar);
}
function formatWithoutAligning(value, sign, grouping, precision, type) {
let out = '';
switch (type) {
case 's':
return value;
case 'c':
return String.fromCharCode(Number(value));
case 'd':
out = `${Math.floor(Number(value))}`;
break;
case 'f':
case 'F':
out = Number(value).toFixed(precision);
break;
case 'e':
case 'E':
out = Number(value).toExponential(precision).replace(/(e[+-])(\d)$/, '$10$2'); // pad exponent to 2 chars (like Python)
if (type === 'E')
out = out.replace('e', 'E');
break;
case '%':
out = `${(100 * Number(value)).toFixed(precision)}%`;
break;
default:
if (FORMAT_TYPES.includes(type)) {
throw new Error(`NotImplementedError: Formatting code "${type}" not supported`);
}
else {
throw new Error(`ValueError: Invalid format code "${type}"`);
}
}
if (grouping === ',') {
const match = out.match(/(-?\d+)(.*)/);
if (match) {
out = match[1].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + match[2];
}
}
if (sign === '+' && out[0] !== '-')
out = '+' + out;
if (sign === ' ' && out[0] !== '-')
out = ' ' + out;
return out;
}
function alignString(str, length, align, fillChar) {
switch (align) {
case '<': return str.padEnd(length, fillChar);
case '>': return str.padStart(length, fillChar);
case '=':
if (str.startsWith('+') || str.startsWith('-') || str.startsWith(' ')) {
return str[0] + str.slice(1, undefined).padStart(length - 1, fillChar);
}
else {
return str.padStart(length, fillChar);
}
case '^':
const nStart = Math.max(0, Math.floor((length - str.length) / 2));
return str.padStart(str.length + nStart, fillChar).padEnd(length, fillChar);
}
}