@@ -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,9 @@ 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 not a real format, so it
130+ * keeps the text the file rendered for it too.
131+ *
109132 * A number with no format at all is treated as General too. Every other
110133 * number keeps the text the file rendered for it, so a LibreOffice workbook
111134 * indexes as LibreOffice showed it (`0,5` in a German locale).
@@ -133,7 +156,12 @@ export function normalizeSheetDisplayText(
133156
134157 if ( cell . t === 'd' && cell . v instanceof Date ) {
135158 const format = typeof cell . z === 'string' ? cell . z : undefined
136- if ( format !== undefined && ELAPSED_TOKEN . test ( format ) ) continue
159+ if (
160+ format !== undefined &&
161+ ( format . length > MAX_NUMBER_FORMAT_LENGTH || ELAPSED_TOKEN . test ( format ) )
162+ ) {
163+ continue
164+ }
137165 cell . w = isoDateText ( cell . v , format )
138166 } else if ( cell . t === 'n' && typeof cell . v === 'number' && isGeneralFormat ( cell . z ) ) {
139167 cell . w = generalNumberText ( cell . v )
0 commit comments