From 61cd6f13b82b8e2547d2b5415509e0ec3170dd02 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Wed, 16 Sep 2026 15:22:32 +0000 Subject: [PATCH] Quote prefix with a character that Excel refuses unquoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix `needQuotes` allowing every code point from U+00AF up to be unquoted. E.g. `'My»Sheet'!RC[-2]` translated to A1 as `My»Sheet!A1`, and Excel refuses that in formula entry and triggers repair if it sees it in a file. Testing every code point in U+00AF-U+FFFF in Excel found 4,295 refused ones, interleaved with accepted ones in a way that doesn't seem to match any clear Unicode category or pattern. So they are added as a generated character class. Nothing assigned above U+FFFF is refused. --- lib/quoteRequiringCharacters.spec.ts | 51 ++++++++++++++++++++++++++++ lib/quoteRequiringCharacters.ts | 8 +++++ lib/stringifyA1Ref.spec.ts | 20 +++++++++++ lib/stringifyPrefix.ts | 3 +- lib/translateToA1.spec.ts | 7 ++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 lib/quoteRequiringCharacters.spec.ts create mode 100644 lib/quoteRequiringCharacters.ts diff --git a/lib/quoteRequiringCharacters.spec.ts b/lib/quoteRequiringCharacters.spec.ts new file mode 100644 index 0000000..59359e1 --- /dev/null +++ b/lib/quoteRequiringCharacters.spec.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from 'vitest'; +import { reRefusedAbove00AF } from './quoteRequiringCharacters.ts'; + +/** The class members as [first, last] code points, in written order. */ +function writtenRanges (source: string): [number, number][] { + const members: [number, number][] = []; + let rebuilt = '['; + for (const [ text, start, end ] of source.matchAll(/\\u([0-9a-f]{4})(?:-\\u([0-9a-f]{4}))?/g)) { + members.push([ parseInt(start, 16), parseInt(end ?? start, 16) ]); + rebuilt += text; + } + expect(rebuilt + ']').toBe(source); + return members; +} + +describe('reRefusedAbove00AF', () => { + const matchedRuns: [number, number][] = []; + let matchedCount = 0; + for (let codePoint = 0; codePoint <= 0x10ffff; codePoint++) { + if (!reRefusedAbove00AF.test(String.fromCodePoint(codePoint))) { + continue; + } + matchedCount++; + const lastRun = matchedRuns.at(-1); + if (lastRun && lastRun[1] === codePoint - 1) { + lastRun[1] = codePoint; + } + else { + matchedRuns.push([ codePoint, codePoint ]); + } + } + + test('matches the 4,295 code points that Excel refuses', () => { + expect(matchedCount).toBe(4295); + expect(matchedRuns[0]).toEqual([ 0xbb, 0xbb ]); + expect(matchedRuns.at(-1)).toEqual([ 0xffef, 0xfff8 ]); + }); + + test('matches no surrogate or noncharacter', () => { + const refuses = (from: number, to: number) => matchedRuns.some(([ start, end ]) => start <= to && end >= from); + expect(refuses(0xd800, 0xdfff), 'surrogates').toBe(false); + expect(refuses(0xfdd0, 0xfdef), 'noncharacters U+FDD0-U+FDEF').toBe(false); + expect(refuses(0xfffe, 0xffff), 'noncharacters U+FFFE and U+FFFF').toBe(false); + }); + + test('is written as 471 sorted, disjoint, non-adjacent ranges', () => { + const written = writtenRanges(reRefusedAbove00AF.source); + expect(written.length).toBe(471); + expect(written).toEqual(matchedRuns); + }); +}); diff --git a/lib/quoteRequiringCharacters.ts b/lib/quoteRequiringCharacters.ts new file mode 100644 index 0000000..45f1c62 --- /dev/null +++ b/lib/quoteRequiringCharacters.ts @@ -0,0 +1,8 @@ +/** + * Code points above U+00AF that Excel refuses in an unquoted sheet or workbook prefix, found by + * testing every code point in U+00AF-U+FFFF. Nothing assigned above U+FFFF is refused. + */ +// The members are escapes, so no combining mark can combine with its neighbour. +/* eslint-disable no-misleading-character-class */ +export const reRefusedAbove00AF = + /[\u00bb\u02ef-\u02ff\u034f-\u035f\u0363-\u036f\u0378-\u0379\u037e\u0380-\u0383\u0387\u038b\u038d\u03a2\u03f6\u0487\u0530\u0557-\u0558\u055a-\u055f\u0589-\u0590\u05a2\u05ba\u05be\u05c0\u05c3\u05c5-\u05cf\u05eb-\u05ee\u05f3-\u061e\u0656-\u065f\u066a-\u066d\u06d4\u0700-\u070e\u074b-\u074c\u07b2-\u07c9\u07eb-\u07f3\u07f6-\u07f9\u07fb-\u07ff\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u083f\u0859-\u085f\u086b-\u086f\u0888\u088f-\u089f\u08ca-\u0900\u093a-\u093b\u094e-\u094f\u0955-\u0957\u0964-\u0965\u0970\u0984\u098d-\u098e\u0991-\u0992\u09a9\u09b1\u09b3-\u09b5\u09ba-\u09bb\u09c5-\u09c6\u09c9-\u09ca\u09cf-\u09d6\u09d8-\u09db\u09de\u09e4-\u09e5\u09fb\u09fd-\u0a01\u0a03-\u0a04\u0a0b-\u0a0e\u0a11-\u0a12\u0a29\u0a31\u0a34\u0a37\u0a3a-\u0a3b\u0a3d\u0a43-\u0a46\u0a49-\u0a4a\u0a4e-\u0a58\u0a5d\u0a5f-\u0a65\u0a75-\u0a80\u0a84\u0a8e\u0a92\u0aa9\u0ab1\u0ab4\u0aba-\u0abb\u0ac6\u0aca\u0ace-\u0acf\u0ad1-\u0adf\u0ae2-\u0ae5\u0af0-\u0af8\u0afa-\u0b00\u0b04\u0b0d-\u0b0e\u0b11-\u0b12\u0b29\u0b31\u0b34\u0b3a-\u0b3b\u0b44-\u0b46\u0b49-\u0b4a\u0b4e-\u0b55\u0b58-\u0b5b\u0b5e\u0b62-\u0b65\u0b78-\u0b81\u0b84\u0b8b-\u0b8d\u0b91\u0b96-\u0b98\u0b9b\u0b9d\u0ba0-\u0ba2\u0ba5-\u0ba7\u0bab-\u0bad\u0bba-\u0bbd\u0bc3-\u0bc5\u0bc9\u0bce-\u0bcf\u0bd1-\u0bd6\u0bd8-\u0be5\u0bf3-\u0c00\u0c04\u0c0d\u0c11\u0c29\u0c3a-\u0c3c\u0c45\u0c49\u0c4e-\u0c54\u0c57\u0c5b-\u0c5c\u0c5e-\u0c5f\u0c62-\u0c65\u0c70-\u0c77\u0c7f\u0c81\u0c84\u0c8d\u0c91\u0ca9\u0cb4\u0cba-\u0cbc\u0cc5\u0cc9\u0cce-\u0cd4\u0cd7-\u0cdc\u0cdf\u0ce2-\u0ce5\u0cf0\u0cf3-\u0d01\u0d0d\u0d11\u0d3b-\u0d3c\u0d44-\u0d45\u0d49\u0d4f-\u0d53\u0d62-\u0d65\u0d79\u0d80-\u0d81\u0d84\u0d97-\u0d99\u0db2\u0dbc\u0dbe-\u0dbf\u0dc7-\u0dc9\u0dcb-\u0dce\u0dd5\u0dd7\u0de0-\u0df1\u0df4-\u0e00\u0e3b-\u0e3e\u0e4f\u0e5a-\u0e80\u0e83\u0e85\u0e8b\u0ea4\u0ea6\u0eba\u0ebe-\u0ebf\u0ec5\u0ec7\u0ece-\u0ecf\u0eda-\u0edb\u0ee0-\u0eff\u0f04-\u0f12\u0f3a-\u0f3d\u0f48\u0f6d-\u0f70\u0f85\u0f8d-\u0f8f\u0f98\u0fbd\u0fcd-\u0fce\u0fd0-\u0fff\u102b\u1033-\u1035\u103a-\u103e\u104a-\u104f\u105e-\u1060\u1062-\u1064\u1067-\u106d\u1071-\u1074\u1082-\u108d\u108f-\u109f\u10c6\u10c8-\u10cc\u10ce-\u10cf\u10fb\u1249\u124e-\u124f\u1257\u1259\u125e-\u125f\u1289\u128e-\u128f\u12b1\u12b6-\u12b7\u12bf\u12c1\u12c6-\u12c7\u12d7\u1311\u1316-\u1317\u135b-\u1368\u137d-\u137f\u1390-\u139f\u13f6-\u13f7\u13fe-\u1400\u166d-\u166e\u169b-\u169f\u16eb-\u16ed\u16f9-\u16ff\u1712-\u171e\u1732-\u173f\u1752-\u175f\u176d\u1771-\u177f\u17d4-\u17d6\u17d8-\u17da\u17dd-\u17df\u17ea-\u17ef\u17fa-\u180a\u180f\u181a-\u181f\u1879-\u187f\u18ab-\u18af\u18f6-\u18ff\u191f-\u194f\u196e-\u196f\u1975-\u197f\u19ac-\u19af\u19ca-\u19d9\u19db-\u19ff\u1a17-\u1a1f\u1a55-\u1aa6\u1aa8-\u1b04\u1b34-\u1b44\u1b4d-\u1b82\u1ba1-\u1bad\u1bb0-\u1bb9\u1be6-\u1bff\u1c24-\u1c4c\u1c50-\u1c59\u1c7e-\u1c7f\u1c8b-\u1c8f\u1cbb-\u1cbc\u1cc0-\u1ce8\u1ced\u1cf4\u1cf7-\u1cf9\u1cfb-\u1cff\u1dc0-\u1dff\u1f16-\u1f17\u1f1e-\u1f1f\u1f46-\u1f47\u1f4e-\u1f4f\u1f58\u1f5a\u1f5c\u1f5e\u1f7e-\u1f7f\u1fb5\u1fc5\u1fd4-\u1fd5\u1fdc\u1ff0-\u1ff1\u1ff5\u1fff\u2011-\u2012\u2017\u201a-\u201b\u201e-\u201f\u2022-\u2024\u2031\u2034\u2036-\u203a\u203c-\u2043\u2045-\u2069\u2072-\u2073\u207d-\u207e\u208d-\u208f\u209d-\u209f\u20b0-\u20cf\u20e4-\u20ff\u213b\u2140-\u2144\u214a-\u214d\u214f\u218a-\u218f\u21f4-\u21ff\u22f2-\u22ff\u2329-\u232a\u237c\u239b-\u23ff\u2427-\u243f\u244b-\u245f\u2596-\u259f\u25f8-\u25ff\u2614-\u2618\u2672-\u2700\u2705\u270a-\u270b\u2728\u274c\u274e\u2753-\u2755\u2757\u275f-\u2760\u2768-\u2775\u2795-\u2797\u27b0\u27bf-\u27ff\u2900-\u2bff\u2ce5-\u2cea\u2cef-\u2cf1\u2cf4-\u2cfc\u2cfe-\u2cff\u2d26\u2d28-\u2d2c\u2d2e-\u2d2f\u2d68-\u2d6e\u2d70-\u2d7f\u2d97-\u2d9f\u2da7\u2daf\u2db7\u2dbf\u2dc7\u2dcf\u2dd7\u2ddf-\u2e2e\u2e30-\u2e7f\u2e9a\u2ef4-\u2eff\u2fe0-\u2fef\u3018-\u301c\u3030\u303d\u3040\u3097-\u3098\u30a0\u3100-\u3104\u3130\u318f\u31c0-\u31ef\u321d-\u321f\u3244-\u3247\u3250\u327c-\u327e\u32cc-\u32cf\u32ff\u3377-\u337a\u33de-\u33df\u33ff\u4dc0-\u4dff\ua48d-\ua48f\ua4a2-\ua4a3\ua4b4\ua4c1\ua4c5\ua4c7-\ua4cf\ua4fe-\ua4ff\ua60d-\ua60f\ua620-\ua629\ua62c-\ua63f\ua66f-\ua67e\ua69e-\ua69f\ua6f0-\ua716\ua720-\ua721\ua789-\ua78a\ua7ce-\ua7cf\ua7d2\ua7d4\ua7dd-\ua7f1\ua802\ua806\ua80b\ua823-\ua82f\ua836-\ua83f\ua874-\ua881\ua8b4-\ua8f1\ua8f8-\ua8fa\ua8fc\ua8ff-\ua909\ua926-\ua92f\ua947-\ua95f\ua97d-\ua983\ua9b3-\ua9ce\ua9d0-\ua9df\ua9e5\ua9f0-\ua9f9\ua9ff\uaa29-\uaa3f\uaa43\uaa4c-\uaa5f\uaa77-\uaa79\uaa7b-\uaa7d\uaab0\uaab2-\uaab4\uaab7-\uaab8\uaabe-\uaabf\uaac1\uaac3-\uaada\uaade-\uaadf\uaaeb-\uaaf1\uaaf5-\uab00\uab07-\uab08\uab0f-\uab10\uab17-\uab1f\uab27\uab2f\uab5b\uab6a-\uab6f\uabe3-\uabff\ud7a4-\ud7af\ud7c7-\ud7ca\ud7fc-\ud7ff\ufa6e-\ufa6f\ufada-\ufaff\ufb07-\ufb12\ufb18-\ufb1c\ufb37\ufb3d\ufb3f\ufb42\ufb45\ufbb2-\ufbd2\ufd3e-\ufd4f\ufd90-\ufd91\ufdc8-\ufdcf\ufdfc-\ufe1f\ufe24-\ufe2f\ufe32\ufe45-\ufe48\ufe53\ufe58\ufe67\ufe6c-\ufe6f\ufe75\ufefd-\ufefe\uff00\uff5f-\uff60\uffbf-\uffc1\uffc8-\uffc9\uffd0-\uffd1\uffd8-\uffd9\uffdd-\uffdf\uffe7\uffef-\ufff8]/; diff --git a/lib/stringifyA1Ref.spec.ts b/lib/stringifyA1Ref.spec.ts index af07b48..fb68688 100644 --- a/lib/stringifyA1Ref.spec.ts +++ b/lib/stringifyA1Ref.spec.ts @@ -67,6 +67,18 @@ describe('stringifyA1Ref', () => { expect(stringifyA1Ref({ context: [ 'Truer' ], range: rangeA1 })).toBe('Truer!A1'); }); + test('quotes characters above U+00AF that Excel refuses unquoted', () => { + expect(stringifyA1Ref({ context: [ 'My\u00bbSheet' ], range: rangeA1 })).toBe("'My\u00bbSheet'!A1"); + expect(stringifyA1Ref({ context: [ 'My\u02efSheet' ], range: rangeA1 })).toBe("'My\u02efSheet'!A1"); + expect(stringifyA1Ref({ context: [ 'My\ufff8Sheet' ], range: rangeA1 })).toBe("'My\ufff8Sheet'!A1"); + expect(stringifyA1Ref({ context: [ 'Jan:My\u00bbSheet' ], range: rangeA1 })).toBe("'Jan:My\u00bbSheet'!A1"); + expect(stringifyA1Ref({ context: [ 'My\u00baSheet' ], range: rangeA1 })).toBe('My\u00baSheet!A1'); + expect(stringifyA1Ref({ context: [ 'My\u00bcSheet' ], range: rangeA1 })).toBe('My\u00bcSheet!A1'); + expect(stringifyA1Ref({ context: [ 'My\u02eeSheet' ], range: rangeA1 })).toBe('My\u02eeSheet!A1'); + expect(stringifyA1Ref({ context: [ 'My\ufff9Sheet' ], range: rangeA1 })).toBe('My\ufff9Sheet!A1'); + expect(stringifyA1Ref({ context: [ 'My\u{1f600}Sheet' ], range: rangeA1 })).toBe('My\u{1f600}Sheet!A1'); + }); + test('3D references are quoted per endpoint', () => { expect(stringifyA1Ref({ context: [ 'Jan:Dec' ], range: rangeA1 })).toBe('Jan:Dec!A1'); expect(stringifyA1Ref({ context: [ 'Sheet 1:Sheet 2' ], range: rangeA1 })).toBe("'Sheet 1:Sheet 2'!A1"); @@ -148,6 +160,14 @@ describe('stringifyA1Ref in XLSX mode', () => { expect(stringifyA1RefXlsx({ sheetName: '1040', name: 'foo' })).toBe("'1040'!foo"); }); + test('quotes characters above U+00AF that Excel refuses unquoted', () => { + expect(stringifyA1RefXlsx({ sheetName: 'My\u00bbSheet', range: rangeA1 })).toBe("'My\u00bbSheet'!A1"); + expect(stringifyA1RefXlsx({ sheetName: 'My\u00bcSheet', range: rangeA1 })).toBe('My\u00bcSheet!A1'); + expect(stringifyA1RefXlsx({ workbookName: 'My\u00bbBook.xlsx', sheetName: 'Sheet1', range: rangeA1 })).toBe("'[My\u00bbBook.xlsx]Sheet1'!A1"); + expect(stringifyA1RefXlsx({ workbookName: 'My\u00bcBook.xlsx', sheetName: 'Sheet1', range: rangeA1 })).toBe('[My\u00bcBook.xlsx]Sheet1!A1'); + expect(stringifyA1RefXlsx({ sheetName: 'My\u00bbSheet', name: 'foo' })).toBe("'My\u00bbSheet'!foo"); + }); + test('an external link index is not quoted', () => { expect(stringifyA1RefXlsx({ workbookName: '1', sheetName: 'Sheet1', range: rangeA1 })).toBe('[1]Sheet1!A1'); expect(stringifyA1RefXlsx({ workbookName: '12', sheetName: 'Sheet1', range: rangeA1 })).toBe('[12]Sheet1!A1'); diff --git a/lib/stringifyPrefix.ts b/lib/stringifyPrefix.ts index 0f16009..eb82115 100644 --- a/lib/stringifyPrefix.ts +++ b/lib/stringifyPrefix.ts @@ -8,6 +8,7 @@ import type { ReferenceName, ReferenceNameXlsx } from './types.ts'; +import { reRefusedAbove00AF } from './quoteRequiringCharacters.ts'; const CHAR_PERIOD = 46; const CHAR_0 = 48; @@ -34,7 +35,7 @@ export function needQuotes (scope: string, blockSheetRanges: boolean, bracketed: } return bits.some(bit => needQuotes(bit, blockSheetRanges, bracketed)) ? 1 : 0; } - if (reBannedChars.test(scope)) { + if (reBannedChars.test(scope) || reRefusedAbove00AF.test(scope)) { return 1; } if (reIsRangelike.test(scope)) { diff --git a/lib/translateToA1.spec.ts b/lib/translateToA1.spec.ts index e75b5bd..0ad8128 100644 --- a/lib/translateToA1.spec.ts +++ b/lib/translateToA1.spec.ts @@ -273,6 +273,13 @@ describe('translate 3D references', () => { isR2A('=A:C!R1C1', 'A1', "='A:C'!$A$1"); isR2A("=SUM('Sheet 1:Sheet 2'!R1C1:R2C2)", 'A1', "=SUM('Sheet 1:Sheet 2'!$A$1:$B$2)"); }); + + test('keeps the quotes on a name with a character that Excel refuses unquoted', () => { + isR2A("='My\u00bbSheet'!RC[-2]", 'C1', "='My\u00bbSheet'!A1"); + isR2A("='My\u00bcSheet'!RC[-2]", 'C1', '=My\u00bcSheet!A1'); + isR2A("=SUM('Alpha:My\u00bbSheet'!R1C1)", 'A1', "=SUM('Alpha:My\u00bbSheet'!$A$1)"); + isR2A("='[My\u00bbBook.xlsx]Sheet1'!R1C1", 'A1', "='[My\u00bbBook.xlsx]Sheet1'!$A$1"); + }); }); describe('translate r & c as LET parameters', () => {