@beyondwork/docx-react-component 1.0.52 → 1.0.54

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 (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Chart-side text measurement helper (Stage 3B).
3
+ *
4
+ * Exposes `measureText(text, txPr, theme): TextMetrics` with two backends:
5
+ * - **Canvas** (`CanvasRenderingContext2D.measureText`) when
6
+ * `globalThis.document` is defined (real browser / jsdom with canvas).
7
+ * - **Empirical** glyph-width lookup table when running in SSR or Node
8
+ * test environments without a real DOM.
9
+ *
10
+ * Results are LRU-cached by (text × fontFamily × sizeHalfPoints × bold ×
11
+ * italic) so repeated calls in a single render cycle are effectively free.
12
+ * The cache is intentionally module-level (shared across renders) because
13
+ * chart font choices are low-cardinality.
14
+ *
15
+ * The canvas backend is NOT created on module import — it's lazy-created on
16
+ * the first measurement call so the module can be imported in SSR without
17
+ * side-effects.
18
+ */
19
+
20
+ import type { TextProperties } from "../../../io/ooxml/chart/types.ts";
21
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Public API
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface ChartTextMetrics {
28
+ width: number;
29
+ /** Cap-height approximation — useful for vertical centering. */
30
+ ascent: number;
31
+ descent: number;
32
+ lineHeight: number;
33
+ }
34
+
35
+ /**
36
+ * Measure `text` rendered with `txPr` (chart text properties) in the
37
+ * given `theme`. Falls back to the empirical backend when a canvas context
38
+ * is not available (SSR, Node tests).
39
+ *
40
+ * `theme` is accepted for future font-fallback resolution; currently only
41
+ * `txPr.fontFamily` / `txPr.fontSizePt` / `txPr.bold` / `txPr.italic` are
42
+ * used (theme-linked font schemes deferred to Stage 6).
43
+ */
44
+ export function measureText(
45
+ text: string,
46
+ txPr: TextProperties | undefined,
47
+ _theme: ResolvedTheme | undefined,
48
+ ): ChartTextMetrics {
49
+ const family = txPr?.fontFamily ?? DEFAULT_FONT_FAMILY;
50
+ const sizePt = txPr?.fontSizePt ?? DEFAULT_FONT_SIZE_PT;
51
+ const bold = txPr?.bold ?? false;
52
+ const italic = txPr?.italic ?? false;
53
+
54
+ const key = cacheKey(text, family, sizePt, bold, italic);
55
+ const cached = lruGet(key);
56
+ if (cached) return cached;
57
+
58
+ const result = canvasContext
59
+ ? measureViaCanvas(text, family, sizePt, bold, italic)
60
+ : measureEmpirical(text, family, sizePt, bold, italic);
61
+
62
+ lruSet(key, result);
63
+ return result;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Defaults
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const DEFAULT_FONT_FAMILY = "Calibri, Carlito, 'Segoe UI', 'Liberation Sans', Arial, sans-serif";
71
+ const DEFAULT_FONT_SIZE_PT = 10;
72
+ /** pt → px at 96 DPI: 1pt = 96/72 px. */
73
+ const PT_TO_PX = 96 / 72;
74
+ const LINE_HEIGHT_RATIO = 1.2;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Canvas backend (lazy init)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ let canvasContext: CanvasRenderingContext2D | null = null;
81
+
82
+ function getCanvasContext(): CanvasRenderingContext2D | null {
83
+ if (canvasContext !== null) return canvasContext;
84
+ if (typeof globalThis.document === "undefined") return null;
85
+ try {
86
+ const canvas = globalThis.document.createElement("canvas");
87
+ canvasContext = canvas.getContext("2d");
88
+ } catch {
89
+ canvasContext = null;
90
+ }
91
+ return canvasContext;
92
+ }
93
+
94
+ // Initialise lazily on first call (not at import time).
95
+ function ensureCanvas(): void {
96
+ if (canvasContext === null) getCanvasContext();
97
+ }
98
+
99
+ function measureViaCanvas(
100
+ text: string,
101
+ family: string,
102
+ sizePt: number,
103
+ bold: boolean,
104
+ italic: boolean,
105
+ ): ChartTextMetrics {
106
+ const ctx = getCanvasContext();
107
+ if (!ctx) return measureEmpirical(text, family, sizePt, bold, italic);
108
+
109
+ const sizePx = sizePt * PT_TO_PX;
110
+ const weight = bold ? "bold" : "normal";
111
+ const style = italic ? "italic" : "normal";
112
+ ctx.font = `${style} ${weight} ${sizePx}px ${family}`;
113
+ const m = ctx.measureText(text);
114
+
115
+ const ascent = m.actualBoundingBoxAscent ?? sizePx * 0.75;
116
+ const descent = m.actualBoundingBoxDescent ?? sizePx * 0.2;
117
+ return {
118
+ width: m.width,
119
+ ascent,
120
+ descent,
121
+ lineHeight: sizePx * LINE_HEIGHT_RATIO,
122
+ };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Empirical backend — glyph-width tables
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Average character width in em units per font-family (normalised to 1 em).
131
+ * Values derived from Calibri / Arial measurements at regular weight.
132
+ * Bold adds ~12%; italic is nearly identical in advance width.
133
+ */
134
+ const GLYPH_AVG_EM: Record<string, number> = {
135
+ calibri: 0.44,
136
+ carlito: 0.44,
137
+ arial: 0.50,
138
+ helvetica: 0.50,
139
+ "segoe ui": 0.48,
140
+ "liberation sans": 0.50,
141
+ times: 0.46,
142
+ "times new roman": 0.46,
143
+ };
144
+
145
+ /** Per-glyph width table (em units, Calibri-like proportions). */
146
+ const GLYPH_EM: Readonly<Record<string, number>> = {
147
+ // Digits (monospace-like in Calibri)
148
+ "0": 0.50, "1": 0.44, "2": 0.50, "3": 0.50, "4": 0.50,
149
+ "5": 0.50, "6": 0.50, "7": 0.46, "8": 0.50, "9": 0.50,
150
+ // Common punctuation / symbols
151
+ " ": 0.26, ".": 0.22, ",": 0.22, "-": 0.30, "+": 0.52,
152
+ "%": 0.68, "$": 0.50, "€": 0.56, "£": 0.48, "/": 0.30,
153
+ ":": 0.22, ";": 0.22, "(": 0.28, ")": 0.28, "[": 0.26, "]": 0.26,
154
+ // Upper-case
155
+ A: 0.60, B: 0.58, C: 0.58, D: 0.64, E: 0.52, F: 0.50,
156
+ G: 0.64, H: 0.64, I: 0.22, J: 0.40, K: 0.58, L: 0.50,
157
+ M: 0.72, N: 0.64, O: 0.68, P: 0.56, Q: 0.68, R: 0.60,
158
+ S: 0.52, T: 0.54, U: 0.64, V: 0.60, W: 0.82, X: 0.56,
159
+ Y: 0.56, Z: 0.54,
160
+ // Lower-case
161
+ a: 0.46, b: 0.50, c: 0.42, d: 0.50, e: 0.46, f: 0.28,
162
+ g: 0.50, h: 0.50, i: 0.22, j: 0.22, k: 0.48, l: 0.22,
163
+ m: 0.74, n: 0.50, o: 0.48, p: 0.50, q: 0.50, r: 0.30,
164
+ s: 0.40, t: 0.34, u: 0.50, v: 0.46, w: 0.64, x: 0.46,
165
+ y: 0.46, z: 0.40,
166
+ };
167
+
168
+ function avgGlyphEm(family: string): number {
169
+ const key = family.toLowerCase().split(",")[0]!.trim().replace(/^['"]|['"]$/g, "");
170
+ return GLYPH_AVG_EM[key] ?? 0.50;
171
+ }
172
+
173
+ function measureEmpirical(
174
+ text: string,
175
+ family: string,
176
+ sizePt: number,
177
+ bold: boolean,
178
+ _italic: boolean,
179
+ ): ChartTextMetrics {
180
+ const sizePx = sizePt * PT_TO_PX;
181
+ const boldFactor = bold ? 1.12 : 1.0;
182
+ const fallbackEm = avgGlyphEm(family);
183
+
184
+ let widthEm = 0;
185
+ for (const ch of text) {
186
+ widthEm += GLYPH_EM[ch] ?? fallbackEm;
187
+ }
188
+
189
+ const width = widthEm * sizePx * boldFactor;
190
+ return {
191
+ width,
192
+ ascent: sizePx * 0.75,
193
+ descent: sizePx * 0.2,
194
+ lineHeight: sizePx * LINE_HEIGHT_RATIO,
195
+ };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // LRU cache (simple doubly-linked-list, capacity 256)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ const LRU_CAPACITY = 256;
203
+
204
+ interface LRUNode {
205
+ key: string;
206
+ value: ChartTextMetrics;
207
+ prev: LRUNode | null;
208
+ next: LRUNode | null;
209
+ }
210
+
211
+ const lruMap = new Map<string, LRUNode>();
212
+ let lruHead: LRUNode | null = null; // most-recently used
213
+ let lruTail: LRUNode | null = null; // least-recently used
214
+
215
+ function cacheKey(
216
+ text: string,
217
+ family: string,
218
+ sizePt: number,
219
+ bold: boolean,
220
+ italic: boolean,
221
+ ): string {
222
+ // Half-points to avoid float key drift (1.5pt → "3").
223
+ const halfPts = Math.round(sizePt * 2);
224
+ return `${text}\x00${family}\x00${halfPts}\x00${bold ? 1 : 0}\x00${italic ? 1 : 0}`;
225
+ }
226
+
227
+ function lruGet(key: string): ChartTextMetrics | undefined {
228
+ const node = lruMap.get(key);
229
+ if (!node) return undefined;
230
+ moveToHead(node);
231
+ return node.value;
232
+ }
233
+
234
+ function lruSet(key: string, value: ChartTextMetrics): void {
235
+ const existing = lruMap.get(key);
236
+ if (existing) {
237
+ existing.value = value;
238
+ moveToHead(existing);
239
+ return;
240
+ }
241
+ const node: LRUNode = { key, value, prev: null, next: null };
242
+ lruMap.set(key, node);
243
+ insertHead(node);
244
+ if (lruMap.size > LRU_CAPACITY) evictTail();
245
+ }
246
+
247
+ function insertHead(node: LRUNode): void {
248
+ node.next = lruHead;
249
+ node.prev = null;
250
+ if (lruHead) lruHead.prev = node;
251
+ lruHead = node;
252
+ if (!lruTail) lruTail = node;
253
+ }
254
+
255
+ function moveToHead(node: LRUNode): void {
256
+ if (node === lruHead) return;
257
+ if (node.prev) node.prev.next = node.next;
258
+ if (node.next) node.next.prev = node.prev;
259
+ if (node === lruTail) lruTail = node.prev;
260
+ node.prev = null;
261
+ node.next = lruHead;
262
+ if (lruHead) lruHead.prev = node;
263
+ lruHead = node;
264
+ }
265
+
266
+ function evictTail(): void {
267
+ if (!lruTail) return;
268
+ lruMap.delete(lruTail.key);
269
+ if (lruTail.prev) lruTail.prev.next = null;
270
+ lruTail = lruTail.prev;
271
+ if (!lruTail) lruHead = null;
272
+ }
273
+
274
+ /**
275
+ * Flush the module-level LRU cache. Called by tests to ensure isolation
276
+ * between test cases that change backend availability.
277
+ */
278
+ export function _flushMetricsCache(): void {
279
+ lruMap.clear();
280
+ lruHead = null;
281
+ lruTail = null;
282
+ }
283
+
284
+ /**
285
+ * Override the canvas context for testing. Pass `null` to force the
286
+ * empirical backend; pass a mock `CanvasRenderingContext2D` to exercise
287
+ * the canvas path without a real DOM.
288
+ */
289
+ export function _setCanvasContextForTesting(
290
+ ctx: CanvasRenderingContext2D | null,
291
+ ): void {
292
+ canvasContext = ctx;
293
+ }
294
+
295
+ // Eagerly try to acquire the canvas context if a document is already
296
+ // available (e.g. jsdom in test environments that mount a DOM). A failure
297
+ // here is fine — the empirical backend kicks in.
298
+ ensureCanvas();
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Gridline generator — major / minor lines for cartesian chart axes (Stage 3C).
3
+ *
4
+ * Gridlines visualize the tick lattice inside the plot area. Major lines
5
+ * sit at every major tick; minor lines sit at each intermediate minor
6
+ * tick. Word's default appearance:
7
+ *
8
+ * - Color: theme `tx1` with `lumMod 15000 + lumOff 85000` (≈ 15% grey on
9
+ * a white background). Renders as `#D9D9D9` on the default Office theme.
10
+ * - Stroke width: 0.75 pt.
11
+ * - No dash (solid).
12
+ *
13
+ * The generator emits pure geometric line descriptors (x1, y1, x2, y2)
14
+ * so renderers can compose them via `svgLine` from `svg-primitives.ts`.
15
+ * Gridline **rendering** — turning the descriptors into SVG elements —
16
+ * is the renderer's responsibility; this module is pure math.
17
+ *
18
+ * Axis orientation rules (for value axes):
19
+ * - Y-axis (left side of plot): horizontal gridlines at each y tick
20
+ * position, spanning the full plot width.
21
+ * - X-axis (bottom of plot): vertical gridlines at each x tick
22
+ * position, spanning the full plot height.
23
+ *
24
+ * `crossBetween` (B5 fix) applies to *category* axes on bar/column charts
25
+ * only. When `"between"` (bar default), gridlines fall between categories
26
+ * (at category edges), not at category centers. When `"midCat"` (line
27
+ * default), gridlines align with category centers. For value axes this
28
+ * setting has no effect.
29
+ */
30
+
31
+ import type { Rect } from "../layout/plot-area.ts";
32
+ import type { TickResult } from "../layout/axis-layout.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export type GridlineAxis = "x" | "y";
39
+
40
+ export interface GridlineSegment {
41
+ x1: number;
42
+ y1: number;
43
+ x2: number;
44
+ y2: number;
45
+ major: boolean;
46
+ }
47
+
48
+ export interface GridlineInput {
49
+ /** "x" = vertical lines at x-tick positions; "y" = horizontal at y-tick. */
50
+ axis: GridlineAxis;
51
+ plotRect: Rect;
52
+ /** Major + minor tick positions (from generateValueTicks / etc.). */
53
+ ticks: TickResult;
54
+ /** Data-range bounds that the plot mapping uses. */
55
+ min: number;
56
+ max: number;
57
+ /**
58
+ * Reverse flag from `ValueAxis.reverse` (`c:scaling/orientation=maxMin`).
59
+ * When true, the max end sits at the low-coordinate side and the
60
+ * mapping flips.
61
+ */
62
+ reverse?: boolean;
63
+ }
64
+
65
+ export interface CategoryGridlineInput {
66
+ plotRect: Rect;
67
+ /** Number of categories (bar/line/area x-axis slot count). */
68
+ categoryCount: number;
69
+ /**
70
+ * `c:crossBetween` from the value axis that crosses this category axis.
71
+ * `"between"` → lines at category edges (bar/column convention).
72
+ * `"midCat"` → lines at category centers (line/area convention).
73
+ * Undefined defaults to `"midCat"` per Word's observed behavior.
74
+ */
75
+ crossBetween?: "between" | "midCat";
76
+ /** Optional skip factor to match tick-mark-skip on dense axes. */
77
+ tickMarkSkip?: number;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Value-axis gridlines
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Generate major + minor gridline segments for a value or date axis.
86
+ * Output order: majors first (ascending tick position), then minors.
87
+ */
88
+ export function generateValueGridlines(input: GridlineInput): GridlineSegment[] {
89
+ const out: GridlineSegment[] = [];
90
+ const span = input.max - input.min;
91
+ if (span === 0 || !Number.isFinite(span)) return out;
92
+
93
+ const mapPosition = makeMapPosition(input);
94
+ for (const pos of input.ticks.major) {
95
+ out.push(buildSegment(mapPosition(pos), input.axis, input.plotRect, true));
96
+ }
97
+ for (const pos of input.ticks.minor) {
98
+ out.push(buildSegment(mapPosition(pos), input.axis, input.plotRect, false));
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Build a single gridline descriptor at the given coordinate.
105
+ * - x-axis: vertical line, spans full plot height at this x-coord.
106
+ * - y-axis: horizontal line, spans full plot width at this y-coord.
107
+ */
108
+ function buildSegment(
109
+ coord: number,
110
+ axis: GridlineAxis,
111
+ plotRect: Rect,
112
+ major: boolean,
113
+ ): GridlineSegment {
114
+ if (axis === "x") {
115
+ return {
116
+ x1: coord,
117
+ y1: plotRect.y,
118
+ x2: coord,
119
+ y2: plotRect.y + plotRect.h,
120
+ major,
121
+ };
122
+ }
123
+ return {
124
+ x1: plotRect.x,
125
+ y1: coord,
126
+ x2: plotRect.x + plotRect.w,
127
+ y2: coord,
128
+ major,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Build a value→plot-coordinate mapper for the given axis. `reverse`
134
+ * flips the mapping so `max` sits at the low-coordinate side.
135
+ *
136
+ * For y-axis (axis='y'), pixel Y grows downward but data typically
137
+ * grows upward, so the default mapping inverts: `min` at `y + h`,
138
+ * `max` at `y`. `reverse=true` flips that back.
139
+ */
140
+ function makeMapPosition(input: GridlineInput): (value: number) => number {
141
+ const { axis, plotRect, min, max, reverse } = input;
142
+ const span = max - min;
143
+
144
+ if (axis === "x") {
145
+ if (reverse) {
146
+ return (v) => plotRect.x + ((max - v) / span) * plotRect.w;
147
+ }
148
+ return (v) => plotRect.x + ((v - min) / span) * plotRect.w;
149
+ }
150
+ // y
151
+ if (reverse) {
152
+ return (v) => plotRect.y + ((v - min) / span) * plotRect.h;
153
+ }
154
+ return (v) => plotRect.y + plotRect.h - ((v - min) / span) * plotRect.h;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Category-axis gridlines (B5 — crossBetween honored)
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Generate gridlines for a category axis. Category axes live under
163
+ * bar/column/line/area charts; `crossBetween` decides whether lines
164
+ * fall between categories (at slot edges) or at slot centers.
165
+ *
166
+ * - `"between"` (bar default): N+1 edge lines at slot boundaries.
167
+ * - `"midCat"` (line default): N center lines at slot mid-points.
168
+ *
169
+ * `tickMarkSkip` (Word's `c:tickMarkSkip`) thins out dense axes by
170
+ * emitting every Nth line. Defaults to 1 (all lines).
171
+ */
172
+ export function generateCategoryGridlines(
173
+ input: CategoryGridlineInput,
174
+ ): GridlineSegment[] {
175
+ const { plotRect, categoryCount } = input;
176
+ if (categoryCount <= 0) return [];
177
+ const mode = input.crossBetween ?? "midCat";
178
+ const skip = Math.max(1, Math.floor(input.tickMarkSkip ?? 1));
179
+ const out: GridlineSegment[] = [];
180
+ const slotWidth = plotRect.w / categoryCount;
181
+
182
+ if (mode === "between") {
183
+ // Edges at 0, 1, 2, …, categoryCount → N+1 positions.
184
+ for (let i = 0; i <= categoryCount; i += skip) {
185
+ const x = plotRect.x + i * slotWidth;
186
+ out.push({
187
+ x1: x,
188
+ y1: plotRect.y,
189
+ x2: x,
190
+ y2: plotRect.y + plotRect.h,
191
+ major: true,
192
+ });
193
+ }
194
+ return out;
195
+ }
196
+ // midCat: centers at 0.5, 1.5, …
197
+ for (let i = 0; i < categoryCount; i += skip) {
198
+ const x = plotRect.x + (i + 0.5) * slotWidth;
199
+ out.push({
200
+ x1: x,
201
+ y1: plotRect.y,
202
+ x2: x,
203
+ y2: plotRect.y + plotRect.h,
204
+ major: true,
205
+ });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Default appearance helpers
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Word-default gridline color (theme `tx1` with `lumMod 15000 +
216
+ * lumOff 85000` on a black `tx1`, which evaluates to `#D9D9D9`). Kept
217
+ * as a helper so renderers can ask for the default or substitute a
218
+ * theme-resolved alternative.
219
+ */
220
+ export const DEFAULT_MAJOR_GRIDLINE_COLOR = "#D9D9D9";
221
+
222
+ /**
223
+ * Minor gridline color is lighter than major. Word renders minor lines
224
+ * at `tx1 + lumMod 5000 + lumOff 95000` which resolves to `#F2F2F2`.
225
+ */
226
+ export const DEFAULT_MINOR_GRIDLINE_COLOR = "#F2F2F2";
227
+
228
+ export const DEFAULT_GRIDLINE_STROKE_WIDTH_PT = 0.75;