documonster 0.20.1 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/dist/esm/modules/excel/core/cell.js +5 -6
  4. package/dist/esm/modules/excel/core/pivot-table.js +17 -9
  5. package/dist/esm/modules/excel/stream/xlsb-worksheet-reader.js +4 -6
  6. package/dist/esm/modules/excel/utils/cell-format-parse.js +112 -174
  7. package/dist/esm/modules/excel/utils/cell-format.js +42 -946
  8. package/dist/esm/modules/formula/functions/text.js +23 -631
  9. package/dist/esm/modules/pdf/excel-bridge.js +5 -5
  10. package/dist/esm/utils/number-format-render.js +814 -0
  11. package/dist/esm/utils/number-format.js +477 -0
  12. package/dist/esm/utils/utils.base.js +3 -70
  13. package/dist/iife/documonster.archive.iife.min.js +1 -1
  14. package/dist/iife/documonster.csv.iife.min.js +1 -1
  15. package/dist/iife/documonster.draw.iife.min.js +1 -1
  16. package/dist/iife/documonster.excel.iife.min.js +50 -50
  17. package/dist/iife/documonster.formula.iife.min.js +1 -1
  18. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  19. package/dist/iife/documonster.mermaid.iife.min.js +1 -1
  20. package/dist/iife/documonster.pdf.iife.min.js +18 -18
  21. package/dist/iife/documonster.stream.iife.min.js +1 -1
  22. package/dist/iife/documonster.word.iife.min.js +60 -60
  23. package/dist/iife/documonster.xml.iife.min.js +1 -1
  24. package/dist/types/modules/excel/core/cell.d.ts +11 -0
  25. package/dist/types/modules/excel/core/pivot-table.d.ts +7 -0
  26. package/dist/types/modules/excel/utils/cell-format-parse.d.ts +13 -9
  27. package/dist/types/modules/excel/utils/cell-format.d.ts +21 -33
  28. package/dist/types/modules/excel/utils/text-metrics.d.ts +1 -0
  29. package/dist/types/utils/number-format-render.d.ts +47 -0
  30. package/dist/types/utils/number-format.d.ts +215 -0
  31. package/dist/types/utils/utils.base.d.ts +1 -9
  32. package/package.json +1 -1
package/README.md CHANGED
@@ -211,7 +211,7 @@ const buffer = await Workbook.toBuffer(wb);
211
211
 
212
212
  ```html
213
213
  <!-- Script tag (no bundler) — one IIFE per module, each under the shared `Documonster` global -->
214
- <script src="https://unpkg.com/documonster@0.20.1/dist/iife/documonster.excel.iife.min.js"></script>
214
+ <script src="https://unpkg.com/documonster@0.20.2/dist/iife/documonster.excel.iife.min.js"></script>
215
215
  <script>
216
216
  const { Workbook, Cell } = Documonster.Excel;
217
217
  const wb = Workbook.create();
package/README_zh.md CHANGED
@@ -198,7 +198,7 @@ const buffer = await Workbook.toBuffer(wb);
198
198
 
199
199
  ```html
200
200
  <!-- Script 标签(无需打包工具)— 每个模块一个 IIFE,共享同一个 `Documonster` 全局 -->
201
- <script src="https://unpkg.com/documonster@0.20.1/dist/iife/documonster.excel.iife.min.js"></script>
201
+ <script src="https://unpkg.com/documonster@0.20.2/dist/iife/documonster.excel.iife.min.js"></script>
202
202
  <script>
203
203
  const { Workbook, Cell } = Documonster.Excel;
204
204
  const wb = Workbook.create();
