Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+15
View File
@@ -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);
}
+98
View File
@@ -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("Текст");
});
});
+373
View File
@@ -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");
}