@beyondwork/docx-react-component 1.0.49 → 1.0.51

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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Excel-style number-format engine for chart tick labels + data labels
3
+ * (Stage 3A, pure math).
4
+ *
5
+ * Supports the top ~20 real-world format codes:
6
+ * - Digit placeholders: `0` (required digit), `#` (optional digit).
7
+ * - Decimal point: `.`.
8
+ * - Thousands separator: `,` between digit placeholders.
9
+ * - Percent: `%` — scales value by 100 and appends `%`.
10
+ * - Currency: `$` and quoted literals like `"$"`.
11
+ * - Scientific: `0.00E+00` style.
12
+ * - Date tokens: `yyyy` `yy` `mm` `m` `mmm` `mmmm` `dd` `d`.
13
+ * - Time tokens: `hh` `h` `mm` (contextual) `ss`.
14
+ * - `General`: fall back to JavaScript's default `toString()`.
15
+ * - `@`: text placeholder — returns the value verbatim (stringified).
16
+ *
17
+ * Out of scope (deferred post-v1):
18
+ * - Conditional formats (`[>100]...;...`).
19
+ * - Locale-specific formats (`[$-409]`).
20
+ * - Color tokens (`[Red]`, `[Blue]`).
21
+ * - Fraction formats (`# ?/?`).
22
+ *
23
+ * Unknown / malformed codes fall through to `String(value)` so the
24
+ * renderer never produces `NaN` or an exception at render time.
25
+ */
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public entry point
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export function formatNumber(value: number, code: string | undefined): string {
32
+ if (code === undefined || code === "" || code.toLowerCase() === "general") {
33
+ return Number.isFinite(value) ? String(value) : "";
34
+ }
35
+ if (code === "@") return String(value);
36
+
37
+ if (!Number.isFinite(value)) return "";
38
+
39
+ // Date/time codes contain y/m/d/h/s letter tokens outside literals.
40
+ // Detect and route to the date formatter.
41
+ if (isDateFormatCode(code)) {
42
+ return formatDate(value, code);
43
+ }
44
+
45
+ // Scientific: "0.00E+00" / "0E+0"
46
+ if (/E[+-]?0/iu.test(code)) {
47
+ return formatScientific(value, code);
48
+ }
49
+
50
+ return formatDecimal(value, code);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Decimal formatter
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function formatDecimal(value: number, code: string): string {
58
+ // Extract percent, currency prefix, thousands separator, and decimal
59
+ // precision from the format code. We scan for digit placeholders
60
+ // (0, #) and the decimal point.
61
+ let working = value;
62
+ const tokens = tokenizeLiterals(code);
63
+
64
+ // Find a sample of the digit-placeholder substring to measure precision.
65
+ const digitRun = tokens
66
+ .filter((t) => t.kind === "digits")
67
+ .map((t) => t.value)
68
+ .join("");
69
+
70
+ // Percent is a separate token from the tokenizer; scale accordingly.
71
+ const hasPercent = tokens.some((t) => t.kind === "percent");
72
+ if (hasPercent) working *= 100;
73
+
74
+ const decimalIdx = digitRun.indexOf(".");
75
+ const fractionDigits = decimalIdx >= 0 ? digitRun.length - decimalIdx - 1 : 0;
76
+ const useThousands = /,(?=[0#])/.test(digitRun);
77
+ const absWorking = Math.abs(working);
78
+
79
+ const [intPart, fracPart] = absWorking
80
+ .toFixed(Math.max(0, fractionDigits))
81
+ .split(".");
82
+
83
+ const intGrouped = useThousands ? groupThousands(intPart!) : intPart!;
84
+ const formatted =
85
+ fracPart !== undefined && fracPart.length > 0
86
+ ? `${intGrouped}.${fracPart}`
87
+ : intGrouped;
88
+ const signed = working < 0 ? `-${formatted}` : formatted;
89
+
90
+ // Stitch back prefix/suffix literals around the numeric body. We
91
+ // replace the first run of digit placeholders with the formatted
92
+ // number; other literals (currency symbols, spaces, "%") stay.
93
+ let produced = false;
94
+ const parts: string[] = [];
95
+ for (const t of tokens) {
96
+ if (t.kind === "digits") {
97
+ if (!produced) {
98
+ parts.push(signed);
99
+ produced = true;
100
+ }
101
+ // drop subsequent digit runs — they were part of the numeric
102
+ // template we already substituted.
103
+ } else if (t.kind === "literal") {
104
+ parts.push(t.value);
105
+ } else if (t.kind === "percent") {
106
+ parts.push("%");
107
+ } else if (t.kind === "currency") {
108
+ parts.push(t.value);
109
+ }
110
+ }
111
+ if (!produced) parts.push(signed);
112
+ return parts.join("");
113
+ }
114
+
115
+ function groupThousands(intPart: string): string {
116
+ return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Scientific formatter
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function formatScientific(value: number, code: string): string {
124
+ const match = /(0+)(\.0+)?[eE]([+-]?)(0+)/u.exec(code);
125
+ if (!match) return value.toExponential();
126
+ const mantissaInt = match[1]!.length;
127
+ const mantissaFrac = match[2] ? match[2].length - 1 : 0;
128
+ const signToken = match[3] ?? "";
129
+ const fracDigits = mantissaInt + mantissaFrac - 1;
130
+ const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
131
+ const mantissa = value / Math.pow(10, exp);
132
+ const mantissaStr = mantissa.toFixed(Math.max(0, fracDigits));
133
+ const sign = signToken === "+" ? (exp >= 0 ? "+" : "-") : exp < 0 ? "-" : "";
134
+ const expStr = String(Math.abs(exp)).padStart(match[4]!.length, "0");
135
+ return `${mantissaStr}E${sign}${expStr}`;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Date formatter
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const DATE_CODE_PATTERN = /\b(yyyy|yy|mmmm|mmm|mm|m|dd|d|hh|h|ss)\b/iu;
143
+
144
+ function isDateFormatCode(code: string): boolean {
145
+ // Strip quoted literals to avoid false positives on quoted text.
146
+ const stripped = code.replace(/"[^"]*"/g, "");
147
+ return DATE_CODE_PATTERN.test(stripped);
148
+ }
149
+
150
+ function formatDate(serial: number, code: string): string {
151
+ // Excel serial → Date. Epoch 1899-12-30.
152
+ const ms = (serial - 25569) * 86_400_000;
153
+ const date = new Date(ms);
154
+ const y = date.getUTCFullYear();
155
+ const m = date.getUTCMonth() + 1;
156
+ const d = date.getUTCDate();
157
+ const hh = date.getUTCHours();
158
+ const mi = date.getUTCMinutes();
159
+ const ss = date.getUTCSeconds();
160
+
161
+ const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
162
+ const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
163
+
164
+ // Parse the format by splitting on literal-vs-token boundaries.
165
+ // Process longest tokens first (yyyy before yy, mmmm before mmm/mm, etc.).
166
+ let out = "";
167
+ let i = 0;
168
+ let sawTime = false; // `mm` after h/hh means minutes
169
+ const lowered = code.toLowerCase();
170
+ while (i < code.length) {
171
+ const ch = code[i]!;
172
+ if (ch === '"') {
173
+ const end = code.indexOf('"', i + 1);
174
+ if (end === -1) { out += code.slice(i + 1); break; }
175
+ out += code.slice(i + 1, end);
176
+ i = end + 1;
177
+ continue;
178
+ }
179
+ // Try 4/3/2/1-char tokens at this position.
180
+ const tok4 = lowered.slice(i, i + 4);
181
+ const tok3 = lowered.slice(i, i + 3);
182
+ const tok2 = lowered.slice(i, i + 2);
183
+ const tok1 = lowered.slice(i, i + 1);
184
+ if (tok4 === "yyyy") { out += String(y).padStart(4, "0"); i += 4; continue; }
185
+ if (tok4 === "mmmm") { out += MONTHS_LONG[m - 1]!; i += 4; continue; }
186
+ if (tok3 === "mmm") { out += MONTHS_SHORT[m - 1]!; i += 3; continue; }
187
+ if (tok2 === "yy") { out += String(y).slice(-2); i += 2; continue; }
188
+ if (tok2 === "hh") { out += pad2(hh); sawTime = true; i += 2; continue; }
189
+ if (tok2 === "mm") {
190
+ if (sawTime) {
191
+ out += pad2(mi);
192
+ sawTime = false;
193
+ } else {
194
+ out += pad2(m);
195
+ }
196
+ i += 2;
197
+ continue;
198
+ }
199
+ if (tok2 === "ss") { out += pad2(ss); i += 2; continue; }
200
+ if (tok2 === "dd") { out += pad2(d); i += 2; continue; }
201
+ if (tok1 === "h") { out += String(hh); sawTime = true; i += 1; continue; }
202
+ if (tok1 === "m") { out += sawTime ? String(mi) : String(m); if (sawTime) sawTime = false; i += 1; continue; }
203
+ if (tok1 === "d") { out += String(d); i += 1; continue; }
204
+ if (tok1 === "y") { out += String(y); i += 1; continue; }
205
+ out += ch;
206
+ i += 1;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ function pad2(n: number): string {
212
+ return n < 10 ? `0${n}` : String(n);
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Literal tokenization for decimal format codes
217
+ // ---------------------------------------------------------------------------
218
+
219
+ interface FormatToken {
220
+ kind: "digits" | "literal" | "percent" | "currency";
221
+ value: string;
222
+ }
223
+
224
+ /**
225
+ * Split a decimal format code into a stream of tokens so the formatter
226
+ * can re-stitch prefix/suffix literals around the formatted number.
227
+ *
228
+ * Examples:
229
+ * `"$#,##0.00"` → [{currency "$"}, {digits "#,##0.00"}]
230
+ * `"0.00%"` → [{digits "0.00"}, {percent}]
231
+ * `"\"Total: \"0"` → [{literal "Total: "}, {digits "0"}]
232
+ */
233
+ function tokenizeLiterals(code: string): FormatToken[] {
234
+ const out: FormatToken[] = [];
235
+ let i = 0;
236
+ let digitBuf = "";
237
+ const flushDigits = () => {
238
+ if (digitBuf) {
239
+ out.push({ kind: "digits", value: digitBuf });
240
+ digitBuf = "";
241
+ }
242
+ };
243
+ while (i < code.length) {
244
+ const ch = code[i]!;
245
+ if (ch === '"') {
246
+ flushDigits();
247
+ const end = code.indexOf('"', i + 1);
248
+ const lit = end === -1 ? code.slice(i + 1) : code.slice(i + 1, end);
249
+ out.push({ kind: "literal", value: lit });
250
+ i = end === -1 ? code.length : end + 1;
251
+ continue;
252
+ }
253
+ if (ch === "\\") {
254
+ flushDigits();
255
+ if (i + 1 < code.length) {
256
+ out.push({ kind: "literal", value: code[i + 1]! });
257
+ i += 2;
258
+ } else {
259
+ i += 1;
260
+ }
261
+ continue;
262
+ }
263
+ if (ch === "$") {
264
+ flushDigits();
265
+ out.push({ kind: "currency", value: "$" });
266
+ i += 1;
267
+ continue;
268
+ }
269
+ if (ch === "%") {
270
+ flushDigits();
271
+ out.push({ kind: "percent", value: "%" });
272
+ i += 1;
273
+ continue;
274
+ }
275
+ if (ch === "0" || ch === "#" || ch === "." || ch === "," || ch === " ") {
276
+ digitBuf += ch;
277
+ i += 1;
278
+ continue;
279
+ }
280
+ // Unknown char — treat as literal.
281
+ flushDigits();
282
+ out.push({ kind: "literal", value: ch });
283
+ i += 1;
284
+ }
285
+ flushDigits();
286
+ return out;
287
+ }
@@ -11,8 +11,29 @@ import {
11
11
  extractPlainTextSegments,
12
12
  type PastePlainSegment,
13
13
  } from "./paste-plain-text";
14
+ import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
14
15
  import type { PositionMap } from "./pm-position-map";
15
16
 
17
+ /**
18
+ * I2 Tier B Slice 2 — MIME types Word + the browser use for WordprocessingML
19
+ * clipboard payloads. The first one is the legacy MS-Office HTML-embedded
20
+ * format; the second is the native Word clipboard type. Browsers expose both
21
+ * under `ClipboardEvent.clipboardData.getData(mime)`.
22
+ */
23
+ const WORDML_MIMES = [
24
+ "application/x-docx-fragment",
25
+ "application/vnd.ms-word.wordprocessingml.paste",
26
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
27
+ ] as const;
28
+
29
+ function readWordMLPayload(clipboard: DataTransfer): string | null {
30
+ for (const mime of WORDML_MIMES) {
31
+ const value = clipboard.getData(mime);
32
+ if (value && value.trim().length > 0) return value;
33
+ }
34
+ return null;
35
+ }
36
+
16
37
  /**
17
38
  * Callback subset used by paste / drop dispatch. Exported so tests can
18
39
  * record dispatch order without constructing the full
@@ -93,6 +114,18 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
93
114
  charCount: number;
94
115
  source: "paste" | "drop";
95
116
  }) => void;
117
+ /**
118
+ * I2 Tier B Slice 2 — optional. Fires when the paste handler detects an
119
+ * Office-clipboard WordprocessingML payload and parses it successfully into
120
+ * a canonical fragment. The host is responsible for dispatching
121
+ * `runtime.insertFragment(fragment)`; the bridge does not reach into the
122
+ * runtime directly so this plumbing stays consistent with the Tier A
123
+ * plain-text callback pattern.
124
+ */
125
+ onPasteFragment?: (meta: {
126
+ fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
127
+ source: "wordml";
128
+ }) => void;
96
129
  /**
97
130
  * Optional. Fires on `compositionstart` (true) and `compositionend`
98
131
  * (false). The surface forwards this to the predicted lane's session
@@ -195,11 +228,17 @@ export function createCommandBridgePlugins(
195
228
  return true; // Block PM from processing
196
229
  },
197
230
 
198
- // Plain-text paste: extract text/plain from the clipboard and
199
- // dispatch through the runtime-owned callbacks that typing uses.
200
- // Rich paste (HTML, Office clipboard) stays blocked — hosts that
201
- // listen for onBlockedInput still get notified when a non-plain-
202
- // text payload arrives. See docs/plans/editor-paste-drop.md.
231
+ // I2 paste handler Tier B (WordML) preferred, Tier A (plain) fallback.
232
+ //
233
+ // Preference order per `docs/plans/lane-1-i2-tier-b-rich-paste.md`:
234
+ // 1. Office-clipboard WordprocessingML payload if the host wired
235
+ // `onPasteFragment` AND the clipboard carries the MIME. Parsed via
236
+ // `parseCanonicalFragmentFromWordML`.
237
+ // 2. Plain text via `extractPlainTextSegments` (Tier A).
238
+ // 3. `onBlockedInput` for HTML-only / empty payloads.
239
+ //
240
+ // Rich-paste fallback on parse failure or missing host callback: fall
241
+ // through to Tier A so the user isn't left with a silent no-op.
203
242
  handlePaste(_view, event) {
204
243
  if (isComposing) return true;
205
244
  const clipboard = event.clipboardData;
@@ -207,6 +246,22 @@ export function createCommandBridgePlugins(
207
246
  callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
208
247
  return true;
209
248
  }
249
+
250
+ // Tier B: WordprocessingML
251
+ if (callbacks.onPasteFragment) {
252
+ const wordml = readWordMLPayload(clipboard);
253
+ if (wordml) {
254
+ const parsed = parseCanonicalFragmentFromWordML(wordml);
255
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
256
+ callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
257
+ return true;
258
+ }
259
+ // Parse failed or empty — fall through to plain-text so the paste
260
+ // still does something (defensive against malformed clipboard payloads).
261
+ }
262
+ }
263
+
264
+ // Tier A: plain text
210
265
  const plain = clipboard.getData("text/plain");
211
266
  if (!plain) {
212
267
  callbacks.onBlockedInput?.(