diff --git a/README.en.md b/README.en.md
index 26c8c44..2c5ebd5 100644
--- a/README.en.md
+++ b/README.en.md
@@ -44,7 +44,7 @@ right here, with the URL, the header and the data never passing through our serv
-
+
@@ -65,7 +65,7 @@ self-hosted, straight from PostgreSQL/MySQL — one saved read-only query, and n
and full-fidelity Excel/PDF export, where a chart exported to `.xlsx` is a real, editable chart
bound to its cells, because the OOXML chart parts are written by hand (ExcelJS writes none). Plus optional
bring-your-own-backend cloud save and live co-editing over it — presence, last-writer-wins with the loser told, and
-an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1974 automated tests.
+an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1979 automated tests.
---
@@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark
---
-### 🧪 What 1974 passing tests could not catch
+### 🧪 What 1979 passing tests could not catch
Every test of the assistant **mocks the model** — it returns what I imagined it would. Put a real
API key behind it, ask fourteen ordinary questions, and **six answers used functions this engine
@@ -120,7 +120,7 @@ Then **the first fix made it worse.** The rule started as "give the closest form
allows", so _"join all the names into one line"_ came back as `=SUM(A2:A20)` — `0` in the cell, no
error, nothing to notice. **A visible `#NAME?` traded for an invisible wrong number.**
-**And it happened again, in a different place.** With every gate green — 1974 tests, `axe` clean on
+**And it happened again, in a different place.** With every gate green — 1979 tests, `axe` clean on
both pages at two widths — an hour of clicking through the public build the way a first-time visitor
would found three things no gate can see:
@@ -395,7 +395,7 @@ Other available commands:
| `npm run build` | Build a production bundle |
| `npm run start` | Run the production build (run `npm run build` first) |
| `npm run lint` | Check code quality with ESLint |
-| `npm test` | Run the 1974-case Vitest suite |
+| `npm test` | Run the 1979-case Vitest suite |
| `npm run check:readme` | Check the READMEs still match the code (links/images/test count/new modules/both languages) |
| `npm run check:screens` | Figures printed on a screenshot still match the source |
| `npm run check:rls` | Two real accounts against your own Supabase: does the database refuse what the policies say it should (needs env) |
@@ -2512,12 +2512,16 @@ package again as it opens and **says so before anything is edited**: what, how m
| Excel PivotTables | values only | static numbers, not a PivotTable |
| Formulas calling a function the engine lacks | `#NAME?` | **the formula is kept**, and Excel calculates it |
| Range names that cannot come in (a formula, a whole column, several areas, or a sheet the file lacks) | formulas using them read `#NAME?` | the name is lost; formulas still carry it |
+| Cells holding a constant error value (`#DIV/0!`, `#N/A`) | shown as the text of the code; a formula that reads one does not see an error, so `SUM` steps over it (until [#226](https://github.com/SuruchBoss/ExcelToGo/issues/226)) | **written back as the error value** |
| External links to other files | values do not update | lost |
| Macros (VBA) | not run | lost |

- **A plain file sees nothing.** The report comes up only when something is actually lost.
+- **A constant error cell used to open blank, with nothing said** (#227), and a total over it read as a clean number. It now
+ keeps its code and goes back out as the error it was. An error code typed as Excel spells it (`#N/A`, `#DIV/0!`) also
+ goes out as the error, as Excel would store it; a leading apostrophe or the Text format keeps it text.
- **Got it** closes it. It opens again from the **menu** (or the amber button beside Export Excel from 1366px) for as long
as the file's sheets are open; undoing the open, or opening another file over it, takes the report away too.
- It reads the original package, not what the importer returned, because what the importer returned is exactly what
@@ -3010,7 +3014,7 @@ architecture behind it.
| `@anthropic-ai/sdk` | Connects to the Claude API for the AI assistant |
| `lucide-react` | UI icons |
| `clsx` | Conditional className composition |
-| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1974 cases) |
+| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1979 cases) |
> **Note:** No off-the-shelf formula library (e.g. HyperFormula) is used — the **formula engine is hand-written**
> (tokenizer, parser, evaluator, and functions) to keep full control over its behavior. See
@@ -3284,7 +3288,7 @@ src/
fillSeries.ts # What dragging the corner continues into — numbers, Thai days and months, quarters, formulas
sheetSearch.ts # Find and replace over the raw text rather than the displayed result
workbookRefs.ts # The formula rewrites that are not a fact about one sheet: cross-sheet shifts, renames
- fileLosses.ts # What an opened file holds that the app can't keep (pictures, charts, PivotTables, macros, links, missing functions), read from the package (#83)
+ fileLosses.ts # What an opened file holds that the app can't keep (pictures, charts, PivotTables, macros, links, missing functions, constant error values), read from the package (#83)
sheetNames.ts # Excel's sheet-name rules in one place: ≤31 chars, no \ / ? * [ ] :, unique ignoring case (#54)
byok.ts # The visitor's own API key: this tab only, masked when shown
sheet.ts # The core sheet data model, whole-sheet computation, applying a formula by scope,
@@ -4050,13 +4054,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down
## 🧪 Testing
```bash
-npm test # 1974 cases across 140 files, via Vitest
+npm test # 1979 cases across 140 files, via Vitest
```
Testing is focused on the **formula engine, sort logic, JSON-to-table conversion, pagination, rate-limit backoff, Excel templates and live-block placement** — pure functions with no React/DOM dependency, so
they run fast and give high confidence.
-**But not one of those 1974 cases opens the app**, and nearly every bug this project found by hand lived in
+**But not one of those 1979 cases opens the app**, and nearly every bug this project found by hand lived in
the wiring *between* pieces that all passed their tests — the toolbar's "+ row" called `addRow`, which
announced nothing, while `insertRowAtSelection` next to it announced correctly (both tested) · the AI
assistant sent a range including its text header, because the context builder read raw `sheet.cells`
@@ -4175,10 +4179,10 @@ once; disable `ArrowRight` in the grid and two assertions in the third fail. (Th
second one stayed green: the `case` I inserted landed *after* the existing `case "ArrowRight"` and was dead
code. Proving a gate means checking that the thing you meant to break actually broke.)
-> **1974 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
+> **1979 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
> the model, so it returns what the test author imagined. A test count says what you thought to ask,
> not whether you asked enough. Only a real API key found this: see
-> [What 1974 passing tests could not catch](#-what-1974-passing-tests-could-not-catch), repeatable
+> [What 1979 passing tests could not catch](#-what-1979-passing-tests-could-not-catch), repeatable
> with `npm run check:ai`.
| File | Cases | Tests |
@@ -4219,8 +4223,8 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `sheetNames.test.ts` | 7 | Excel's sheet-name rules (#54): each rule broken, case counted as one name, cleaning a name and finding a free one, + never reusing a name after a delete, an old save's duplicate keeping its formulas on the first tab, an invalid name renamed with its formulas |
| `sheetMerges.test.ts` | 35 | Which cells a merge swallows, shifting merges on row/column insert and delete, dropping one that collapses to a single cell |
| `sheetTemplate.test.ts` | 14 | Which cells are locked vs. fields, inline and range-backed dropdown options, column-width conversion |
-| `fileLosses.test.ts` | 10 | What an opened file can't keep (#83): a real file with a picture, a chart and a function the engine lacks, counted with its sheet; that formula exported as it was; a plain file reporting nothing; `_xlfn.` spelling of a function the engine has not counted; PivotTables, shapes, macros, links and a chart sheet as the spec lays them out; a non-zip not failing the open; range names that cannot come in (#60) as one line of the same report, after the missing functions, the whole report for a file with nothing else, and still said when the package cannot be read again |
-| `excelIO.test.ts` | 55 | Builds a real .xlsx and round-trips it: reading fields/dropdowns/widths, an unprotected file isn't a template, export→import comes back identical, and styling (fills/font sizes/borders/row heights/merges) round-trips, as do all five kinds of conditional formatting rule and cell notes (both the plain-string and Excel's rich-text form), cross-sheet dropdowns read from the sheet the rule names (quoted names, a hidden sheet, a missing one, and the PaynEat ERP draft-0 template), a column exactly width 9 still 9 after export (#136), a merge in the file holding its value in the top-left cell only with SUM/COUNTA matching Excel and the merge still exported (#55) |
+| `fileLosses.test.ts` | 12 | What an opened file can't keep (#83): a real file with a picture, a chart and a function the engine lacks, counted with its sheet; that formula exported as it was; a plain file reporting nothing; `_xlfn.` spelling of a function the engine has not counted; PivotTables, shapes, macros, links and a chart sheet as the spec lays them out; a non-zip not failing the open; range names that cannot come in (#60) as one line of the same report, after the missing functions, the whole report for a file with nothing else, and still said when the package cannot be read again; constant error cells (#227) counted with their sheet while a formula whose result is an error is not, and still counted after an export and a reopen |
+| `excelIO.test.ts` | 58 | Builds a real .xlsx and round-trips it: reading fields/dropdowns/widths, an unprotected file isn't a template, export→import comes back identical, and styling (fills/font sizes/borders/row heights/merges) round-trips, as do all five kinds of conditional formatting rule and cell notes (both the plain-string and Excel's rich-text form), cross-sheet dropdowns read from the sheet the rule names (quoted names, a hidden sheet, a missing one, and the PaynEat ERP draft-0 template), a column exactly width 9 still 9 after export (#136), a merge in the file holding its value in the top-left cell only with SUM/COUNTA matching Excel and the merge still exported (#55), a real `t="e"` cell opening as its error code rather than blank, going back out as `t="e"` and still there on a reopen, while `'#N/A` and a Text-formatted cell go out as text (#227) |
| `charts.test.ts` | 42 | Reading a range into series and labels (including a text label column), gaps for non-numbers, a zero-anchored axis, moving/resizing/clamping a chart's frame, what the legend names per kind, shifting on edits |
| `server/urlGuard.test.ts` | 31 | Addresses the server refuses to reach (loopback, private ranges, cloud metadata, IPv6 link-local), IPv4 embedded in IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), non-http schemes, and the allowlist |
| `server/sourcesAuth.test.ts` | 9 | No token means off, right/wrong/prefix tokens, and telling "switched off" apart from "wrong token" |
diff --git a/README.md b/README.md
index 7da7c4b..2791b9b 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
-
+
@@ -61,7 +61,7 @@ and saying so when data came back incomplete. A range can be given a name (in Th
what it means, and a cell can carry a rule about what may go in it, refused before it is written rather than
flagged afterwards. Conditional formatting re-colours cells from their current values — comparisons,
top/bottom ranks, colour scales and data bars — and round-trips through Excel's own rule format. Bilingual UI
-(Thai/English), 1974 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
+(Thai/English), 1979 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
---
@@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
---
-### 🧪 สิ่งที่เทสต์ 1974 เคสจับไม่ได้
+### 🧪 สิ่งที่เทสต์ 1979 เคสจับไม่ได้
เทสต์ของผู้ช่วย AI ใช้ **mock ทั้งหมด** — มันตอบตามที่ผมคิดว่ามันจะตอบ พอเอา API key จริงมายิงคำถามธรรมดา
14 ข้อ **6 คำตอบใช้ฟังก์ชันที่เอนจินนี้ไม่มี** (`TEXTJOIN` `FIND` `RANK.EQ` `SUMPRODUCT` `CEILING` `CHAR`)
@@ -116,7 +116,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
มันจึงตอบคำถาม *"ต่อชื่อทั้งหมดเป็นบรรทัดเดียว"* ด้วย `=SUM(A2:A20)` ซึ่งได้ `0` โดยไม่มีอะไรบอกว่าผิด
**เปลี่ยน error ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น**
-**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1974 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
+**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1979 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
ที่สองความกว้าง — ผมลองไล่คลิก build สาธารณะแบบคนที่เพิ่งเข้ามาครั้งแรกอยู่ชั่วโมงหนึ่ง แล้วเจอสามอย่าง
ที่ไม่มีด่านไหนมองเห็น:
@@ -381,7 +381,7 @@ npm run dev
| `npm run build` | build เป็นเวอร์ชัน production |
| `npm run start` | รันเวอร์ชันที่ build แล้ว (ต้อง `npm run build` ก่อน) |
| `npm run lint` | ตรวจสอบคุณภาพโค้ดด้วย ESLint |
-| `npm test` | รัน unit test 1974 เคสด้วย Vitest |
+| `npm test` | รัน unit test 1979 เคสด้วย Vitest |
| `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) |
| `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม |
| `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) |
@@ -2203,12 +2203,16 @@ merge เลื่อนตามการแทรก/ลบแถวและ
| Pivot Table ของ Excel | เห็นแค่ค่า | ค่าเป็นตัวเลขนิ่ง ไม่ใช่ Pivot Table |
| สูตรที่ใช้ฟังก์ชันที่เอนจินไม่มี | ขึ้น `#NAME?` | **สูตรเดิมยังอยู่** เปิดใน Excel แล้วคำนวณได้ |
| ชื่อช่วงที่นำเข้าไม่ได้ (ชื่อที่เป็นสูตร ทั้งคอลัมน์ หลายช่วง หรือชี้ชีตที่ไม่มี) | สูตรที่ใช้ชื่อขึ้น `#NAME?` | ชื่อหายไป สูตรยังถือชื่อนั้นไว้ |
+| เซลล์ที่เก็บค่า error คงที่ (`#DIV/0!`, `#N/A`) | แสดงเป็นข้อความของรหัส สูตรที่อ่านช่องนั้นไม่เห็นว่าเป็น error `SUM` จึงข้ามไป (จนกว่า [#226](https://github.com/SuruchBoss/ExcelToGo/issues/226) จะเข้า) | **เขียนกลับเป็นค่า error เหมือนเดิม** |
| ลิงก์ไปไฟล์อื่น (external links) | ค่าไม่อัปเดต | หายไป |
| มาโคร (VBA) | ไม่รัน | หายไป |

- **ไฟล์ธรรมดาไม่เห็นอะไรเลย** รายงานขึ้นเฉพาะเมื่อเจอของที่เก็บไม่ได้จริง
+- **เซลล์ที่เก็บค่า error คงที่เคยเปิดมาเป็นช่องว่างโดยไม่มีอะไรบอก** (#227) และยอดรวมที่อ่านช่องนั้นออกมาเป็นตัวเลขดูปกติ · ตอนนี้รหัสยังอยู่
+ และส่งออกกลับเป็นค่า error เหมือนเดิม · รหัส error ที่พิมพ์ตรงตามที่ Excel เขียน (`#N/A`, `#DIV/0!`) ก็ส่งออกเป็นค่า error แบบที่ Excel เก็บ
+ ถ้าขึ้นต้นด้วย `'` หรือจัดรูปแบบเป็นข้อความจะยังเป็นข้อความ
- กด **เข้าใจแล้ว** เพื่อปิด แล้วเปิดดูอีกครั้งได้จาก**เมนู** (หรือปุ่มสีเหลืองข้างปุ่มส่งออก Excel ตั้งแต่ 1366px) ตราบเท่าที่ชีตจากไฟล์นั้นยังเปิดอยู่
· undo การเปิดไฟล์ หรือเปิดไฟล์อื่นทับ รายงานก็หายไปด้วย
- อ่านจาก package ต้นฉบับ ไม่ใช่จากผลของตัวนำเข้า เพราะผลของตัวนำเข้าคือสิ่งที่ของพวกนี้หายไปแล้ว ·
@@ -2620,7 +2624,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans
| `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI |
| `lucide-react` | ไอคอน UI |
| `clsx` | รวม className แบบมีเงื่อนไข |
-| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1974 เคส) |
+| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1979 เคส) |
> **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง**
> ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ
@@ -2880,7 +2884,7 @@ src/
fillSeries.ts # ลากมุมแล้วได้อะไรต่อ — ชุดตัวเลข วันไทย เดือนไทย ไตรมาส และการขยับสูตร
sheetSearch.ts # ค้นหา/แทนที่จากข้อความดิบ ไม่ใช่ผลลัพธ์ที่แสดง
workbookRefs.ts # การแก้สูตรที่ไม่ใช่เรื่องของชีตเดียว — แทรกแถวข้ามชีต และเปลี่ยนชื่อชีต
- fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี) อ่านจาก package (#83)
+ fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี ค่า error คงที่) อ่านจาก package (#83)
sheetNames.ts # กติกาชื่อชีตของ Excel ที่เดียว — ≤31 ตัว, ไม่มี \ / ? * [ ] :, ไม่ซ้ำแม้ต่างตัวพิมพ์ (#54)
byok.ts # เก็บ API key ของผู้ใช้ไว้ในแท็บเดียว + ปิดบังตอนแสดงผล
sheet.ts # โมเดลข้อมูลตารางหลัก, คำนวณค่าทั้งชีต, ใส่สูตรตาม scope ต่างๆ, แทรก/ลบแถว-คอลัมน์
@@ -3517,13 +3521,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็
## 🧪 การทดสอบ
```bash
-npm test # 1974 เคส ใน 140 ไฟล์ ด้วย Vitest
+npm test # 1979 เคส ใน 140 ไฟล์ ด้วย Vitest
```
โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM
จึงเทสต์ได้เร็วและมั่นใจได้สูง
-**แต่ 1974 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
+**แต่ 1979 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection`
ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
@@ -3623,9 +3627,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง
`case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง)
-> **1974 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
+> **1979 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
> มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม
-> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1974 เคสจับไม่ได้](#-สิ่งที่เทสต์-1974-เคสจับไม่ได้)
+> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1979 เคสจับไม่ได้](#-สิ่งที่เทสต์-1979-เคสจับไม่ได้)
> และรันซ้ำเองได้ด้วย `npm run check:ai`
| ไฟล์ | เคส | ทดสอบอะไร |
@@ -3666,8 +3670,8 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `sheetNames.test.ts` | 7 | กติกาชื่อชีตของ Excel (#54): ทุกข้อที่ผิด, ตัวพิมพ์เล็กใหญ่ถือเป็นชื่อเดียว, แก้ชื่อให้ถูกและหาชื่อที่ว่าง, ปุ่ม + ไม่ได้ชื่อซ้ำหลังลบแท็บ, งานเก่าที่ชื่อซ้ำ: แท็บแรกได้ชื่อเดิมและสูตรไม่ย้าย, ชื่อผิดกติกาเปลี่ยนพร้อมสูตร |
| `sheetMerges.test.ts` | 35 | ช่องไหนถูก merge กลืน, การเลื่อน merge ตอนแทรก/ลบแถว-คอลัมน์, merge ที่เหลือช่องเดียวถูกทิ้ง |
| `sheetTemplate.test.ts` | 14 | เซลล์ไหนล็อก/กรอกได้, ตัวเลือก dropdown แบบ inline และแบบอ้างช่วง, แปลงความกว้างคอลัมน์ |
-| `fileLosses.test.ts` | 10 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้ |
-| `excelIO.test.ts` | 55 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55) |
+| `fileLosses.test.ts` | 12 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้, เซลล์ error คงที่ (#227) นับพร้อมชื่อชีตแต่สูตรที่ผลเป็น error ไม่นับ และยังนับได้หลังส่งออกแล้วเปิดใหม่ |
+| `excelIO.test.ts` | 58 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55), เซลล์ `t="e"` จริงเปิดมาได้รหัส error ไม่ใช่ช่องว่าง ส่งออกกลับเป็น `t="e"` และเปิดซ้ำยังอยู่ ส่วน `'#N/A` กับช่องรูปแบบข้อความยังออกเป็นข้อความ (#227) |
| `charts.test.ts` | 42 | อ่านช่วงเป็นชุดข้อมูล+ป้ายกำกับ (รวมกรณีคอลัมน์ซ้ายเป็นข้อความ), ช่องว่างเมื่อไม่ใช่ตัวเลข, แกนที่รวมศูนย์, การย้าย/ย่อขยาย/หนีบกรอบไว้ในชีต, คำอธิบายสีตามชนิดกราฟ, การเลื่อนตามแทรก/ลบ |
| `server/urlGuard.test.ts` | 31 | ที่อยู่ที่ห้ามยิงถึง (loopback, วงใน, metadata ของคลาวด์, IPv6 link-local), IPv4 ที่ซ่อนใน IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), scheme ที่ไม่ใช่ http, และ allowlist |
| `server/sourcesAuth.test.ts` | 9 | ไม่ตั้งโทเคน = ปิดทั้งหมด, โทเคนถูก/ผิด/เป็นแค่บางส่วน, แยก "ปิดอยู่" ออกจาก "โทเคนผิด" |
diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png
index e188902..71229d0 100644
Binary files a/public/screenshots/27-landing-stats.png and b/public/screenshots/27-landing-stats.png differ
diff --git a/public/screenshots/en/27-landing-stats.png b/public/screenshots/en/27-landing-stats.png
index eb41c8a..034f2cd 100644
Binary files a/public/screenshots/en/27-landing-stats.png and b/public/screenshots/en/27-landing-stats.png differ
diff --git a/public/screenshots/screenshots.json b/public/screenshots/screenshots.json
index 251e602..4519528 100644
--- a/public/screenshots/screenshots.json
+++ b/public/screenshots/screenshots.json
@@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 1974,
+ "tests": 1979,
"securityTests": 325
},
"38-landing-problems.png": {
@@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 1974,
+ "tests": 1979,
"securityTests": 325
},
"en/38-landing-problems.png": {
diff --git a/public/social-preview.png b/public/social-preview.png
index e7f6b44..a610bf7 100644
Binary files a/public/social-preview.png and b/public/social-preview.png differ
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
index 2d6c6bc..324440d 100644
--- a/src/app/opengraph-image.tsx
+++ b/src/app/opengraph-image.tsx
@@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
- ["1974", "automated tests"],
+ ["1979", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 00b060a..ce855be 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -430,7 +430,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
- { value: "1974", label: "automated tests" },
+ { value: "1979", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
@@ -816,12 +816,15 @@ 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",
},
diff --git a/src/i18n/th.ts b/src/i18n/th.ts
index 429cd70..2f8e24b 100644
--- a/src/i18n/th.ts
+++ b/src/i18n/th.ts
@@ -429,7 +429,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
- { value: "1974", label: "เทสต์อัตโนมัติ" },
+ { value: "1979", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
@@ -814,12 +814,14 @@ 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: "แอปไม่รันมาโคร และไฟล์ที่ส่งออกไม่มีมาโครติดไป",
},
diff --git a/src/lib/excelIO.test.ts b/src/lib/excelIO.test.ts
index 1f7a48a..f4a6f97 100644
--- a/src/lib/excelIO.test.ts
+++ b/src/lib/excelIO.test.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import ExcelJS from "exceljs";
+import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { exportWorkbookToXlsxBlob, importWorkbookFromFile } from "./excelIO";
import { computeSheet, createEmptySheet, createWorkbookResolver, setCellRaw, SheetModel } from "./sheet";
@@ -705,3 +706,63 @@ describe("a merged cell imports as Excel holds it", () => {
expect(ws.getCell("A1").value).toBe(10);
});
});
+
+/**
+ * A constant error cell, as Excel writes one: `#DIV/0!` with no formula (#227).
+ * The importer had no branch for it, so it opened blank and a total over it came out clean.
+ */
+async function errorCellsFile(): Promise {
+ const wb = new ExcelJS.Workbook();
+ const ws = wb.addWorksheet("ยอดขาย");
+ ws.getCell("A1").value = { error: "#DIV/0!" } as ExcelJS.CellErrorValue;
+ ws.getCell("A2").value = { error: "#N/A" } as ExcelJS.CellErrorValue;
+ ws.getCell("A3").value = 5;
+ ws.getCell("A4").value = { formula: "SUM(A1:A3)", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue;
+ return new File([await wb.xlsx.writeBuffer()], "errors.xlsx");
+}
+
+/** The `` element for one cell, straight from the package, so a test sees what Excel will read. */
+async function cellXml(data: Blob | ArrayBuffer, address: string): Promise {
+ const zip = await JSZip.loadAsync(data instanceof Blob ? await data.arrayBuffer() : data);
+ const xml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
+ return new RegExp(`]*?(?:/>|>[\\s\\S]*?)`).exec(xml)?.[0] ?? "";
+}
+
+describe("a constant error cell in an opened file (#227)", () => {
+ it("keeps its code instead of opening blank", async () => {
+ const file = await errorCellsFile();
+ // The file holds the cells the way Excel writes them: an error type and no formula.
+ expect(await cellXml(await file.arrayBuffer(), "A1")).toMatch(/t="e"[^>]*>\s*#DIV\/0!<\/v>/);
+ expect(await cellXml(await file.arrayBuffer(), "A1")).not.toContain("");
+
+ const [{ sheet }] = await importWorkbookFromFile(file);
+ expect(sheet.cells.slice(0, 4).map((row) => row[0])).toEqual(["#DIV/0!", "#N/A", "5", "=SUM(A1:A3)"]);
+ });
+
+ it("goes back out as an error value, so Excel's own SUM over it still says #DIV/0!", async () => {
+ const [{ sheet }] = await importWorkbookFromFile(await errorCellsFile());
+ const blob = await exportWorkbookToXlsxBlob(exportable(sheet, "ยอดขาย"));
+ expect(await cellXml(blob, "A1")).toMatch(/t="e"[^>]*>\s*#DIV\/0!<\/v>/);
+ expect(await cellXml(blob, "A2")).toMatch(/t="e"[^>]*>\s*#N\/A<\/v>/);
+
+ // And open → export → open keeps them.
+ const [{ sheet: again }] = await importWorkbookFromFile(new File([await blob.arrayBuffer()], "again.xlsx"));
+ expect(again.cells.slice(0, 4).map((row) => row[0])).toEqual(["#DIV/0!", "#N/A", "5", "=SUM(A1:A3)"]);
+ });
+
+ it("text that only looks like an error stays text: after an apostrophe, or in a cell formatted as text", async () => {
+ const sheet = createEmptySheet(5, 2);
+ sheet.cells[0][0] = "'#N/A";
+ sheet.cells[1][0] = "#N/A";
+ sheet.formats[1][0] = { numberFormat: "text" };
+ sheet.cells[2][0] = "#REF!";
+ sheet.cells[3][0] = "#n/a";
+ const ws = await readBack(await exportWorkbookToXlsxBlob(exportable(sheet, "Codes")));
+ expect(ws.getCell("A1").value).toBe("#N/A");
+ 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");
+ });
+});
diff --git a/src/lib/excelIO.ts b/src/lib/excelIO.ts
index c62d9fe..2159798 100644
--- a/src/lib/excelIO.ts
+++ b/src/lib/excelIO.ts
@@ -108,6 +108,13 @@ function cellValueToRaw(cell: ExcelJS.Cell): string {
if ("text" in v && typeof v.text === "string") {
return v.text;
}
+ // A constant error, `#DIV/0!`. Kept as its code rather than dropped (#227):
+ // it opened blank, and a total over it came out as a clean number. The grid holds it as text
+ // until error values can live in a cell (#226); the open report says so, and export writes it
+ // back as the error it was.
+ if ("error" in v && typeof v.error === "string") {
+ return v.error;
+ }
if ("result" in v) {
const result = v.result;
return result === undefined || result === null ? "" : String(result);
@@ -693,6 +700,9 @@ export async function importWorkbookFromFile(file: File): Promise v !== undefined && v !== false);
@@ -720,6 +730,11 @@ 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).
diff --git a/src/lib/fileLosses.test.ts b/src/lib/fileLosses.test.ts
index 95d4ad3..06de1bb 100644
--- a/src/lib/fileLosses.test.ts
+++ b/src/lib/fileLosses.test.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { readFileSync } from "node:fs";
+import ExcelJS from "exceljs";
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { findFileLosses, unknownFunctionsIn } from "./fileLosses";
@@ -156,3 +157,30 @@ describe("range names a file holds that cannot come in (#60), in the same report
expect(losses).toEqual([{ kind: "names", count: 1, sheets: [], names: ["Dynamic"] }]);
});
});
+
+describe("constant error cells (#227), shown as text until #226", () => {
+ /** Two sheets: errors typed into the first, as Excel writes them, and a formula that errs in the second. */
+ async function errorsFile(): Promise {
+ const wb = new ExcelJS.Workbook();
+ const sales = wb.addWorksheet("ยอดขาย");
+ sales.getCell("A1").value = { error: "#DIV/0!" } as ExcelJS.CellErrorValue;
+ sales.getCell("A2").value = { error: "#N/A" } as ExcelJS.CellErrorValue;
+ sales.getCell("A3").value = 5;
+ sales.getCell("A4").value = { formula: "SUM(A1:A3)", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue;
+ const other = wb.addWorksheet("อื่นๆ");
+ other.getCell("B2").value = { formula: "1/0", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue;
+ 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 () => {
+ 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: ["ยอดขาย"] }]);
+ });
+});
diff --git a/src/lib/fileLosses.ts b/src/lib/fileLosses.ts
index 9ccd81b..d408e2c 100644
--- a/src/lib/fileLosses.ts
+++ b/src/lib/fileLosses.ts
@@ -22,7 +22,16 @@ 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";
+export type LossKind =
+ | "pictures"
+ | "shapes"
+ | "charts"
+ | "pivots"
+ | "macros"
+ | "externalLinks"
+ | "unknownFunctions"
+ | "names"
+ | "errorValues";
export interface FileLoss {
kind: LossKind;
@@ -36,9 +45,10 @@ 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.
+ * 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.
*/
-const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "externalLinks", "macros"];
+const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "errorValues", "externalLinks", "macros"];
interface Rel {
type: string;
@@ -78,6 +88,23 @@ function resolve(dir: string, target: string): string {
const endsWith = (type: string, kind: string) => type.endsWith(`/${kind}`);
+/**
+ * Cells holding a constant error, `#DIV/0!` (#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;
@@ -162,6 +189,8 @@ 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);