Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions README.en.md

Large diffs are not rendered by default.

39 changes: 23 additions & 16 deletions README.md

Large diffs are not rendered by default.

Binary file modified public/screenshots/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/screenshots/en/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions public/screenshots/screenshots.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 1979,
"tests": 1985,
"securityTests": 325
},
"38-landing-problems.png": {
Expand All @@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 1979,
"tests": 1985,
"securityTests": 325
},
"en/38-landing-problems.png": {
Expand Down
Binary file modified public/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
["1979", "automated tests"],
["1985", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
Expand Down
5 changes: 1 addition & 4 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
{ value: "1979", label: "automated tests" },
{ value: "1985", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
Expand Down Expand Up @@ -818,15 +818,12 @@ export const en: Messages = {
pivots: (n) => `${n} Excel ${n === 1 ? "PivotTable" : "PivotTables"}`,
unknownFunctions: (n, names) => `${n} ${n === 1 ? "formula uses" : "formulas use"} a function the app doesn't have yet (${names.join(", ")})`,
names: (n, names) => `${n} range ${n === 1 ? "name" : "names"} that could not be brought in (${names.join(", ")})`,
errorValues: (n) => `${n} ${n === 1 ? "cell holds" : "cells hold"} an error value such as #DIV/0! or #N/A`,
externalLinks: (n) => `${n} ${n === 1 ? "link" : "links"} to other files`,
macros: () => "Macros (VBA)",
},
detail: {
unknownFunctions: "The app shows #NAME? for them, but the exported file keeps the formula as it was, and Excel can calculate it",
names: "Formulas using them read #NAME?, and the exported file won't have these names. Only a name for one range on a sheet of the file comes in — not a formula, a whole column or several areas",
errorValues:
"The app shows them as text, so a formula that reads one does not see an error: SUM steps over it and gives a number. The exported file writes them back as error values, as they were",
externalLinks: "Values pulled from other files won't update in the app",
macros: "The app doesn't run macros, and the exported file doesn't carry them",
},
Expand Down
4 changes: 1 addition & 3 deletions src/i18n/th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
{ value: "1979", label: "เทสต์อัตโนมัติ" },
{ value: "1985", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
Expand Down Expand Up @@ -817,14 +817,12 @@ export const th: Messages = {
pivots: (n) => `Pivot Table ของ Excel ${n} ตัว`,
unknownFunctions: (n, names) => `สูตร ${n} ช่องที่ใช้ฟังก์ชันที่แอปยังไม่มี (${names.join(", ")})`,
names: (n, names) => `ชื่อช่วง ${n} ชื่อที่นำเข้ามาไม่ได้ (${names.join(", ")})`,
errorValues: (n) => `เซลล์ ${n} ช่องที่เก็บค่า error เช่น #DIV/0! หรือ #N/A`,
externalLinks: (n) => `ลิงก์ไปไฟล์อื่น ${n} ลิงก์`,
macros: () => "มาโคร (VBA)",
},
detail: {
unknownFunctions: "ในแอปจะขึ้น #NAME? แต่ไฟล์ที่ส่งออกยังเก็บสูตรเดิมไว้ เปิดใน Excel แล้วคำนวณได้",
names: "สูตรที่ใช้ชื่อเหล่านี้ขึ้น #NAME? และไฟล์ที่ส่งออกจะไม่มีชื่อเหล่านี้ · นำเข้าได้เฉพาะชื่อที่ชี้ช่วงเดียวบนชีตในไฟล์ ไม่ใช่สูตร ทั้งคอลัมน์ หรือหลายช่วง",
errorValues: "ในแอปแสดงเป็นข้อความ สูตรที่อ่านช่องเหล่านี้จึงไม่เห็นว่าเป็น error เช่น SUM จะข้ามไปแล้วได้ตัวเลข · ไฟล์ที่ส่งออกยังเขียนกลับเป็นค่า error เหมือนเดิม",
externalLinks: "ค่าที่ดึงจากไฟล์อื่นจะไม่อัปเดตในแอป",
macros: "แอปไม่รันมาโคร และไฟล์ที่ส่งออกไม่มีมาโครติดไป",
},
Expand Down
33 changes: 32 additions & 1 deletion src/lib/cellLiteral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

import ExcelJS from "exceljs";
import { beforeEach, describe, expect, it } from "vitest";
import { formattedNumber, literalValue, looksNumeric, rawForText } from "./cellLiteral";
import { ERROR_CODES, formattedNumber, literalValue, looksNumeric, rawForText } from "./cellLiteral";
import { FormulaError } from "./formulaEngine/types";
import { computeSheet, createEmptySheet, setCellRaw, setRangeFormat, SheetModel } from "./sheet";
import { computeStats, resetComputeCache } from "./sheetCompute";
import { sortRange } from "./sheetSort";
Expand Down Expand Up @@ -187,6 +188,36 @@ describe("rawForText", () => {
});
});

describe("an error code in a cell is that error (#226)", () => {
it("for each of the seven codes, spelled as Excel spells them", () => {
for (const code of ERROR_CODES) {
const value = literalValue(code);
expect(value, code).toBeInstanceOf(FormulaError);
expect((value as FormulaError).code, code).toBe(code);
}
});

it("in any case, spelled back as Excel spells it, as a typed #n/a is in Excel", () => {
for (const typed of ["#n/a", "#div/0!", "#Value!", "#ref!"]) {
const value = literalValue(typed);
expect(value, typed).toBeInstanceOf(FormulaError);
expect((value as FormulaError).code, typed).toBe(typed.toUpperCase());
}
// A file's string cell reading "#n/a" is text there too, so it keeps an apostrophe.
expect(rawForText("#n/a")).toBe("'#n/a");
});

it("but stays text after an apostrophe, under the Text format, or when it only starts with #", () => {
expect(literalValue("'#N/A")).toBe("#N/A");
expect(literalValue("#DIV/0!", "text")).toBe("#DIV/0!");
// `#SPILL!` and the other newer codes stay text when typed into Excel too.
for (const t of ["#1", "#N/A please", "#hashtag", "#", "#SPILL!", "#CALC!"]) expect(literalValue(t), t).toBe(t);
// Text from a file's string cell keeps its apostrophe, so it does not turn into the error.
expect(rawForText("#N/A")).toBe("'#N/A");
expect(literalValue(rawForText("#DIV/0!"))).toBe("#DIV/0!");
});
});

describe("the .xlsx round trip", () => {
async function exported(column: string[]): Promise<ExcelJS.Worksheet> {
const sheet = sheetWith(column);
Expand Down
22 changes: 20 additions & 2 deletions src/lib/cellLiteral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { NumberFormat } from "./cellFormat";
import { dateLiteral } from "./excelDate";
import type { FormulaValue } from "./formulaEngine/types";
import { FormulaError, type FormulaValue } from "./formulaEngine/types";

/**
* What a non-formula cell holds: a number when it reads as one, its text otherwise.
Expand Down Expand Up @@ -33,7 +33,13 @@ import type { FormulaValue } from "./formulaEngine/types";
* a pasted column of them used to add up to 0. The text stays in the cell and shows as typed; the
* file gets the number with a matching format (`formattedNumber`). Commas in the wrong places
* (`1,25`), dashes (`081-234-5678`, `123-4-56789-0`) and a leading zero (`01,250`) stay text.
* 7. Anything else that `Number()` accepts is a number.
* 7. **One of Excel's seven error codes** (`#N/A`, `#DIV/0!`, …) is that error (#226), as when it is
* typed into Excel: `SUM` over it gives the error and `IFERROR` catches it. It used to be text,
* so a pasted or imported `#DIV/0!` was stepped over and the total came out clean. Case does not
* matter, as in Excel, where a typed `#n/a` becomes `#N/A`. Text that only starts with `#`
* (`#1`, `#N/A please`) is still text, and so are the newer codes (`#SPILL!`), which Excel
* itself keeps as text when typed.
* 8. Anything else that `Number()` accepts is a number.
*
* Nothing is stored to make the automatic rules work, so a sheet saved before they existed shows
* its zeros again the moment it is opened.
Expand All @@ -42,6 +48,8 @@ export function literalValue(raw: string, numberFormat?: NumberFormat): FormulaV
if (raw === "") return null;
if (raw.startsWith("'")) return raw.slice(1);
if (numberFormat === "text") return raw;
const error = errorLiteral(raw);
if (error) return error;
if (LEADING_ZERO.test(raw) || LONG_DIGITS.test(raw)) return raw;
const date = dateLiteral(raw);
if (date) return date.serial;
Expand All @@ -53,6 +61,16 @@ export function literalValue(raw: string, numberFormat?: NumberFormat): FormulaV

const LEADING_ZERO = /^0\d+$/;

/** The error values a cell can hold as a constant, the seven Excel writes into a file as `t="e"`. */
export const ERROR_CODES: readonly string[] = ["#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"];

/** The error a cell's text stands for, spelled as Excel spells it, or null when the text is not one
* of `ERROR_CODES`. Case is ignored, as Excel ignores it when the code is typed. */
export function errorLiteral(raw: string): FormulaError | null {
const code = ERROR_CODES.find((c) => c === raw.toUpperCase());
return code ? new FormulaError(code) : null;
}

/**
* A number with thousands commas, a trailing percent or a leading currency sign: sign, then
* currency, then digits grouped in threes, then decimals, then `%`. The sign may also sit after the
Expand Down
3 changes: 2 additions & 1 deletion src/lib/dataSources/jsonToTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ export function parseCsv(text: string): string[][] {
/**
* A CSV field is text until it reads as a number — by the grid's own rule (#36), not `Number()`, so
* a branch code `007` or a phone number keeps its zeros instead of arriving as `7`. `literalValue`
* imports only types, so the server's copy of this file pulls in nothing from the browser side.
* pulls in nothing from the browser side, so neither does the server's copy of this file. An error
* code (#226) comes back as its text here; the cell it lands in reads it as the error.
*/
function coerce(s: string): CellValue {
const t = s.trim();
Expand Down
4 changes: 2 additions & 2 deletions src/lib/excelIO.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ describe("a constant error cell in an opened file (#227)", () => {
expect(ws.getCell("A2").value).toBe("#N/A");
// Typed as Excel spells it, it is the error Excel would make of it.
expect(ws.getCell("A3").value).toEqual({ error: "#REF!" });
// Only the exact spelling Excel writes in a file goes out as an error; any other spelling stays as typed.
expect(ws.getCell("A4").value).toBe("#n/a");
// Excel makes the error of a typed #n/a too, so it goes out as #N/A (checked in Excel, #226).
expect(ws.getCell("A4").value).toEqual({ error: "#N/A" });
});
});
15 changes: 5 additions & 10 deletions src/lib/excelIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,9 +700,6 @@ export async function importWorkbookFromFile(file: File): Promise<ImportedSheet[
});
}

/** The error values Excel writes into a file as `t="e"` cells. */
const EXCEL_ERROR_CODES = new Set(["#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"]);

/** Whether a cell's format sets anything, so an empty cell that only carries a fill still goes out. */
function hasAnyFormat(format: CellFormat | undefined): boolean {
return !!format && Object.values(format).some((v) => v !== undefined && v !== false);
Expand Down Expand Up @@ -730,15 +727,13 @@ async function writeSheetToWorksheet(worksheet: ExcelJS.Worksheet, sheet: SheetM
};
} else if (raw === "") {
// leave blank
} else if (EXCEL_ERROR_CODES.has(raw) && format?.numberFormat !== "text") {
// An error code as Excel spells it goes out as the error value, the way Excel itself stores
// `#N/A` typed into a cell. Written as text, a file that held `#DIV/0!` came back to Excel
// with SUM stepping over it (#227). An apostrophe or a Text format still keeps it text.
cell.value = { error: raw } as ExcelJS.CellErrorValue;
} else {
// The engine's own reading, not a second copy of it: a file that decided differently from
// the grid is how `0812345678` went out as the number 812345678 (#23).
cell.value = literalValue(raw, sheet.formats[r]?.[c]?.numberFormat) as string | number;
// the grid is how `0812345678` went out as the number 812345678 (#23). An error code goes
// out as the error value, as Excel stores `#N/A` typed into a cell (#227, #226); an
// apostrophe or a Text format still keeps it text.
const value = literalValue(raw, format?.numberFormat);
cell.value = isError(value) ? ({ error: value.code } as ExcelJS.CellErrorValue) : (value as string | number);
}

if (format?.bold || format?.color || format?.fontSize || format?.italic || format?.underline) {
Expand Down
14 changes: 4 additions & 10 deletions src/lib/fileLosses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe("range names a file holds that cannot come in (#60), in the same report
});
});

