Skip to content

Commit

Permalink
[IMP] format: add the handling of repeated character
Browse files Browse the repository at this point in the history
This commit adds the handling of the `*` asterisk character in the format,
which repeats the character that comes after it enough times to fill
the column.

The asterisk is ignored in `cell.formattedValue`, and the character is
only repeated if the `formatValue` helper is given a `width` parameter,
which we now do only in the grid renderer.

The commit also adds this new format feature to the accounting format,
to match the behaviour of the other spreadsheet applications.

Task: 3965246
Part-of: #4961
Signed-off-by: Lucas Lefèvre (lul) <[email protected]>
  • Loading branch information
hokolomopo committed Sep 17, 2024
1 parent 55dcbc7 commit 20c0646
Show file tree
Hide file tree
Showing 13 changed files with 265 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -372,17 +372,16 @@ export class FindAndReplaceStore extends SpreadsheetStore implements HighlightPr

const searchRegex = getSearchRegex(searchString, searchOptions);
const replaceRegex = new RegExp(searchRegex.source, searchRegex.flags + "g");
const toReplace: string | null = this.getters.getCellText(
selectedMatch,
searchOptions.searchFormulas
);
const toReplace: string | null = this.getters.getCellText(selectedMatch, {
showFormula: searchOptions.searchFormulas,
});
const content = toReplace.replace(replaceRegex, replaceWith);
const canonicalContent = canonicalizeNumberContent(content, this.getters.getLocale());
this.model.dispatch("UPDATE_CELL", { ...selectedMatch, content: canonicalContent });
}

private getSearchableString(position: CellPosition): string {
return this.getters.getCellText(position, this.searchOptions.searchFormulas);
return this.getters.getCellText(position, { showFormula: this.searchOptions.searchFormulas });
}

// ---------------------------------------------------------------------------
Expand Down
91 changes: 81 additions & 10 deletions src/helpers/format/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { FormatToken } from "./format_tokenizer";
*/
const DEFAULT_FORMAT_NUMBER_OF_DIGITS = 11;

const REPEATED_CHAR_PLACEHOLDER = "REPEATED_CHAR_PLACEHOLDER_";

