Skip to content

Quote sheet names per the file format, not the formula bar - #35

Open
jafin wants to merge 1 commit into
ClosedXML:developfrom
XLibur:upstream-fix/sheet-name-quotation
Open

Quote sheet names per the file format, not the formula bar#35
jafin wants to merge 1 commit into
ClosedXML:developfrom
XLibur:upstream-fix/sheet-name-quotation

Conversation

@jafin

@jafin jafin commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #29, though not for the reason the issue gives.

The reported character is not the bug

The issue points at U+FF5E and suspects the QuoteFirst/QuoteNext bitmasks are missing bits. Checked against Excel (Office 16) — neither holds:

  • Excel accepts ABC~!A1 unquoted, in first and non-first position, and a workbook storing that form opens cleanly. Control: the same file with ASCII ~ (U+007E) unquoted is rejected, so the check discriminates.
  • The bitmasks match ident-sheet-first.txt / ident-sheet-next.txt bit-for-bit across all 65,536 codepoints.

The real bug is in how the data was collected

The tables were collected from Excel's formula bar. The file format is stricter. For a sheet named ABC~, Excel displays

=ABC~!A1

but stores

<f>'ABC~'!A1</f>

So a table built from the UI under-quotes, and for some characters the unquoted form makes Excel refuse to open the workbook — the reported symptom. Which position is fatal differs per codepoint:

Codepoints Fatal unquoted
U+2028, U+2029, U+202A–U+202E, U+303E as the first character
U+2065–U+2069, U+303D anywhere in the name

U+303D (〽 PART ALTERNATION MARK) is an ordinary visible Japanese character, and a likely candidate for what the reporter actually hit.

What this changes

Both tables re-collected from saved workbooks across the whole BMP, driving Excel over COM and reading the stored <f> back out of the XML. Excel's writer quotes a strict superset of the old tables: 41 divergences in first position, 37 in a later one, zero in the other direction.

Merged as a union, not a replacement. The Excel build used here accepts 107 codepoints bare that the build behind the original tables quotes — they were unassigned when it shipped. Taking the new measurement outright would produce files that break on older Excel. Quoting is accepted by every version, so the union is the only safe merge. This is also why the change is additive: every one of the 78 data edits is NOYES.

TRUE/FALSE sheet names are now quoted, in any casing. Every character is unremarkable alone, so only the whole name gives it away; unquoted, Excel reads TRUE!A1 as a logical literal and rejects the file. Reference-shaped names (A1, R1C1, XFD1048576) are deliberately left alone — Excel quotes them but reads them bare either way, and TryGetUnquotedSheet decides an identifier is an unquoted sheet name by calling ShouldQuote, so quoting them would cost reading.

NameUtils.EscapeName doubled apostrophes from a hardcoded index 1 instead of the builder's length. Every current caller passes an empty builder so no output was wrong, but the helper mangled the formula for any that did not:

EscapeName("SUM(", "it's")          => SUM(''it's'
EscapeName("SUM(Alpha!A1,", "it's") => SUM(Alpha!A1,'it's'

It now uses AppendEscapedSheetName, which already had this right.

Verification

End-to-end against Excel, with 1,095 sheet names per position rewritten using the library's output:

REJECTED old-next.xlsx      REJECTED old-first.xlsx      (old tables)
OK       library-next.xlsx  OK       library-first.xlsx  (new tables)

Reading is unaffected. All 78 changed names still parse from their old unquoted form and round-trip cleanly, so existing files stay readable — they are just re-emitted quoted.

The collector only ever measures the character in the last position for the non-first table, so a full mid-name pass over the BMP was run as a cross-check. It found zero codepoints needing quotes that the shipped table does not already cover, so both non-first positions agree.

746 tests pass (5 pre-existing skips).

Also included

  • tools/sheet-quotation/ — the collector and a README of the traps, so the data can be regenerated rather than trusted: U+0003 wedges Excel on a modal dialog DisplayAlerts = $false does not suppress; sheet names must not look like a cell reference or Excel quotes them for that reason instead of the character under test; SaveAs resolves relative paths against Excel's working directory rather than the caller's.
  • A test asserting the compiled bitmasks still match the data files, and that those files hold one row per codepoint a sheet name can contain. Nothing regenerates the masks automatically, so without this the two can drift apart and the only symptom is a workbook Excel will not open.

Happy to split the tooling out or drop it if you would rather not carry it.

The quotation tables were collected from Excel's formula bar, which is more
permissive than the file format. For a sheet named with a trailing U+FF5E the
formula bar shows ABC~!A1 while the saved workbook stores 'ABC~'!A1, so a
formula written from the displayed form can make Excel refuse to open the file.

Both tables are re-collected from saved workbooks across the whole BMP, by
driving Excel over COM and reading the stored formula back out of the XML. 41
codepoints in the first position and 37 in a later one were marked as needing
no quotes when Excel quotes them. For a subset Excel rejects the workbook
outright, and which position is fatal differs per codepoint: U+2028, U+2029,
U+202A-U+202E, U+303D and U+303E as the first character, U+2065-U+2069 and
U+303D anywhere in the name.

The measurement is merged with the existing data as a union rather than
replacing it. A newer Excel accepts 107 codepoints bare that the build behind
the original tables quotes, because they were unassigned when it shipped.
Quoting is accepted by every version, so the union keeps output loadable on
both.

Also quote a sheet named TRUE or FALSE in any casing. Every character is
unremarkable on its own, so only the whole name gives it away; unquoted, Excel
reads TRUE!A1 as a logical literal and rejects the file. Names shaped like a
reference (A1, R1C1) are left alone: Excel quotes them but reads them bare
either way, and the reader decides an identifier is an unquoted sheet name by
calling ShouldQuote.

Fix NameUtils.EscapeName, which doubled apostrophes from a hardcoded index 1
instead of the builder's length. Every caller happens to pass an empty builder,
so no output was wrong, but the helper mangled the formula for any that did not.

Add tools/sheet-quotation with the collector and the traps it has to work
around, and a test asserting the compiled bitmasks still match the data files
they are generated from.

Fixes ClosedXML#29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The sheet name is not properly escaped. char="~"

1 participant