describe("constant error cells (#227), shown as text until #226", () => {
describe("constant error cells (#227, #226)", () => {
/** Two sheets: errors typed into the first, as Excel writes them, and a formula that errs in the second. */
async function errorsFile(): Promise<Uint8Array> {
const wb = new ExcelJS.Workbook();
Expand All @@ -172,15 +172,9 @@ describe("constant error cells (#227), shown as text until #226", () => {
return new Uint8Array(await wb.xlsx.writeBuffer());
}

it("are counted and placed; a formula whose result is an error is not, since it calculates again", async () => {
it("are not reported: the app now reads them as the errors they are, and export keeps them", async () => {
// #227 listed them because the app held them as text; since #226 nothing about them is lost.
const { losses } = await openFixture(await errorsFile());
expect(losses).toEqual([{ kind: "errorValues", count: 2, sheets: ["ยอดขาย"] }]);
});

it("are still there, and still said, after the file goes out of the app and back in", async () => {
const { sheets } = await openFixture(await errorsFile());
const blob = await exportWorkbookToXlsxBlob(sheets.map(({ name, sheet }) => ({ name, sheet, computed: computeSheet(sheet) })));
const { losses } = await openFixture(new Uint8Array(await blob.arrayBuffer()));
expect(losses).toEqual([{ kind: "errorValues", count: 2, sheets: ["ยอดขาย"] }]);
expect(losses).toEqual([]);
});
});
35 changes: 3 additions & 32 deletions src/lib/fileLosses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,7 @@ import { tokenize } from "./formulaEngine/tokenizer";
import { FUNCTIONS } from "./formulaEngine/functions";
import type { SheetModel } from "./sheet";

export type LossKind =
| "pictures"
| "shapes"
| "charts"
| "pivots"
| "macros"
| "externalLinks"
| "unknownFunctions"
| "names"
| "errorValues";
export type LossKind = "pictures" | "shapes" | "charts" | "pivots" | "macros" | "externalLinks" | "unknownFunctions" | "names";

export interface FileLoss {
kind: LossKind;
Expand All @@ -45,10 +36,9 @@ export interface FileLoss {
/**
* The order the notice lists them in: what a person sees in the file first, then what runs. Range
* names sit next to unknown functions because both end in `#NAME?` on screen; a person who
* reads one line about `#NAME?` should find the other cause right under it. Error values follow,
* the last thing that changes what a formula reads.
* reads one line about `#NAME?` should find the other cause right under it.
*/
const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "errorValues", "externalLinks", "macros"];
const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "externalLinks", "macros"];

interface Rel {
type: string;
Expand Down Expand Up @@ -88,23 +78,6 @@ function resolve(dir: string, target: string): string {

const endsWith = (type: string, kind: string) => type.endsWith(`/${kind}`);

/**
* Cells holding a constant error, `<c t="e"><v>#DIV/0!</v></c>` (#227). The app keeps the code as
* text until #226, so a formula that reads one does not see an error; that is what the notice owes.
* A cell with a formula is not one of them: its error is a result, and the app calculates it again.
*/
export function countConstantErrors(sheetXml: string): number {
if (!sheetXml.includes('t="e"')) return 0;
let n = 0;
for (const m of sheetXml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?c>)/g)) {
if (!/\bt="e"/.test(m[1])) continue;
const inner = m[2] ?? "";
if (/<(?:[A-Za-z_][\w.-]*:)?f[\s>/]/.test(inner)) continue;
if (/<(?:[A-Za-z_][\w.-]*:)?v[\s>]/.test(inner)) n += 1;
}
return n;
}

/** Count of elements with this local name, whatever prefix the writer chose (`xdr:pic`, `pic`). */
function countElements(xml: string, local: string): number {
return (xml.match(new RegExp(`<(?:[A-Za-z_][\\w.-]*:)?${local}[\\s>/]`, "g")) ?? []).length;
Expand Down Expand Up @@ -189,8 +162,6 @@ export async function findFileLosses(
tally.add("charts", 1, part.name);
continue;
}
const sheetXml = (await zip.file(part.path)?.async("string")) ?? "";
tally.add("errorValues", countConstantErrors(sheetXml), part.name);
const rels = await relsOf(zip, part.path);
for (const rel of rels.values()) {
if (endsWith(rel.type, "pivotTable")) tally.add("pivots", 1, part.name);
Expand Down
Loading
Loading