Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export function downloadTextFile(fileName: string, contents: string, mimeType: string) {
|
||||
const blob = new Blob([contents], { type: mimeType });
|
||||
downloadBlobFile(fileName, blob);
|
||||
}
|
||||
|
||||
export function downloadBlobFile(fileName: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cellId,
|
||||
columnLabel,
|
||||
columnLabelsForCount,
|
||||
evaluateCell,
|
||||
expandRange,
|
||||
formatCellValueForDisplay,
|
||||
mergedCellInfo,
|
||||
normalizeCellRange,
|
||||
parseCellId,
|
||||
summarizeColumn,
|
||||
usedSheetBounds
|
||||
} from "./spreadsheet";
|
||||
|
||||
describe("spreadsheet utilities", () => {
|
||||
it("expands rectangular ranges", () => {
|
||||
expect(expandRange("A1:B2")).toEqual(["A1", "B1", "A2", "B2"]);
|
||||
});
|
||||
|
||||
it("supports Excel-style columns beyond H", () => {
|
||||
expect(columnLabel(0)).toBe("A");
|
||||
expect(columnLabel(25)).toBe("Z");
|
||||
expect(columnLabel(26)).toBe("AA");
|
||||
expect(columnLabelsForCount(28).slice(-2)).toEqual(["AA", "AB"]);
|
||||
expect(cellId(27, 19)).toBe("AB20");
|
||||
expect(parseCellId("ab20")).toEqual({ column: 27, row: 19 });
|
||||
expect(expandRange("Z1:AA2")).toEqual(["Z1", "AA1", "Z2", "AA2"]);
|
||||
});
|
||||
|
||||
it("evaluates aggregate formulas", () => {
|
||||
const cells = { A1: "2", A2: "3", A3: "=SUM(A1:A2)", A4: "=AVG(A1:A2)" };
|
||||
expect(evaluateCell("A3", cells)).toBe("5");
|
||||
expect(evaluateCell("A4", cells)).toBe("2.50");
|
||||
});
|
||||
|
||||
it("evaluates arithmetic formulas with cell references", () => {
|
||||
const cells = { A1: "10", B1: "5", C1: "=A1*B1+2" };
|
||||
expect(evaluateCell("C1", cells)).toBe("52");
|
||||
});
|
||||
|
||||
it("evaluates formulas outside the default visible grid", () => {
|
||||
const cells = { AA20: "7", AB20: "8", AC20: "=SUM(AA20:AB20)", AD20: "=AC20*2" };
|
||||
expect(evaluateCell("AC20", cells)).toBe("15");
|
||||
expect(evaluateCell("AD20", cells)).toBe("30");
|
||||
});
|
||||
|
||||
it("reports cycles instead of silently treating them as zero", () => {
|
||||
const cells = { A1: "=SUM(A1:A2)", A2: "2", B1: "=B1+1" };
|
||||
expect(evaluateCell("A1", cells)).toBe("#CYCLE");
|
||||
expect(evaluateCell("B1", cells)).toBe("#CYCLE");
|
||||
});
|
||||
|
||||
it("calculates used sheet bounds from populated cells", () => {
|
||||
expect(usedSheetBounds({ C3: "x", AA20: "y" })).toEqual({ columnCount: 27, rowCount: 20 });
|
||||
expect(usedSheetBounds({}, [{ start: "A1", end: "J24" }])).toEqual({ columnCount: 10, rowCount: 24 });
|
||||
expect(usedSheetBounds({})).toEqual({ columnCount: 8, rowCount: 16 });
|
||||
});
|
||||
|
||||
it("normalizes and locates merged cell ranges", () => {
|
||||
expect(normalizeCellRange({ start: "c2", end: "A1" })).toEqual({ start: "A1", end: "C2" });
|
||||
expect(mergedCellInfo("B1", [{ start: "A1", end: "C2" }])).toEqual({
|
||||
range: { start: "A1", end: "C2" },
|
||||
isOrigin: false,
|
||||
columnSpan: 3,
|
||||
rowSpan: 2
|
||||
});
|
||||
expect(mergedCellInfo("A1", [{ start: "A1", end: "C2" }])?.isOrigin).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the final aggregate formula as the column summary when present", () => {
|
||||
const cells = {
|
||||
E2: "=SUM(B2:D2)",
|
||||
E3: "=SUM(B3:D3)",
|
||||
E4: "=SUM(B4:D4)",
|
||||
E6: "=SUM(E2:E4)",
|
||||
B2: "12500",
|
||||
C2: "14300",
|
||||
D2: "16800",
|
||||
B3: "8200",
|
||||
C3: "9100",
|
||||
D3: "9900",
|
||||
B4: "19600",
|
||||
C4: "21400",
|
||||
D4: "23800"
|
||||
};
|
||||
|
||||
expect(summarizeColumn(cells, 4)).toBe(135600);
|
||||
});
|
||||
|
||||
it("formats numeric cell display values from XLSX number formats", () => {
|
||||
expect(formatCellValueForDisplay("44927", { numberFormat: "dd.mm.yyyy" })).toBe("01.01.2023");
|
||||
expect(formatCellValueForDisplay("44927.5", { numberFormat: "dd.mm.yyyy hh:mm" })).toBe("01.01.2023 12:00");
|
||||
expect(formatCellValueForDisplay("0.25", { numberFormat: "0%" })).toBe("25%");
|
||||
expect(formatCellValueForDisplay("1250.5", { numberFormat: "# ##0.00 ₽" })).toBe("1 250,50 ₽");
|
||||
expect(formatCellValueForDisplay("Текст", { numberFormat: "0.00" })).toBe("Текст");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { CellFormat } from "../types";
|
||||
|
||||
export const DEFAULT_COLUMN_COUNT = 8;
|
||||
export const DEFAULT_ROW_COUNT = 16;
|
||||
export const columnLabels = columnLabelsForCount(DEFAULT_COLUMN_COUNT);
|
||||
|
||||
export interface SpreadsheetRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export function cellId(columnIndex: number, rowIndex: number) {
|
||||
return `${columnLabel(columnIndex)}${rowIndex + 1}`;
|
||||
}
|
||||
|
||||
export function parseCellId(id: string) {
|
||||
const match = /^([A-Z]+)([1-9][0-9]*)$/i.exec(id.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const column = columnIndex(match[1]);
|
||||
if (column < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = Number(match[2]) - 1;
|
||||
return { column, row };
|
||||
}
|
||||
|
||||
export function columnLabel(columnIndex: number) {
|
||||
let value = Math.max(0, Math.floor(columnIndex)) + 1;
|
||||
let label = "";
|
||||
|
||||
while (value > 0) {
|
||||
const remainder = (value - 1) % 26;
|
||||
label = String.fromCharCode(65 + remainder) + label;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
|
||||
return label || "A";
|
||||
}
|
||||
|
||||
export function columnLabelsForCount(count: number) {
|
||||
const normalizedCount = Math.max(0, Math.floor(count));
|
||||
return Array.from({ length: normalizedCount }, (_, index) => columnLabel(index));
|
||||
}
|
||||
|
||||
export function usedSheetBounds(
|
||||
cells: Record<string, string>,
|
||||
ranges: SpreadsheetRange[] = [],
|
||||
minimumColumns = DEFAULT_COLUMN_COUNT,
|
||||
minimumRows = DEFAULT_ROW_COUNT
|
||||
) {
|
||||
const cellBounds = Object.entries(cells).reduce(
|
||||
(bounds, [address, value]) => {
|
||||
if (value === "") {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
const parsed = parseCellId(address);
|
||||
if (!parsed) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
return {
|
||||
columnCount: Math.max(bounds.columnCount, parsed.column + 1),
|
||||
rowCount: Math.max(bounds.rowCount, parsed.row + 1)
|
||||
};
|
||||
},
|
||||
{
|
||||
columnCount: minimumColumns,
|
||||
rowCount: minimumRows
|
||||
}
|
||||
);
|
||||
|
||||
return ranges.reduce((bounds, range) => {
|
||||
const rangeBounds = cellRangeBounds(range);
|
||||
if (!rangeBounds) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
return {
|
||||
columnCount: Math.max(bounds.columnCount, rangeBounds.endColumn + 1),
|
||||
rowCount: Math.max(bounds.rowCount, rangeBounds.endRow + 1)
|
||||
};
|
||||
}, cellBounds);
|
||||
}
|
||||
|
||||
export function summarizeColumn(cells: Record<string, string>, columnIndex: number) {
|
||||
const bounds = usedSheetBounds(cells);
|
||||
const entries = Array.from({ length: bounds.rowCount }, (_, rowIndex) => {
|
||||
const id = cellId(columnIndex, rowIndex);
|
||||
const raw = cells[id] ?? "";
|
||||
const evaluated = evaluateCell(id, cells);
|
||||
return {
|
||||
raw,
|
||||
value: Number(evaluated)
|
||||
};
|
||||
}).filter((entry) => Number.isFinite(entry.value));
|
||||
|
||||
const aggregateEntry = [...entries].reverse().find((entry) => /^=(SUM|AVG|MIN|MAX)\(/i.test(entry.raw.trim()));
|
||||
if (aggregateEntry) {
|
||||
return aggregateEntry.value;
|
||||
}
|
||||
|
||||
return entries.reduce((sum, entry) => sum + entry.value, 0);
|
||||
}
|
||||
|
||||
export function expandRange(range: string) {
|
||||
const [start, end] = range.split(":");
|
||||
const parsedStart = parseCellId(start);
|
||||
const parsedEnd = parseCellId(end);
|
||||
if (!parsedStart || !parsedEnd) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const minColumn = Math.min(parsedStart.column, parsedEnd.column);
|
||||
const maxColumn = Math.max(parsedStart.column, parsedEnd.column);
|
||||
const minRow = Math.min(parsedStart.row, parsedEnd.row);
|
||||
const maxRow = Math.max(parsedStart.row, parsedEnd.row);
|
||||
const ids: string[] = [];
|
||||
|
||||
for (let row = minRow; row <= maxRow; row += 1) {
|
||||
for (let column = minColumn; column <= maxColumn; column += 1) {
|
||||
ids.push(cellId(column, row));
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function normalizeCellRange(range: SpreadsheetRange): SpreadsheetRange | null {
|
||||
const bounds = cellRangeBounds(range);
|
||||
if (!bounds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: cellId(bounds.startColumn, bounds.startRow),
|
||||
end: cellId(bounds.endColumn, bounds.endRow)
|
||||
};
|
||||
}
|
||||
|
||||
export function cellRangeBounds(range: SpreadsheetRange) {
|
||||
const parsedStart = parseCellId(range.start);
|
||||
const parsedEnd = parseCellId(range.end);
|
||||
if (!parsedStart || !parsedEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startColumn: Math.min(parsedStart.column, parsedEnd.column),
|
||||
startRow: Math.min(parsedStart.row, parsedEnd.row),
|
||||
endColumn: Math.max(parsedStart.column, parsedEnd.column),
|
||||
endRow: Math.max(parsedStart.row, parsedEnd.row)
|
||||
};
|
||||
}
|
||||
|
||||
export function mergedCellInfo(cell: string, ranges: SpreadsheetRange[]) {
|
||||
const parsedCell = parseCellId(cell);
|
||||
if (!parsedCell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const bounds = cellRangeBounds(range);
|
||||
if (!bounds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inside =
|
||||
parsedCell.column >= bounds.startColumn &&
|
||||
parsedCell.column <= bounds.endColumn &&
|
||||
parsedCell.row >= bounds.startRow &&
|
||||
parsedCell.row <= bounds.endRow;
|
||||
if (!inside) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
range: normalizeCellRange(range),
|
||||
isOrigin: parsedCell.column === bounds.startColumn && parsedCell.row === bounds.startRow,
|
||||
columnSpan: bounds.endColumn - bounds.startColumn + 1,
|
||||
rowSpan: bounds.endRow - bounds.startRow + 1
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function valuesForArgument(argument: string, cells: Record<string, string>, seen: Set<string>) {
|
||||
if (argument.includes(":")) {
|
||||
return expandRange(argument).map((id) => evaluateCell(id, cells, seen));
|
||||
}
|
||||
|
||||
return [evaluateCell(argument, cells, seen)];
|
||||
}
|
||||
|
||||
export function evaluateCell(id: string, cells: Record<string, string>, seen = new Set<string>()): string {
|
||||
const normalizedId = id.trim().toUpperCase();
|
||||
const raw = cells[normalizedId] ?? cells[id] ?? "";
|
||||
if (!raw.startsWith("=")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (seen.has(normalizedId)) {
|
||||
return "#CYCLE";
|
||||
}
|
||||
|
||||
seen.add(normalizedId);
|
||||
|
||||
try {
|
||||
const expression = raw.slice(1).trim();
|
||||
const aggregate = /^(SUM|AVG|MIN|MAX)\(([^)]+)\)$/i.exec(expression);
|
||||
if (aggregate) {
|
||||
const fn = aggregate[1].toUpperCase();
|
||||
const evaluatedValues = aggregate[2].split(",").flatMap((argument) => valuesForArgument(argument.trim(), cells, seen));
|
||||
const errorValue = evaluatedValues.find((value) => value.startsWith("#"));
|
||||
if (errorValue) {
|
||||
return errorValue;
|
||||
}
|
||||
|
||||
const values = evaluatedValues.map(toNumber);
|
||||
|
||||
if (values.length === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const result =
|
||||
fn === "AVG"
|
||||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
: fn === "MIN"
|
||||
? Math.min(...values)
|
||||
: fn === "MAX"
|
||||
? Math.max(...values)
|
||||
: values.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
return formatNumber(result);
|
||||
}
|
||||
|
||||
let referenceError = "";
|
||||
const safeExpression = expression.replace(/\b[A-Z]+[1-9][0-9]*\b/gi, (reference) => {
|
||||
const evaluated = evaluateCell(reference.toUpperCase(), cells, seen);
|
||||
if (evaluated.startsWith("#")) {
|
||||
referenceError = evaluated;
|
||||
return "0";
|
||||
}
|
||||
|
||||
return String(toNumber(evaluated));
|
||||
});
|
||||
|
||||
if (referenceError) {
|
||||
return referenceError;
|
||||
}
|
||||
|
||||
if (!/^[0-9+\-*/().\s]+$/.test(safeExpression)) {
|
||||
return "#VALUE";
|
||||
}
|
||||
|
||||
const result = Function(`"use strict"; return (${safeExpression});`)();
|
||||
return Number.isFinite(result) ? formatNumber(result) : "#VALUE";
|
||||
} catch {
|
||||
return "#VALUE";
|
||||
} finally {
|
||||
seen.delete(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
function columnIndex(label: string) {
|
||||
const normalized = label.trim().toUpperCase();
|
||||
if (!/^[A-Z]+$/.test(normalized)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return normalized.split("").reduce((value, letter) => value * 26 + letter.charCodeAt(0) - 64, 0) - 1;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
}
|
||||
|
||||
export function formatCellValueForDisplay(value: string, format?: CellFormat) {
|
||||
const numberFormat = format?.numberFormat?.trim();
|
||||
if (!numberFormat || value === "" || value.startsWith("#")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const pattern = normalizedNumberFormatPattern(numberFormat);
|
||||
if (isDateLikeNumberFormat(pattern)) {
|
||||
return formatExcelSerialDate(number, pattern) ?? value;
|
||||
}
|
||||
|
||||
if (pattern.includes("%")) {
|
||||
return `${formatFixedNumber(number * 100, decimalPlacesForFormat(pattern))}%`;
|
||||
}
|
||||
|
||||
const currency = currencySymbolForFormat(numberFormat);
|
||||
if (currency) {
|
||||
return `${formatFixedNumber(number, decimalPlacesForFormat(pattern))} ${currency}`;
|
||||
}
|
||||
|
||||
if (/[#0]\.[#0]+/.test(pattern)) {
|
||||
return formatFixedNumber(number, decimalPlacesForFormat(pattern));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizedNumberFormatPattern(format: string) {
|
||||
return format
|
||||
.replace(/"[^"]*"/g, "")
|
||||
.replace(/\\./g, "")
|
||||
.replace(/\[[^\]]+]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isDateLikeNumberFormat(pattern: string) {
|
||||
return /[dy]/.test(pattern) || (/[hs]/.test(pattern) && /m/.test(pattern));
|
||||
}
|
||||
|
||||
function decimalPlacesForFormat(pattern: string) {
|
||||
const decimalPart = /[.,]([#0]+)/.exec(pattern)?.[1] ?? "";
|
||||
return decimalPart.length;
|
||||
}
|
||||
|
||||
function currencySymbolForFormat(format: string) {
|
||||
return /[₽$€£¥]/.exec(format)?.[0] ?? "";
|
||||
}
|
||||
|
||||
function formatFixedNumber(value: number, decimals: number) {
|
||||
const safeDecimals = Math.max(0, Math.min(decimals, 10));
|
||||
const [integerPart, fractionPart = ""] = Math.abs(value).toFixed(safeDecimals).split(".");
|
||||
const sign = value < 0 ? "-" : "";
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return fractionPart ? `${sign}${groupedInteger},${fractionPart}` : `${sign}${groupedInteger}`;
|
||||
}
|
||||
|
||||
function formatExcelSerialDate(serial: number, pattern: string) {
|
||||
if (serial <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const milliseconds = Math.round((serial - 25569) * 86400000);
|
||||
const date = new Date(milliseconds);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasDate = /[dy]/.test(pattern);
|
||||
const hasTime = /[hs]/.test(pattern);
|
||||
const hasSeconds = /s/.test(pattern);
|
||||
const datePart = hasDate ? `${pad2(date.getUTCDate())}.${pad2(date.getUTCMonth() + 1)}.${date.getUTCFullYear()}` : "";
|
||||
const timePart = hasTime
|
||||
? `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}${hasSeconds ? `:${pad2(date.getUTCSeconds())}` : ""}`
|
||||
: "";
|
||||
|
||||
return [datePart, timePart].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function pad2(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
Reference in New Issue
Block a user