// TODO in the future : remove these constants MONTHS/DAYS, and use a library such as luxon to handle it
// + possibly handle automatic translation of day/month
export const MONTHS: Readonly<Record<number, string>> = {
Expand Down Expand Up @@ -64,13 +66,22 @@ export const DAYS: Readonly<Record<number, string>> = {
6: _t("Saturday"),
};

interface FormatWidth {
availableWidth: number;
measureText: (text: string) => number;
}

/**
* Formats a cell value with its format.
*/
export function formatValue(value: CellValue, { format, locale }: LocaleFormat): FormattedValue {
export function formatValue(
value: CellValue,
{ format, locale, formatWidth }: LocaleFormat & { formatWidth?: FormatWidth }
): FormattedValue {
if (typeof value === "boolean") {
value = value ? "TRUE" : "FALSE";
}

switch (typeof value) {
case "string": {
if (value.includes('\\"')) {
Expand All @@ -84,7 +95,7 @@ export function formatValue(value: CellValue, { format, locale }: LocaleFormat):
if (!formatToApply || formatToApply.type !== "text") {
return value;
}
return applyTextInternalFormat(value, formatToApply);
return applyTextInternalFormat(value, formatToApply, formatWidth);
}
case "number":
if (!format) {
Expand All @@ -93,7 +104,7 @@ export function formatValue(value: CellValue, { format, locale }: LocaleFormat):

const internalFormat = parseFormat(format);
if (internalFormat.positive.type === "text") {
return applyTextInternalFormat(value.toString(), internalFormat.positive);
return applyTextInternalFormat(value.toString(), internalFormat.positive, formatWidth);
}

let formatToApply: InternalFormat = internalFormat.positive;
Expand All @@ -105,11 +116,14 @@ export function formatValue(value: CellValue, { format, locale }: LocaleFormat):
}

if (formatToApply.type === "date") {
return applyDateTimeFormat(value, formatToApply);
return repeatCharToFitWidth(applyDateTimeFormat(value, formatToApply), formatWidth);
}

const isNegative = value < 0;
const formatted = applyInternalNumberFormat(Math.abs(value), formatToApply, locale);
const formatted = repeatCharToFitWidth(
applyInternalNumberFormat(Math.abs(value), formatToApply, locale),
formatWidth
);
return isNegative ? "-" + formatted : formatted;
case "object": // case value === null
return "";
Expand All @@ -118,7 +132,8 @@ export function formatValue(value: CellValue, { format, locale }: LocaleFormat):

function applyTextInternalFormat(
value: string,
internalFormat: TextInternalFormat
internalFormat: TextInternalFormat,
formatWidth?: FormatWidth
): FormattedValue {
let formattedValue = "";
for (const token of internalFormat.tokens) {
Expand All @@ -130,9 +145,46 @@ function applyTextInternalFormat(
case "STRING":
formattedValue += token.value;
break;
case "REPEATED_CHAR":
formattedValue += REPEATED_CHAR_PLACEHOLDER + token.value;
break;
}
}
return formattedValue;
return repeatCharToFitWidth(formattedValue, formatWidth);
}

function repeatCharToFitWidth(formattedValue: string, formatWidth?: FormatWidth): string {
const placeholderIndex = formattedValue.indexOf(REPEATED_CHAR_PLACEHOLDER);
if (placeholderIndex === -1) {
return formattedValue;
}
const prefix = formattedValue.slice(0, placeholderIndex);
const suffix = formattedValue.slice(placeholderIndex + REPEATED_CHAR_PLACEHOLDER.length + 1);
const repeatedChar = formattedValue[placeholderIndex + REPEATED_CHAR_PLACEHOLDER.length];

function getTimesToRepeat() {
if (!formatWidth) {
return { timesToRepeat: 0, padding: "" };
}
const widthTaken = formatWidth.measureText(prefix + suffix);
const charWidth = formatWidth.measureText(repeatedChar);
const availableWidth = formatWidth.availableWidth - widthTaken;
if (availableWidth <= 0) {
return { timesToRepeat: 0, padding: "" };
}

const timesToRepeat = Math.floor(availableWidth / charWidth);

const remainingWidth = availableWidth - timesToRepeat * charWidth;
const paddingChar = "\u2009"; // thin space
const paddingWidth = formatWidth.measureText(paddingChar);
const padding = paddingChar.repeat(Math.floor(remainingWidth / paddingWidth));

return { timesToRepeat, padding };
}

const { timesToRepeat, padding } = getTimesToRepeat();
return prefix + repeatedChar.repeat(timesToRepeat) + padding + suffix;
}

function applyInternalNumberFormat(value: number, format: NumberInternalFormat, locale: Locale) {
Expand Down Expand Up @@ -212,6 +264,9 @@ function applyIntegerFormat(
break;
case "THOUSANDS_SEPARATOR":
break;
case "REPEATED_CHAR":
formattedInteger = REPEATED_CHAR_PLACEHOLDER + token.value + formattedInteger;
break;
default:
formattedInteger = token.value + formattedInteger;
break;
Expand Down Expand Up @@ -242,6 +297,9 @@ function applyDecimalFormat(decimalDigits: string, internalFormat: NumberInterna
break;
case "THOUSANDS_SEPARATOR":
break;
case "REPEATED_CHAR":
formattedDecimals += REPEATED_CHAR_PLACEHOLDER + token.value;
break;
default:
formattedDecimals += token.value;
break;
Expand Down Expand Up @@ -412,6 +470,9 @@ function applyDateTimeFormat(value: number, internalFormat: DateInternalFormat):
case "DATE_PART":
currentValue += formatJSDatePart(jsDate, token.value, isMeridian);
break;
case "REPEATED_CHAR":
currentValue += REPEATED_CHAR_PLACEHOLDER + token.value;
break;
default:
currentValue += token.value;
break;
Expand Down Expand Up @@ -563,12 +624,22 @@ export function createAccountingFormat(currency: Partial<Currency>): Format {
if (position === "after" && code) {
textExpression = " " + textExpression;
}
const positivePart = insertTextInFormat(textExpression, position, `${numberFormat}`);
const negativePart = insertTextInFormat(textExpression, position, `(${numberFormat})`);
const zeroPart = insertTextInFormat(textExpression, position, "- ");

const positivePart = insertTextInAccountingFormat(textExpression, position, ` ${numberFormat} `);
const negativePart = insertTextInAccountingFormat(textExpression, position, `(${numberFormat})`);
const zeroPart = insertTextInAccountingFormat(textExpression, position, " - ");
return [positivePart, negativePart, zeroPart].join(";");
}

function insertTextInAccountingFormat(
text: string,
position: "before" | "after",
format: Format
): Format {
const textExpression = `[$${text}]`;
return position === "before" ? textExpression + "* " + format : format + "* " + textExpression;
}

function insertTextInFormat(text: string, position: "before" | "after", format: Format): Format {
const textExpression = `[$${text}]`;
return position === "before" ? textExpression + format : format + textExpression;
Expand Down
32 changes: 27 additions & 5 deletions src/helpers/format/format_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DigitToken,
FormatToken,
PercentToken,
RepeatCharToken,
StringToken,
TextPlaceholderToken,
ThousandsSeparatorToken,
Expand All @@ -29,7 +30,7 @@ export interface MultiPartInternalFormat {

export interface DateInternalFormat {
type: "date";
tokens: (DatePartToken | StringToken | CharToken)[];
tokens: (DatePartToken | StringToken | CharToken | RepeatCharToken)[];
}

export interface NumberInternalFormat {
Expand All @@ -40,6 +41,7 @@ export interface NumberInternalFormat {
| CharToken
| PercentToken
| ThousandsSeparatorToken
| RepeatCharToken
)[];
readonly percentSymbols: number;
readonly thousandsSeparator: boolean;
Expand All @@ -56,12 +58,13 @@ export interface NumberInternalFormat {
| CharToken
| PercentToken
| ThousandsSeparatorToken
| RepeatCharToken
)[];
}

export interface TextInternalFormat {
type: "text";
tokens: (StringToken | CharToken | TextPlaceholderToken)[];
tokens: (StringToken | CharToken | TextPlaceholderToken | RepeatCharToken)[];
}

export type InternalFormat = NumberInternalFormat | DateInternalFormat | TextInternalFormat;
Expand All @@ -80,6 +83,14 @@ export function parseFormat(formatString: Format): MultiPartInternalFormat {
function convertFormatToInternalFormat(format: Format): MultiPartInternalFormat {
const formatParts = tokenizeFormat(format);

// A format can only have a single REPEATED_CHAR token. The rest are converted to simple CHAR tokens.
for (const part of formatParts) {
const repeatedCharTokens = part.filter((token) => token.type === "REPEATED_CHAR");
for (const repeatedCharToken of repeatedCharTokens.slice(1)) {
repeatedCharToken.type = "CHAR";
}
}

const positiveFormat =
parseDateFormatTokens(formatParts[0]) ||
parseNumberFormatTokens(formatParts[0]) ||
Expand Down Expand Up @@ -126,7 +137,8 @@ function areValidDateFormatTokens(
token.type === "DECIMAL_POINT" ||
token.type === "THOUSANDS_SEPARATOR" ||
token.type === "STRING" ||
token.type === "CHAR"
token.type === "CHAR" ||
token.type === "REPEATED_CHAR"
);
}

Expand All @@ -139,6 +151,7 @@ function areValidNumberFormatTokens(
| PercentToken
| StringToken
| CharToken
| RepeatCharToken
)[] {
return tokens.every(
(token) =>
Expand All @@ -147,13 +160,18 @@ function areValidNumberFormatTokens(
token.type === "THOUSANDS_SEPARATOR" ||
token.type === "PERCENT" ||
token.type === "STRING" ||
token.type === "CHAR"
token.type === "CHAR" ||
token.type === "REPEATED_CHAR"
);
}

function areValidTextFormatTokens(tokens: FormatToken[]): tokens is TextInternalFormat["tokens"] {
return tokens.every(
(token) => token.type === "STRING" || token.type === "TEXT_PLACEHOLDER" || token.type === "CHAR"
(token) =>
token.type === "STRING" ||
token.type === "TEXT_PLACEHOLDER" ||
token.type === "CHAR" ||
token.type === "REPEATED_CHAR"
);
}

Expand Down Expand Up @@ -192,6 +210,7 @@ function parseNumberFormatTokens(
throw new Error("Multiple decimal points in a number format");
}
break;
case "REPEATED_CHAR":
case "CHAR":
case "STRING":
parsedPart.push(token);
Expand Down Expand Up @@ -308,6 +327,9 @@ function internalFormatPartToFormat(
case "CHAR":
format += shouldEscapeFormatChar(token.value) ? `\\${token.value}` : token.value;
break;
case "REPEATED_CHAR":
format += "*" + token.value;
break;
default:
format += token.value;
}
Expand Down
27 changes: 25 additions & 2 deletions src/helpers/format/format_tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export interface DatePartToken {
value: string;
}

export interface RepeatCharToken {
type: "REPEATED_CHAR";
value: string;
}

export type FormatToken =
| DigitToken
| DecimalPointToken
Expand All @@ -48,7 +53,8 @@ export type FormatToken =
| PercentToken
| ThousandsSeparatorToken
| TextPlaceholderToken
| DatePartToken;
| DatePartToken
| RepeatCharToken;

export function tokenizeFormat(str: string): FormatToken[][] {
const chars = new TokenizingChars(str);
Expand All @@ -72,7 +78,8 @@ export function tokenizeFormat(str: string): FormatToken[][] {
tokenizeDecimalPoint(chars) ||
tokenizePercent(chars) ||
tokenizeDatePart(chars) ||
tokenizeTextPlaceholder(chars);
tokenizeTextPlaceholder(chars) ||
tokenizeRepeatedChar(chars);

if (!token) {
throw new Error("Unknown token at " + chars.remaining());
Expand Down Expand Up @@ -188,3 +195,19 @@ function tokenizeDatePart(chars: TokenizingChars): FormatToken | null {
}
return { type: "DATE_PART", value };
}

function tokenizeRepeatedChar(chars: TokenizingChars): FormatToken | null {
if (chars.current !== "*") {
return null;
}

chars.shift();
const repeatedChar = chars.shift();
if (!repeatedChar) {
throw new Error("Unexpected end of format string");
}
return {
type: "REPEATED_CHAR",
value: repeatedChar,
};
}
Loading

0 comments on commit 20c0646

Please sign in to comment.