@@ -23,6 +23,15 @@ const GENERAL_SIGNIFICANT_DIGITS = 15
2323
2424const ELAPSED_TOKEN = / \[ ( h + | m + | s + ) \] / i
2525
26+ /**
27+ * Excel rejects a custom number format longer than 255 characters, so a longer
28+ * `cell.z` is not a real format. It comes verbatim from the workbook's
29+ * `styles.xml`, and parsing it would let one cell cost unbounded CPU.
30+ */
31+ const MAX_NUMBER_FORMAT_LENGTH = 255
32+
33+ const TIME_SEPARATOR = / [: \s] /
34+
2635/**
2736 * Strips the parts of a number format that carry no date tokens: quoted
2837 * literals, backslash escapes, bracketed colour/condition/elapsed sections and
@@ -48,14 +57,25 @@ export function isTimeOnlyFormat(format: string): boolean {
4857 if ( / [ y d ] / . test ( tokens ) ) return false
4958 if ( ! / [ h m s ] / . test ( tokens ) ) return false
5059 for ( const match of tokens . matchAll ( / m + / g) ) {
51- const before = tokens . slice ( 0 , match . index ) . replace ( / [: \s] + $ / , '' )
52- const after = tokens . slice ( match . index + match [ 0 ] . length ) . replace ( / ^ [: \s] + / , '' )
53- const isMinutes = before . endsWith ( 'h' ) || after . startsWith ( 's' )
54- if ( ! isMinutes ) return false
60+ if ( ! isMinutesRun ( tokens , match . index , match . index + match [ 0 ] . length ) ) return false
5561 }
5662 return true
5763}
5864
65+ /**
66+ * Whether the `m` run at `[start, end)` sits next to an hour or a seconds
67+ * token, skipping `:` and whitespace. Each separator span is walked at most
68+ * once from each neighbouring run, so classifying a whole format is linear.
69+ */
70+ function isMinutesRun ( tokens : string , start : number , end : number ) : boolean {
71+ let before = start - 1
72+ while ( before >= 0 && TIME_SEPARATOR . test ( tokens [ before ] ) ) before --
73+ if ( tokens [ before ] === 'h' ) return true
74+ let after = end
75+ while ( after < tokens . length && TIME_SEPARATOR . test ( tokens [ after ] ) ) after ++
76+ return tokens [ after ] === 's'
77+ }
78+
5979const MS_PER_SECOND = 1000
6080
6181/**
@@ -106,6 +126,8 @@ function isGeneralFormat(format: unknown): boolean {
106126 * Elapsed-time formats (`[h]:mm`, `[mm]:ss`) are durations, not moments;
107127 * `cellDates` still parses them into a `Date`, so their `w` (`30:00`) is kept.
108128 *
129+ * A date whose format is longer than Excel allows is treated as unformatted.
130+ *
109131 * A number with no format at all is treated as General too. Every other
110132 * number keeps the text the file rendered for it, so a LibreOffice workbook
111133 * indexes as LibreOffice showed it (`0,5` in a German locale).
@@ -132,7 +154,10 @@ export function normalizeSheetDisplayText(
132154 if ( ! cell ) continue
133155
134156 if ( cell . t === 'd' && cell . v instanceof Date ) {
135- const format = typeof cell . z === 'string' ? cell . z : undefined
157+ const format =
158+ typeof cell . z === 'string' && cell . z . length <= MAX_NUMBER_FORMAT_LENGTH
159+ ? cell . z
160+ : undefined
136161 if ( format !== undefined && ELAPSED_TOKEN . test ( format ) ) continue
137162 cell . w = isoDateText ( cell . v , format )
138163 } else if ( cell . t === 'n' && typeof cell . v === 'number' && isGeneralFormat ( cell . z ) ) {
0 commit comments