/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
const empty_json = JSON.stringify({}); // we could probably hard-code this
import type { Theme } from './theme';
/** horizontal align constants for cell style */
export type HorizontalAlign = '' | 'left' | 'center' | 'right';
/** vertical align constants for cell style */
export type VerticalAlign = '' | 'top' | 'bottom' | 'middle';
export type ThemeColorType =
'Background'|
'Text'|
'Background2'|
'Text2'|
'Accent'|
'Accent2'|
'Accent3'|
'Accent4'|
'Accent5'|
'Accent6';
const ThemeColorMap: Record = {
'Background': 0,
'Text': 1,
'Background2': 2,
'Text2': 3,
'Accent': 4,
'Accent2': 5,
'Accent3': 6,
'Accent4': 7,
'Accent5': 8,
'Accent6': 9,
};
/*
for (const [index, entry] of ThemeColorList.entries()) {
ThemeColorMap[entry] = index;
}
*/
export const ThemeColorIndex = (color: ThemeColor) => {
if (typeof color.theme === 'number') {
return color.theme;
}
return ThemeColorMap[color.theme] || 0;
};
/**
* font size for cell style. we generally prefer relative sizes
* (percent or em) because they are relative to the default theme
* size, which might be different on different platforms.
*/
export interface FontSize {
unit: 'pt'|'px'|'em'|'%';
value: number;
}
/* *
* color for cell style. color is used for foreground, background and
* borders in the cell style. can be either a theme color (theme index
* plus tint), or CSS text.
*
* @privateRemarks
* FIXME: this should be a union type. we do a lot of if switching anyway.
* /
export interface Color {
theme?: number;
tint?: number;
text?: string;
/ ** @internal * /
offset?: Color;
/ ** @deprecated * /
none?: boolean;
}
*/
export interface HTMLColor {
text: string;
/** @internal */
offset?: Color;
}
export interface ThemeColor {
theme: number|ThemeColorType;
tint?: number;
/** @internal */
offset?: Color;
}
export interface NullColor {
/** @internal */
offset?: Color;
}
export type Color = ThemeColor|HTMLColor|NullColor;
export const IsHTMLColor = (color?: Color): color is HTMLColor => {
return !!color && (typeof (color as HTMLColor).text === 'string');
};
export const IsThemeColor = (color?: Color): color is ThemeColor => {
return !!color && (typeof (color as ThemeColor).theme !== 'undefined');
};
export const IsDefinedColor = (color?: Color): color is (ThemeColor|HTMLColor) => {
return !!color && (
(typeof (color as HTMLColor).text === 'string') ||
(typeof (color as ThemeColor).theme !== 'undefined'));
}
/** @internal */
export interface CompositeBorderEdge {
width: number;
color: Color;
}
/** @internal */
export interface CompositeBorder {
top: CompositeBorderEdge,
left: CompositeBorderEdge,
right: CompositeBorderEdge,
bottom: CompositeBorderEdge,
}
/**
* style properties applied to a single cell, row, column, or sheet.
* when rendering a cell, we composite all styles that might apply.
*/
export interface CellStyle {
/** horizontal align defaults to left */
horizontal_align?: HorizontalAlign;
/** vertical align defaults to bottom */
vertical_align?: VerticalAlign;
/** representation for NaN */
nan?: string;
/** number format, either a symbolic name like "General" or a format string */
number_format?: string;
/** wrap text */
wrap?: boolean;
/**
* font size. we recommend using relative font sizes (either % or em)
* which will be relative to the theme font size.
*/
font_size?: FontSize;
/** font face. this can be a comma-delimited list, like CSS */
font_face?: string;
/** flag */
bold?: boolean; // FIXME: switch to weight
/** flag */
italic?: boolean;
/** flag */
underline?: boolean;
/** flag */
strike?: boolean;
/** border weight */
border_top?: number;
/** border weight */
border_right?: number;
/** border weight */
border_left?: number;
/** border weight */
border_bottom?: number;
/** text color */
text?: Color;
/** background color */
fill?: Color;
/** border color */
border_top_fill?: Color;
/** border color */
border_left_fill?: Color;
/** border color */
border_right_fill?: Color;
/** border color */
border_bottom_fill?: Color;
/** text indent */
indent?: number;
/**
* cell is locked for editing
*
* @privateRemarks
*
* this should properly be in cell, not style -- but we keep
* it here so it can cascade like other styles.
*
*/
locked?: boolean;
}
/**
* @internal
*
* starting on data bars, but there might be other conditional
* stuff we want to tack on to standard styles.
*
*/
export type ExtendedCelLStyle = CellStyle & {
databar?: {
fill: Color;
negative?: Color;
hide_values?: boolean;
value: number; // as %
// min: number; // always going to be 0
// max: number; // always going to be 1
zero: number; // as %
}
}
/** @internal */
export type PropertyKeys = keyof CellStyle;
/**
* (finally) removing the old namespace. we keep this object around for
* some internal methods, but all the types have moved to the top-level
* of this module and need to be imported separately.
*
* we could theoretically build a backcompat module that re-exports all
* the types, but it's probably not necessary -- most updates will just
* require a find-and-replace (plus adding some imports).
*
* @internal
*/
export const Style = {
/**
* note that there are no default colors; those should be set
* in grid when style is applied. that way the default colors for
* border, text and background colors will be theme-dependent and
* can change.
*
* @internal
*/
DefaultProperties: {
horizontal_align: '',
vertical_align: '',
number_format: 'General', // '0.######', // use symbolic, e.g. "general"
nan: 'NaN',
font_size: { unit: 'em', value: 1 },
// font_size: { unit: 'pt', value: 10.5 },
// font_face: 'sans-serif',
bold: false, // drop "font_"
italic: false, // ...
underline: false, // ...
strike: false, //
text: { theme: 1 },
border_top: 0, // adding defaults so these prune propery
border_left: 0,
border_right: 0,
border_bottom: 0,
} as CellStyle,
/**
* this is a utility function for callers that use borders, to
* reduce testing and facilitate reusable methods
*
* @internal
*/
CompositeBorders: (style: CellStyle): CompositeBorder => {
return {
top: {
width: style.border_top || 0,
color: style.border_top_fill || {},
},
left: {
width: style.border_left || 0,
color: style.border_left_fill || {},
},
right: {
width: style.border_right || 0,
color: style.border_right_fill || {},
},
bottom: {
width: style.border_bottom || 0,
color: style.border_bottom_fill || {},
},
};
},
Serialize: (style: CellStyle): string => {
const clone: CellStyle = JSON.parse(JSON.stringify(style));
// scrub border colors without widths
if (clone.border_bottom_fill && !clone.border_bottom) {
clone.border_bottom_fill = undefined;
}
if (clone.border_top_fill && !clone.border_top) {
clone.border_top_fill = undefined;
}
if (clone.border_left_fill && !clone.border_left) {
clone.border_left_fill = undefined;
}
if (clone.border_right_fill && !clone.border_right) {
clone.border_right_fill = undefined;
}
return JSON.stringify(clone);
},
/**
* merge. returns a new object, does not update dest in place.
* NOTE: if it does not update dest in place, then what would be
* the use case for a non-delta merge? (...)
*
* @internal
*/
Merge: (dest: CellStyle, src: CellStyle, delta = true): CellStyle => {
const properties: CellStyle = delta ? {...dest, ...src} : {...src};
return JSON.parse(JSON.stringify(properties));
},
/** @internal */
Composite: (list: CellStyle[]): CellStyle => {
return JSON.parse(JSON.stringify(list.reduce((composite, item) => ({...composite, ...item}), {})));
},
/** @internal */
Empty: (style: CellStyle): boolean => {
return JSON.stringify(style) === empty_json;
},
/* *
* this looks like a type guard, we should switch to a union
* type and then add real type guards
*
* @internal
* /
ValidColor: (color?: Color): boolean => {
return !!(color && (!color.none) && (color.text || color.theme || color.theme === 0));
},
*/
/** @internal */
ParseFontSize: (text = '', default_unit = 'em'): CellStyle => {
const match = text.match(/(-*[\d.]+)\s*(\S*)/);
if (match) {
const value = Number(match[1]);
if (!value || isNaN(value) || value < 0) {
return {}; // invalid
}
const unit = match[2].toLowerCase() || default_unit;
if (unit === 'pt' || unit === 'em' || unit === '%' || unit === 'px') {
// return { font_size_unit: unit, font_size_value: value };
return {
font_size: { unit, value },
};
}
}
return {};
},
/**
* returns the font size of the properties argument as a ratio of the
* base argument. this is intended to show the relative font size of
* a spreadsheet cell; so anything with no value should be "1", and
* everything else is relative to that.
*
* we prefer relative sizes (em, essentially) to fixed sizes because
* we may have different base font sizes on different platforms (we do,
* in fact, on windows because calibri is too small).
*
* using relative sizes helps ensure that it looks similar, if not
* identical, on different platforms.
*
* @internal
*/
RelativeFontSize: (properties: CellStyle, base: CellStyle): number => {
// we can assume (I think) that base will be either points or px;
// there's no case where it should be relative. in fact, let's treat
// that as an error and return 1.
// note that if properties is relative (em or %) we don't have to
// calculate, it's implicit
let base_pt = 12;
let props_pt = 12;
switch (properties.font_size?.unit) {
case 'pt':
if (!properties.font_size.value) { return 1; } // also error
props_pt = properties.font_size.value;
break;
case 'px':
if (!properties.font_size.value) { return 1; } // also error
props_pt = Math.round(properties.font_size.value * 300 / 4) / 100;
break;
case 'em':
return (properties.font_size.value || 1); // short circuit
case '%':
return (properties.font_size.value || 100) / 100; // short circuit
default:
return 1; // error
}
switch (base.font_size?.unit) {
case 'pt':
if (!base.font_size.value) { return 1; } // also error
base_pt = base.font_size.value;
break;
case 'px':
if (!base.font_size.value) { return 1; } // also error
base_pt = Math.round(base.font_size.value * 300 / 4) / 100;
break;
default:
return 1; // error
}
return props_pt / base_pt;
},
/**
* @internal
*/
FontSize: (properties: CellStyle, prefer_points = true): string => {
const value = properties.font_size?.value;
switch (properties.font_size?.unit) {
case 'pt':
return (value||12) + 'pt';
case 'px':
if (prefer_points) {
const points = Math.round((value||16) * 300 / 4) / 100;
return (points) + 'pt';
}
return (value||16) + 'px';
case 'em':
return (value||1) + 'em';
case '%':
return (value||100) + '%';
}
return '';
},
/**
* @internal
*
* generate a font size based on a base size (hopefully in actual units)
* and a relative size (em, %, or possibly a static unit). also optionally
* apply a scale.
*
*/
CompositeFontSize: (base: FontSize, relative: FontSize, scale = 1, prefer_points = false) => {
let composite: FontSize = { ...base };
// maybe it's actually not relative
if (relative.unit === 'pt' || relative.unit === 'px') {
composite = { ...relative };
}
else {
composite.value = relative.value * base.value;
if (relative.unit === '%') {
composite.value /= 100;
}
}
if (composite.unit === 'px' && prefer_points) {
composite.value = Math.round((composite.value||16) * 300 / 4) / 100;
}
composite.value *= scale;
return composite;
},
/**
* return a font string suitable for canvas. because our font sizes are
* (probably) in ems, we need a base size to bounce off.
*/
CompositeFont: (base: FontSize, properties: CellStyle, scale: number, theme: Theme) => {
let variants: string|undefined;
let stack_size: FontSize|undefined; // for reporting only
let font_size: FontSize|undefined;
const parts: string[] = [];
if (properties.bold) {
parts.push('bold');
}
if (properties.italic) {
parts.push('italic');
}
const font_face = properties.font_face || 'stack:default';
// let stack_scale = 1;
// check if this is a stack
if (font_face.startsWith('stack:')) {
let stack = theme.font_stacks[font_face.substring(6) || 'default'];
// default to default (not just a clever name). the rationale is we
// want to support environments that don't have fonts turned on. in
// that case, we just don't create the mappings, so everything shows
// as the default font.
if (!stack) {
stack = theme.font_stacks.default;
}
if (stack) {
stack_size = properties.font_size;
font_size = Style.CompositeFontSize(stack.size, properties.font_size || { unit: 'pt', value: 10 }, scale);
parts.push(font_size.value.toFixed(2) + font_size.unit);
parts.push(stack.font || '');
variants = stack.variants;
}
}
else {
font_size = Style.CompositeFontSize(base, properties.font_size || { unit: 'pt', value: 10 }, scale);
parts.push(font_size.value.toFixed(2) + font_size.unit);
parts.push(font_face || '');
}
return { font: parts.join(' '), variants, base, size: properties.font_size, scale, stack_size, font_size };
},
/*
Font2: (properties: CellStyle, scale: number, theme: Theme) => {
let features = false;
const parts: string[] = [];
if (properties.bold) {
parts.push('bold');
}
if (properties.italic) {
parts.push('italic');
}
const font_face = properties.font_face || 'stack:default';
let stack_scale = 1;
// check if this is a stack
if (font_face.startsWith('stack:')) {
const stack = theme.font_stacks[font_face.substring(6) || 'default'];
if (stack) {
stack_scale = stack.scale;
parts.push(((properties.font_size?.value || 0) * (scale || 1) * (stack.scale || 1)).toFixed(2) +
(properties.font_size?.unit || 'pt'));
parts.push(stack.font || '');
features = !!stack.apply_num_features;
}
}
else {
parts.push(((properties.font_size?.value || 0) * (scale || 1)).toFixed(2) +
(properties.font_size?.unit || 'pt'));
parts.push(font_face || '');
}
return { font: parts.join(' '), features, base_size: properties.font_size, scale, stack_scale };
},
*/
};