@@ -374,7 +374,7 @@ function toCivilDate(value, date1904) {
374
374
  * the only link a `CellData` has; a cell not yet attached to a workbook falls back to the 1900 system, which is
375
375
  * also what the writers default to.
376
376
  */
377
- function cellDate1904(cell) {
377
+ export function cellDate1904(cell) {
378
378
  const workbook = cell.row?.worksheet?._workbook;
379
379
  return workbook?.properties?.date1904 === true;
380
380
  }
@@ -659,11 +659,7 @@ export function cellText(c) {
659
659
  return c._value.toString();
660
660
  }
661
661
  export function cellDisplayText(c) {
662
- return getCellDisplayText({
663
- value: c._value.value,
664
- numFmt: c.style.numFmt,
665
- text: c._value.toString()
666
- });
662
+ return getCellDisplayText(cellView(c));
667
663
  }
668
664
  const HTML_ESCAPE_MAP = {
669
665
  '"': "&quot;",
@@ -686,6 +682,9 @@ export function cellView(c) {
686
682
  get text() {
687
683
  return c._value.toString();
688
684
  },
685
+ get date1904() {
686
+ return cellDate1904(c);
687
+ },
689
688
  get effectiveType() {
690
689
  return c._value.effectiveType;
691
690
  },
@@ -1,16 +1,22 @@
1
+ import { cellNumFmt } from "./cell.js";
1
2
  import { METRIC_DISPLAY_NAMES, pivotError, isPivotError, formatPivotError, VALID_SUBTOTALS } from "./pivot-table-types.js";
2
3
  import { rangeCreate, rangeExpand } from "./range.js";
3
4
  import { rowDimensions, rowValues } from "./row.js";
4
5
  import { tableModel } from "./table.js";
5
- import { columnValues, getColumn, getRow, getSheetName, getSheetValues } from "./worksheet-core.js";
6
+ import { columnValues, findCell, getColumn, getRow, getSheetName, getSheetValues } from "./worksheet-core.js";
6
7
  import { PivotTableError } from "../errors.js";
7
8
  import { colCache } from "../utils/col-cache.js";
8
- import { range } from "#platform/utils/utils";
9
+ import { isDateFmt, range } from "#platform/utils/utils";
9
10
  // Re-export the pure OOXML data types/constants that were relocated to
10
11
  // pivot-table-types.ts, preserving backward compatibility for existing
11
12
  // `import { X } from "./pivot-table.js"` call sites (worksheet.ts,
12
13
  // surface/pivot.ts, etc.).
13
14
  export { METRIC_DISPLAY_NAMES, pivotError, isPivotError, formatPivotError, VALID_SUBTOTALS };
15
+ /** A cell's number format as a code, whichever of its two shapes it is stored in. */
16
+ function formatCodeOf(cell) {
17
+ const numFmt = cell === undefined ? undefined : cellNumFmt(cell);
18
+ return typeof numFmt === "string" ? numFmt : numFmt?.formatCode;
19
+ }
14
20
  /** Extract the values array from a getRow result (RowData or `{ values }`). */
15
21
  function rowValuesOf(row) {
16
22
  return "cells" in row ? rowValues(row) : row.values;
@@ -91,6 +97,10 @@ function createTableSourceAdapter(table) {
91
97
  }
92
98
  return { values };
93
99
  },
100
+ getNumberFormat(rowNumber, columnNumber) {
101
+ // The table's rows are a copy of its values; the formats stay on the worksheet's cells.
102
+ return formatCodeOf(findCell(table.worksheet, startRow + rowNumber - 1, startCol + columnNumber - 1));
103
+ },
94
104
  getSheetValues() {
95
105
  // Return sparse array where index 1 is header row, and subsequent indices are data rows
96
106
  const result = [];
@@ -129,6 +139,7 @@ function resolveSource(model) {
129
139
  getRow: (rowNumber) => getRow(ws, rowNumber),
130
140
  getColumn: (columnNumber) => getColumn(ws, columnNumber),
131
141
  getSheetValues: () => getSheetValues(ws),
142
+ getNumberFormat: (rowNumber, columnNumber) => formatCodeOf(findCell(ws, rowNumber, columnNumber)),
132
143
  get dimensions() {
133
144
  return computeSourceDimensions(ws);
134
145
  }
@@ -492,8 +503,7 @@ function makeCacheFields(source, fieldNamesWithSharedItems, valueFieldNames) {
492
503
  const columnValues = columnValuesOf(source.getColumn(columnIndex));
493
504
  let sawDate = false;
494
505
  for (let i = DATA_START_INDEX; i < columnValues.length; i++) {
495
- const cell = columnValues[i];
496
- const value = unwrapCellValue(cell);
506
+ const value = unwrapCellValue(columnValues[i]);
497
507
  if (value === null || value === undefined) {
498
508
  continue;
499
509
  }
@@ -501,11 +511,9 @@ function makeCacheFields(source, fieldNamesWithSharedItems, valueFieldNames) {
501
511
  sawDate = true;
502
512
  continue;
503
513
  }
504
- const numFmt = cell
505
- ?.style?.numFmt;
506
- const code = typeof numFmt === "string" ? numFmt : numFmt?.formatCode;
507
- // A date format names a date or time component outside a literal.
508
- if (typeof code === "string" && /(?:^|[^\\])[dmyhs]/i.test(code)) {
514
+ // A date format, by the same rule the readers use to turn a serial into a `Date`. Column values are bare
515
+ // values, so the format has to come from the cell itself.
516
+ if (typeof value === "number" && isDateFmt(source.getNumberFormat?.(i, columnIndex))) {
509
517
  sawDate = true;
510
518
  continue;
511
519
  }
@@ -185,12 +185,10 @@ function readWideString(reader) {
185
185
  /**
186
186
  * A serial wearing a date format is a date — through `isDateFmt`, the same rule every other reader applies.
187
187
  *
188
- * This used to carry its own detector and its own epoch expression, and both differed from the buffered reader's.
189
- * The detector disagreed on four of forty-five real formats: it did not take only the first section, so `";;;dd"`
190
- * and `"@;;;yyyy"` were dates here and numbers there, and it stripped `\\`-escapes and lacked the `b`/`y` codes, so
191
- * `"\\d0"` and `"y"` were numbers here and dates there. The same workbook therefore read back differently
192
- * depending on which container it arrived in and whether the caller streamed it — the one thing two readers of the
193
- * same document must not do, as `readXlsbInto` says in as many words.
188
+ * This used to carry its own detector and its own epoch expression, and both differed from the buffered reader's,
189
+ * so the same workbook read back differently depending on which container it arrived in and whether the caller
190
+ * streamed it — the one thing two readers of the same document must not do, as `readXlsbInto` says in as many words.
191
+ * There is now one tokenizer for number formats (`@utils/number-format`), shared with display and `TEXT`.
194
192
  */
195
193
  function dated(value, numberFormat, context) {
196
194
  return isDateFmt(numberFormat) ? excelToDate(value, context.date1904) : value;
@@ -3,10 +3,10 @@
3
3
  * fraction-of-day number, driven entirely by the *target cell's own* `numFmt`
4
4
  * — not by guessing the shape of the input string.
5
5
  *
6
- * This is the inverse of the render-direction logic in `cell-format.ts`
7
- * (`formatDate`, `isDateDisplayFormat`, `isTimeOnlyFormat`): instead of turning
8
- * a value into display text per a format, it turns display text back into a
9
- * value per that same format.
6
+ * This is the inverse of `@utils/number-format-render`: instead of turning a
7
+ * value into display text per a format, it turns display text back into a
8
+ * value per that same format. Both read the format through the one tokenizer
9
+ * in `@utils/number-format`, so a literal on one side is a literal on the other.
10
10
  *
11
11
  * ## How it works (and why it is not a heuristic)
12
12
  *
@@ -25,24 +25,14 @@
25
25
  * is what makes this robust across arbitrary formats instead of accreting a
26
26
  * patch per newly-discovered format shape.
27
27
  */
28
- const MONTHS_LONG = [
29
- "january",
30
- "february",
31
- "march",
32
- "april",
33
- "may",
34
- "june",
35
- "july",
36
- "august",
37
- "september",
38
- "october",
39
- "november",
40
- "december"
41
- ];
28
+ import { DAY_NAMES, MONTH_NAMES, numberFormatFacets, splitFormatSections, tokenizeFormatSection } from "../../../utils/number-format.js";
29
+ const MONTHS_LONG = MONTH_NAMES.map(m => m.toLowerCase());
42
30
  const MONTHS_SHORT = MONTHS_LONG.map(m => m.slice(0, 3));
31
+ const WEEKDAYS = DAY_NAMES.map(d => d.toLowerCase());
32
+ const WEEKDAYS_SHORT = WEEKDAYS.map(d => d.slice(0, 3));
43
33
  /** A field consumes letters (names, AM/PM) rather than digits. */
44
34
  function isAlphaField(role) {
45
- return role === "month" || role === "ampm" || role === "ampmShort";
35
+ return role === "month" || role === "weekday" || role === "ampm" || role === "ampmShort";
46
36
  }
47
37
  /**
48
38
  * Time-tail roles that may be omitted from the *end* of the input. Excel treats
@@ -53,181 +43,114 @@ const OMITTABLE_TRAILING_ROLES = new Set([
53
43
  "hour",
54
44
  "minute",
55
45
  "second",
46
+ "secondFraction",
56
47
  "ampm",
57
48
  "ampmShort"
58
49
  ]);
59
50
  /**
60
- * Take the first (positive) of a format's up-to-four `;`-separated sections,
61
- * honouring quoted spans and backslash escapes so a `;` inside a literal does
62
- * not split the section.
63
- */
64
- function firstSection(fmt) {
65
- let quoted = false;
66
- for (let i = 0; i < fmt.length; i++) {
67
- const ch = fmt[i];
68
- if (ch === "\\") {
69
- i++;
70
- }
71
- else if (ch === '"') {
72
- quoted = !quoted;
73
- }
74
- else if (ch === ";" && !quoted) {
75
- return fmt.slice(0, i);
76
- }
77
- }
78
- return fmt;
79
- }
80
- // Sticky (`y`) so it only matches at the exact cursor, and case-insensitive
81
- // (`i`) because Excel format tokens are case-insensitive (`YYYY-MM-DD`,
82
- // `AM/PM`, `am/pm`, `A/P` are all valid and mean the same thing).
83
- const TOKEN_RE = /yyyy|yy|mmmmm|mmmm|mmm|AM\/PM|A\/P|\[h+\]|\[m+\]|\[s+\]|mm|m|dd|d|hh|h|ss|s/iy;
84
- /**
85
- * Compile a format section into ordered field/literal segments. Consecutive
86
- * literal characters coalesce into one literal segment; `mm`/`m` is resolved to
87
- * month vs. minute by adjacency to an hour (before) or second (after) field,
88
- * matching the render side's `resolveMonthOrMinute`.
51
+ * Compile a format's first section into ordered field/literal segments.
52
+ *
53
+ * The section is read by the shared number-format tokenizer, the same one that renders it and that
54
+ * decides on load whether a serial is a date — so a character is a field here exactly when it is a
55
+ * code there, and `m`/`mm` is a month or a minute by the same positional rule. Consecutive literal
56
+ * characters coalesce into one literal segment.
89
57
  */
90
58
  export function compileFormat(fmt) {
91
- const section = firstSection(fmt);
59
+ const tokens = tokenizeFormatSection(splitFormatSections(fmt)[0]);
92
60
  const segments = [];
93
- // Indices (into `segments`) of month/minute placeholders awaiting resolution.
94
- const ambiguous = [];
95
61
  let literal = "";
96
- let literalHasLetters = false;
97
62
  const flushLiteral = () => {
98
63
  if (literal.length > 0) {
99
- segments.push({ kind: "literal", hasLetters: literalHasLetters });
64
+ segments.push({ kind: "literal", hasLetters: /\p{L}/u.test(literal) });
100
65
  literal = "";
101
- literalHasLetters = false;
102
- }
103
- };
104
- const pushLiteralChar = (ch) => {
105
- literal += ch;
106
- if (/[A-Za-z]/.test(ch)) {
107
- literalHasLetters = true;
108
66
  }
109
67
  };
110
68
  const pushField = (role) => {
111
69
  flushLiteral();
112
70
  segments.push({ kind: "field", role });
113
71
  };
114
- for (let i = 0; i < section.length;) {
115
- const ch = section[i];
116
- // Quoted literal span.
117
- if (ch === '"') {
118
- const end = section.indexOf('"', i + 1);
119
- const inner = section.slice(i + 1, end === -1 ? section.length : end);
120
- for (const c of inner) {
121
- pushLiteralChar(c);
122
- }
123
- i = end === -1 ? section.length : end + 1;
124
- continue;
125
- }
126
- // Backslash escape — next char is a literal.
127
- if (ch === "\\" && i + 1 < section.length) {
128
- pushLiteralChar(section[i + 1]);
129
- i += 2;
130
- continue;
131
- }
132
- // Bracketed span: elapsed-time ([h]/[mm]/[s]) is a field; anything else
133
- // ([Red], [$-409], locale/condition) is display-only — skip entirely.
134
- if (ch === "[") {
135
- const end = section.indexOf("]", i + 1);
136
- const body = section.slice(i + 1, end === -1 ? section.length : end);
137
- const head = body[0]?.toLowerCase();
138
- if (/^h+$/i.test(body)) {
139
- pushField("elapsedHour");
140
- }
141
- else if (/^m+$/i.test(body)) {
142
- pushField("elapsedMinute");
143
- }
144
- else if (/^s+$/i.test(body)) {
145
- pushField("elapsedSecond");
146
- }
147
- void head;
148
- i = end === -1 ? section.length : end + 1;
149
- continue;
72
+ for (const token of tokens) {
73
+ switch (token.kind) {
74
+ case "date":
75
+ switch (token.part) {
76
+ case "year":
77
+ pushField(token.width <= 2 ? "year2" : "year4");
78
+ break;
79
+ case "month":
80
+ pushField("month");
81
+ break;
82
+ case "day":
83
+ pushField(token.width <= 2 ? "day" : "weekday");
84
+ break;
85
+ case "hour":
86
+ pushField("hour");
87
+ break;
88
+ case "minute":
89
+ pushField("minute");
90
+ break;
91
+ case "second":
92
+ pushField("second");
93
+ break;
94
+ }
95
+ break;
96
+ case "elapsed":
97
+ pushField(token.unit === "h"
98
+ ? "elapsedHour"
99
+ : token.unit === "m"
100
+ ? "elapsedMinute"
101
+ : "elapsedSecond");
102
+ break;
103
+ case "numeral":
104
+ literal += token.text;
105
+ break;
106
+ case "ampm":
107
+ pushField(token.short ? "ampmShort" : "ampm");
108
+ break;
109
+ case "literal":
110
+ literal += token.text;
111
+ break;
112
+ case "locale":
113
+ literal += token.symbol;
114
+ break;
115
+ case "pad":
116
+ literal += " ";
117
+ break;
118
+ case "digit":
119
+ literal += token.char;
120
+ break;
121
+ case "decimal":
122
+ literal += ".";
123
+ break;
124
+ case "thousands":
125
+ literal += ",";
126
+ break;
127
+ case "percent":
128
+ literal += "%";
129
+ break;
130
+ case "slash":
131
+ literal += "/";
132
+ break;
133
+ default:
134
+ // Fill, colour, condition and other bracket tags are display-only.
135
+ break;
150
136
  }
151
- // Date/time token?
152
- TOKEN_RE.lastIndex = i;
153
- const m = TOKEN_RE.exec(section);
154
- if (m && m.index === i) {
155
- const raw = m[0].toLowerCase();
156
- switch (raw) {
157
- case "yyyy":
158
- pushField("year4");
159
- break;
160
- case "yy":
161
- pushField("year2");
162
- break;
163
- case "mmmmm":
164
- case "mmmm":
165
- case "mmm":
166
- pushField("month");
167
- break;
168
- case "dd":
169
- case "d":
170
- pushField("day");
171
- break;
172
- case "hh":
173
- case "h":
174
- pushField("hour");
175
- break;
176
- case "ss":
177
- case "s":
178
- pushField("second");
179
- break;
180
- case "am/pm":
181
- pushField("ampm");
182
- break;
183
- case "a/p":
184
- pushField("ampmShort");
185
- break;
186
- case "mm":
187
- case "m":
188
- // Provisionally month; resolved after the whole scan.
189
- flushLiteral();
190
- ambiguous.push(segments.length);
191
- segments.push({ kind: "field", role: "month" });
192
- break;
193
- }
194
- i += m[0].length;
195
- continue;
137
+ // `ss.00` and `[ss].00`: the tokenizer has already attached the decimal places to the seconds code.
138
+ if ((token.kind === "date" || token.kind === "elapsed") && token.fraction > 0) {
139
+ pushField("secondFraction");
196
140
  }
197
- // Anything else is a literal separator character.
198
- pushLiteralChar(ch);
199
- i++;
200
141
  }
201
142
  flushLiteral();
202
- // Resolve each ambiguous m/mm: it is a minute if the nearest field before it
203
- // is an hour, or the nearest field after it is a second (mirroring the
204
- // render-side adjacency rule).
205
- const fieldRoleAt = (segIndex, dir) => {
206
- for (let k = segIndex + dir; k >= 0 && k < segments.length; k += dir) {
207
- const seg = segments[k];
208
- if (seg.kind === "field") {
209
- return seg.role;
210
- }
211
- }
212
- return undefined;
213
- };
214
- for (const idx of ambiguous) {
215
- const before = fieldRoleAt(idx, -1);
216
- const after = fieldRoleAt(idx, 1);
217
- if (before === "hour" || before === "elapsedHour" || after === "second") {
218
- segments[idx].role = "minute";
219
- }
220
- }
221
143
  return segments;
222
144
  }
223
- function monthIndexFromName(word) {
145
+ /** A full name or its three-letter abbreviation — not any word that happens to start like one. */
146
+ function nameIndex(word, full, short) {
224
147
  const lower = word.toLowerCase();
225
- const fullIdx = MONTHS_LONG.indexOf(lower);
226
- if (fullIdx !== -1) {
227
- return fullIdx;
228
- }
229
- const shortIdx = MONTHS_SHORT.indexOf(lower.slice(0, 3));
230
- return shortIdx !== -1 ? shortIdx : undefined;
148
+ const fullIdx = full.indexOf(lower);
149
+ return fullIdx !== -1 ? fullIdx : short.indexOf(lower);
150
+ }
151
+ function monthIndexFromName(word) {
152
+ const idx = nameIndex(word, MONTHS_LONG, MONTHS_SHORT);
153
+ return idx !== -1 ? idx : undefined;
231
154
  }
232
155
  /** True if the compiled format carries at least one date field. */
233
156
  function hasDateField(segments) {
@@ -240,6 +163,7 @@ function hasTimeField(segments) {
240
163
  (s.role === "hour" ||
241
164
  s.role === "minute" ||
242
165
  s.role === "second" ||
166
+ s.role === "secondFraction" ||
243
167
  s.role === "elapsedHour" ||
244
168
  s.role === "elapsedMinute" ||
245
169
  s.role === "elapsedSecond"));
@@ -300,7 +224,13 @@ function matchSegments(segments, input) {
300
224
  return remainingAreOptional(segments, s) ? finalize() : undefined;
301
225
  }
302
226
  const word = lm[0];
303
- if (seg.role === "month") {
227
+ if (seg.role === "weekday") {
228
+ // A weekday name is redundant with the date it sits beside; it must only be a real one.
229
+ if (nameIndex(word, WEEKDAYS, WEEKDAYS_SHORT) === -1) {
230
+ return undefined;
231
+ }
232
+ }
233
+ else if (seg.role === "month") {
304
234
  const idx = monthIndexFromName(word);
305
235
  if (idx === undefined) {
306
236
  return undefined;
@@ -329,6 +259,9 @@ function matchSegments(segments, input) {
329
259
  case "year4":
330
260
  result.year = num;
331
261
  break;
262
+ case "secondFraction":
263
+ result.secondFraction = digits;
264
+ break;
332
265
  case "year2":
333
266
  // The `yy` token only controls display width; a typed 4-digit year is
334
267
  // taken verbatim, a 1–2(–3) digit year uses the 1900/2000 pivot.
@@ -403,7 +336,8 @@ function matchSegments(segments, input) {
403
336
  * meaning or the input does not conform to it.
404
337
  */
405
338
  export function parseValueByFormat(fmt, input) {
406
- if (input.trim() === "") {
339
+ // A text format (`yyyy-mm-dd@`) keeps what is typed as text, as the readers keep its values numbers.
340
+ if (input.trim() === "" || numberFormatFacets(fmt).text) {
407
341
  return undefined;
408
342
  }
409
343
  const segments = compileFormat(fmt);
@@ -421,12 +355,16 @@ export function parseValueByFormat(fmt, input) {
421
355
  const h = parsed.hour ?? 0;
422
356
  const m = parsed.minute ?? 0;
423
357
  const s = parsed.second ?? 0;
424
- return ((parsed.elapsedSeconds ?? 0) + h * 3600 + m * 60 + s) / 86400;
358
+ const fraction = Number("0." + (parsed.secondFraction ?? "0"));
359
+ return ((parsed.elapsedSeconds ?? 0) + h * 3600 + m * 60 + s + fraction) / 86400;
425
360
  }
426
361
  if (parsed.year === undefined || parsed.month === undefined || parsed.day === undefined) {
427
362
  return undefined;
428
363
  }
429
- const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day, parsed.hour ?? 0, parsed.minute ?? 0, parsed.second ?? 0));
364
+ const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day, parsed.hour ?? 0, parsed.minute ?? 0, parsed.second ?? 0,
365
+ // A `Date` holds whole milliseconds: take the first three typed digits, so .9999… stays on its
366
+ // second however many nines follow — converting through a double first rounded it up to 1.
367
+ Number((parsed.secondFraction ?? "").slice(0, 3).padEnd(3, "0"))));
430
368
  // Reject overflowed components (e.g. day 31 in a 30-day month) rather than
431
369
  // silently rolling over into the next month.
432
370
  if (date.getUTCMonth() !== parsed.month - 1 || date.getUTCDate() !== parsed.day) {