@beyondwork/docx-react-component 1.0.36 → 1.0.38
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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +402 -1
- package/src/core/commands/index.ts +18 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +815 -55
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +328 -50
- package/src/io/export/serialize-numbering.ts +114 -24
- package/src/io/export/serialize-tables.ts +87 -11
- package/src/io/export/table-properties-xml.ts +174 -20
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +20 -0
- package/src/io/ooxml/parse-footnotes.ts +62 -1
- package/src/io/ooxml/parse-headers-footers.ts +62 -1
- package/src/io/ooxml/parse-main-document.ts +158 -1
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +45 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +2 -306
- package/src/runtime/document-runtime.ts +287 -11
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +233 -0
- package/src/runtime/layout/inert-layout-facet.ts +59 -0
- package/src/runtime/layout/layout-engine-instance.ts +628 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +452 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +921 -0
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +1398 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/layout/table-render-plan.ts +229 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +755 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +99 -15
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +501 -0
- package/src/ui/headless/scoped-chrome-policy.ts +183 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
- package/src/ui-tailwind/index.ts +33 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +505 -144
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized formatting resolution for the layout engine.
|
|
3
|
+
*
|
|
4
|
+
* Takes a surface block snapshot and resolves the effective formatting
|
|
5
|
+
* properties needed for measurement and page composition. This includes:
|
|
6
|
+
*
|
|
7
|
+
* - Paragraph spacing (before, after, line height, line rule)
|
|
8
|
+
* - Indentation (left, right, firstLine, hanging)
|
|
9
|
+
* - Tab stops with numbering geometry override
|
|
10
|
+
* - Font size for line height calculation
|
|
11
|
+
* - Pagination properties (keepNext, keepLines, widowControl, pageBreakBefore)
|
|
12
|
+
* - Contextual spacing suppression
|
|
13
|
+
*
|
|
14
|
+
* The resolver produces a flat, inspectable structure so that the measurement
|
|
15
|
+
* engine never needs to reach back into multiple sources.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
SurfaceBlockSnapshot,
|
|
20
|
+
} from "../../api/public-types";
|
|
21
|
+
import type {
|
|
22
|
+
ParagraphSpacing,
|
|
23
|
+
} from "../../model/canonical-document.ts";
|
|
24
|
+
|
|
25
|
+
/** Tab stop as it appears on the surface snapshot. */
|
|
26
|
+
interface SurfaceTabStop {
|
|
27
|
+
pos: number;
|
|
28
|
+
val?: string;
|
|
29
|
+
leader?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Normalized tab stop for layout computation. */
|
|
33
|
+
export interface LayoutTabStop {
|
|
34
|
+
position: number;
|
|
35
|
+
align: string;
|
|
36
|
+
leader?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Font metric tables — common fonts used in CCEP/legal contracts
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Average character width in twips per half-point of font size.
|
|
45
|
+
* These are empirical values from measuring common document fonts.
|
|
46
|
+
* Key insight: Word uses font metrics from the OS font table; we use
|
|
47
|
+
* bounded approximations that are close enough for page composition.
|
|
48
|
+
*/
|
|
49
|
+
const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
|
|
50
|
+
// Proportional serif
|
|
51
|
+
"times new roman": 9.2,
|
|
52
|
+
"georgia": 10.0,
|
|
53
|
+
"garamond": 8.8,
|
|
54
|
+
"book antiqua": 9.6,
|
|
55
|
+
"palatino linotype": 9.8,
|
|
56
|
+
"cambria": 9.6,
|
|
57
|
+
// Proportional sans-serif
|
|
58
|
+
"arial": 10.4,
|
|
59
|
+
"calibri": 9.0,
|
|
60
|
+
"helvetica": 10.4,
|
|
61
|
+
"verdana": 12.0,
|
|
62
|
+
"tahoma": 10.6,
|
|
63
|
+
"segoe ui": 9.8,
|
|
64
|
+
"trebuchet ms": 10.2,
|
|
65
|
+
// Monospace
|
|
66
|
+
"courier new": 12.0,
|
|
67
|
+
"consolas": 11.0,
|
|
68
|
+
"lucida console": 12.0,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const DEFAULT_FONT_AVG_CHAR_WIDTH = 10.0; // reasonable fallback
|
|
72
|
+
const DEFAULT_FONT_SIZE_HALF_POINTS = 24; // 12pt
|
|
73
|
+
const DEFAULT_LINE_HEIGHT_FACTOR = 1.15; // Word default for Calibri body
|
|
74
|
+
const TWIPS_PER_POINT = 20;
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Resolved types
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export interface ResolvedParagraphFormatting {
|
|
81
|
+
/** Effective spacing before in twips. */
|
|
82
|
+
spacingBefore: number;
|
|
83
|
+
/** Effective spacing after in twips. */
|
|
84
|
+
spacingAfter: number;
|
|
85
|
+
/** Effective line height in twips. */
|
|
86
|
+
lineHeight: number;
|
|
87
|
+
/** Line rule governing how lineHeight is interpreted. */
|
|
88
|
+
lineRule: "auto" | "exact" | "atLeast";
|
|
89
|
+
/** Left indentation in twips. */
|
|
90
|
+
indentLeft: number;
|
|
91
|
+
/** Right indentation in twips. */
|
|
92
|
+
indentRight: number;
|
|
93
|
+
/** First-line indent in twips (positive = indent, negative = outdent). */
|
|
94
|
+
firstLineIndent: number;
|
|
95
|
+
/** Hanging indent in twips (mutually exclusive with firstLineIndent). */
|
|
96
|
+
hangingIndent: number;
|
|
97
|
+
/** Resolved font size in half-points for the dominant run. */
|
|
98
|
+
fontSizeHalfPoints: number;
|
|
99
|
+
/** Average character width in twips for the dominant font. */
|
|
100
|
+
averageCharWidthTwips: number;
|
|
101
|
+
/** Effective tab stops sorted by position. */
|
|
102
|
+
tabStops: LayoutTabStop[];
|
|
103
|
+
/** Keep with next paragraph. */
|
|
104
|
+
keepNext: boolean;
|
|
105
|
+
/** Keep all lines of this paragraph on the same page. */
|
|
106
|
+
keepLines: boolean;
|
|
107
|
+
/** Force page break before this paragraph. */
|
|
108
|
+
pageBreakBefore: boolean;
|
|
109
|
+
/** Widow/orphan control (default true in Word). */
|
|
110
|
+
widowControl: boolean;
|
|
111
|
+
/** Contextual spacing — suppress before/after when adjacent styles match. */
|
|
112
|
+
contextualSpacing: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ResolvedTableRowFormatting {
|
|
116
|
+
/** Explicit row height in twips, 0 if auto. */
|
|
117
|
+
explicitHeight: number;
|
|
118
|
+
/** Height rule. */
|
|
119
|
+
heightRule: "auto" | "exact" | "atLeast";
|
|
120
|
+
/** Whether this row is a header row (repeats on page breaks). */
|
|
121
|
+
isHeader: boolean;
|
|
122
|
+
/** Whether this row can split across pages. */
|
|
123
|
+
cantSplit: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Paragraph formatting resolution
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function resolveBlockFormatting(
|
|
131
|
+
block: SurfaceBlockSnapshot,
|
|
132
|
+
): ResolvedParagraphFormatting | null {
|
|
133
|
+
if (block.kind !== "paragraph") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const spacing = resolveSpacing(block);
|
|
138
|
+
const indent = resolveIndentation(block);
|
|
139
|
+
const fontInfo = resolveDominantFont(block);
|
|
140
|
+
const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
spacingBefore: spacing.before ?? 0,
|
|
144
|
+
spacingAfter: spacing.after ?? 0,
|
|
145
|
+
lineHeight: lineHeight.height,
|
|
146
|
+
lineRule: lineHeight.rule,
|
|
147
|
+
indentLeft: indent.left,
|
|
148
|
+
indentRight: indent.right,
|
|
149
|
+
firstLineIndent: indent.firstLine,
|
|
150
|
+
hangingIndent: indent.hanging,
|
|
151
|
+
fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
|
|
152
|
+
averageCharWidthTwips: fontInfo.avgCharWidth,
|
|
153
|
+
tabStops: resolveTabStops(block),
|
|
154
|
+
keepNext: Boolean(block.keepNext),
|
|
155
|
+
keepLines: Boolean(block.keepLines),
|
|
156
|
+
pageBreakBefore: Boolean(block.pageBreakBefore),
|
|
157
|
+
widowControl: block.widowControl !== false, // default true in Word
|
|
158
|
+
contextualSpacing: Boolean(block.contextualSpacing),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Spacing resolution
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function resolveSpacing(
|
|
167
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
168
|
+
): ParagraphSpacing {
|
|
169
|
+
// Numbering geometry spacing overrides direct paragraph spacing
|
|
170
|
+
const numbering = block.resolvedNumbering?.geometry.spacing;
|
|
171
|
+
const direct = block.spacing;
|
|
172
|
+
|
|
173
|
+
const normalizeLineRule = (
|
|
174
|
+
rule: string | undefined,
|
|
175
|
+
): ParagraphSpacing["lineRule"] => {
|
|
176
|
+
if (rule === "auto" || rule === "exact" || rule === "atLeast") return rule;
|
|
177
|
+
return undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (numbering) {
|
|
181
|
+
return {
|
|
182
|
+
before: numbering.before ?? direct?.before,
|
|
183
|
+
after: numbering.after ?? direct?.after,
|
|
184
|
+
line: numbering.line ?? direct?.line,
|
|
185
|
+
lineRule: normalizeLineRule(numbering.lineRule) ?? normalizeLineRule(direct?.lineRule),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (direct) {
|
|
190
|
+
return {
|
|
191
|
+
before: direct.before,
|
|
192
|
+
after: direct.after,
|
|
193
|
+
line: direct.line,
|
|
194
|
+
lineRule: normalizeLineRule(direct.lineRule),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Indentation resolution
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function resolveIndentation(
|
|
206
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
207
|
+
): { left: number; right: number; firstLine: number; hanging: number } {
|
|
208
|
+
// Numbering geometry provides precise text column placement for legal numbering
|
|
209
|
+
const numGeometry = block.resolvedNumbering?.geometry;
|
|
210
|
+
|
|
211
|
+
if (numGeometry?.textColumn) {
|
|
212
|
+
const tc = numGeometry.textColumn;
|
|
213
|
+
return {
|
|
214
|
+
left: tc.start,
|
|
215
|
+
right: tc.right ?? 0,
|
|
216
|
+
firstLine: tc.firstLine ?? 0,
|
|
217
|
+
hanging: tc.hanging ?? 0,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (numGeometry?.indentation) {
|
|
222
|
+
const ni = numGeometry.indentation;
|
|
223
|
+
return {
|
|
224
|
+
left: ni.left ?? 0,
|
|
225
|
+
right: ni.right ?? 0,
|
|
226
|
+
firstLine: ni.firstLine ?? 0,
|
|
227
|
+
hanging: ni.hanging ?? 0,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ind = block.indentation;
|
|
232
|
+
if (!ind) {
|
|
233
|
+
return { left: 0, right: 0, firstLine: 0, hanging: 0 };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
left: ind.left ?? 0,
|
|
238
|
+
right: ind.right ?? 0,
|
|
239
|
+
firstLine: ind.firstLine ?? 0,
|
|
240
|
+
hanging: ind.hanging ?? (typeof ind.firstLine === "number" && ind.firstLine < 0 ? Math.abs(ind.firstLine) : 0),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Font / character width resolution
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function resolveDominantFont(
|
|
249
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
250
|
+
): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
|
|
251
|
+
// Find the dominant font from the segments (most common or first text run)
|
|
252
|
+
let fontFamily: string | undefined;
|
|
253
|
+
let fontSizeHalfPoints: number | undefined;
|
|
254
|
+
let maxTextLength = 0;
|
|
255
|
+
|
|
256
|
+
for (const segment of block.segments) {
|
|
257
|
+
if (segment.kind !== "text") continue;
|
|
258
|
+
const segFontFamily = segment.markAttrs?.fontFamily;
|
|
259
|
+
const segFontSize = segment.markAttrs?.fontSize;
|
|
260
|
+
const textLength = segment.text.length;
|
|
261
|
+
|
|
262
|
+
if (textLength > maxTextLength) {
|
|
263
|
+
maxTextLength = textLength;
|
|
264
|
+
if (typeof segFontFamily === "string") {
|
|
265
|
+
fontFamily = segFontFamily;
|
|
266
|
+
}
|
|
267
|
+
if (typeof segFontSize === "number") {
|
|
268
|
+
fontSizeHalfPoints = segFontSize;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const effectiveSize = fontSizeHalfPoints ?? DEFAULT_FONT_SIZE_HALF_POINTS;
|
|
274
|
+
const normalizedFamily = fontFamily?.toLowerCase();
|
|
275
|
+
const charWidthFactor = normalizedFamily !== undefined
|
|
276
|
+
? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
|
|
277
|
+
: DEFAULT_FONT_AVG_CHAR_WIDTH;
|
|
278
|
+
|
|
279
|
+
// Average char width in twips = factor * (fontSize in half-points)
|
|
280
|
+
const avgCharWidth = Math.max(96, Math.round(charWidthFactor * effectiveSize));
|
|
281
|
+
|
|
282
|
+
return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Line height resolution
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function resolveLineHeight(
|
|
290
|
+
spacing: ParagraphSpacing,
|
|
291
|
+
fontSizeHalfPoints: number,
|
|
292
|
+
): { height: number; rule: "auto" | "exact" | "atLeast" } {
|
|
293
|
+
const lineRule = spacing.lineRule ?? "auto";
|
|
294
|
+
|
|
295
|
+
if (lineRule === "exact" && typeof spacing.line === "number" && spacing.line > 0) {
|
|
296
|
+
return { height: spacing.line, rule: "exact" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (lineRule === "atLeast" && typeof spacing.line === "number" && spacing.line > 0) {
|
|
300
|
+
// atLeast: use the larger of the specified height or the auto-calculated height
|
|
301
|
+
const autoHeight = calculateAutoLineHeight(fontSizeHalfPoints);
|
|
302
|
+
return { height: Math.max(spacing.line, autoHeight), rule: "atLeast" };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Auto: line is in 240ths of a line (240 = single space, 480 = double)
|
|
306
|
+
if (typeof spacing.line === "number" && spacing.line > 0) {
|
|
307
|
+
// Convert from 240ths: line/240 * fontSize * 20 (twips per pt)
|
|
308
|
+
const fontSizePoints = fontSizeHalfPoints / 2;
|
|
309
|
+
const lineSpacingFactor = spacing.line / 240;
|
|
310
|
+
const height = Math.round(fontSizePoints * TWIPS_PER_POINT * lineSpacingFactor * DEFAULT_LINE_HEIGHT_FACTOR);
|
|
311
|
+
return { height: Math.max(height, 200), rule: "auto" };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Default: single space at current font size
|
|
315
|
+
return { height: calculateAutoLineHeight(fontSizeHalfPoints), rule: "auto" };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function calculateAutoLineHeight(fontSizeHalfPoints: number): number {
|
|
319
|
+
const fontSizePoints = fontSizeHalfPoints / 2;
|
|
320
|
+
return Math.max(200, Math.round(fontSizePoints * TWIPS_PER_POINT * DEFAULT_LINE_HEIGHT_FACTOR));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Tab stop resolution
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
function resolveTabStops(
|
|
328
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
329
|
+
): LayoutTabStop[] {
|
|
330
|
+
// Surface tab stops use { pos, val, leader } format
|
|
331
|
+
const surfaceTabs: SurfaceTabStop[] | undefined = block.tabStops;
|
|
332
|
+
// Numbering geometry tab stops are on the geometry, not the snapshot root
|
|
333
|
+
const numSurfaceTabs: SurfaceTabStop[] | undefined = block.resolvedNumbering?.geometry.tabStops;
|
|
334
|
+
|
|
335
|
+
const normalize = (tab: SurfaceTabStop): LayoutTabStop => ({
|
|
336
|
+
position: tab.pos,
|
|
337
|
+
align: tab.val ?? "left",
|
|
338
|
+
leader: tab.leader,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (numSurfaceTabs && numSurfaceTabs.length > 0 && surfaceTabs && surfaceTabs.length > 0) {
|
|
342
|
+
const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
|
|
343
|
+
const merged = [
|
|
344
|
+
...numSurfaceTabs.map(normalize),
|
|
345
|
+
...surfaceTabs.filter((t) => !numPositions.has(t.pos)).map(normalize),
|
|
346
|
+
];
|
|
347
|
+
return merged.sort((a, b) => a.position - b.position);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (numSurfaceTabs && numSurfaceTabs.length > 0) {
|
|
351
|
+
return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (surfaceTabs && surfaceTabs.length > 0) {
|
|
355
|
+
return surfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Precise measurement helpers
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Calculate the usable text width for a paragraph line, accounting for
|
|
367
|
+
* indentation and column width.
|
|
368
|
+
*/
|
|
369
|
+
export function resolveTextWidth(
|
|
370
|
+
formatting: ResolvedParagraphFormatting,
|
|
371
|
+
columnWidth: number,
|
|
372
|
+
isFirstLine: boolean,
|
|
373
|
+
): number {
|
|
374
|
+
const baseWidth = columnWidth - formatting.indentLeft - formatting.indentRight;
|
|
375
|
+
|
|
376
|
+
if (isFirstLine) {
|
|
377
|
+
if (formatting.hangingIndent > 0) {
|
|
378
|
+
// Hanging indent: first line starts further left (wider available)
|
|
379
|
+
return Math.max(1, baseWidth + formatting.hangingIndent);
|
|
380
|
+
}
|
|
381
|
+
if (formatting.firstLineIndent !== 0) {
|
|
382
|
+
return Math.max(1, baseWidth - formatting.firstLineIndent);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return Math.max(1, baseWidth);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Calculate characters per line for a given text width and font.
|
|
391
|
+
*/
|
|
392
|
+
export function resolveCharsPerLine(
|
|
393
|
+
textWidth: number,
|
|
394
|
+
avgCharWidth: number,
|
|
395
|
+
): number {
|
|
396
|
+
return Math.max(1, Math.floor(textWidth / avgCharWidth));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Calculate the total height of a paragraph from its resolved formatting.
|
|
401
|
+
*/
|
|
402
|
+
export function calculateParagraphHeight(
|
|
403
|
+
formatting: ResolvedParagraphFormatting,
|
|
404
|
+
lineCount: number,
|
|
405
|
+
): number {
|
|
406
|
+
const contentHeight = formatting.lineHeight * lineCount;
|
|
407
|
+
return Math.max(240, contentHeight + formatting.spacingBefore + formatting.spacingAfter);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Estimate the number of prefix characters added by numbering.
|
|
412
|
+
*/
|
|
413
|
+
export function resolveNumberingPrefixLength(
|
|
414
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
415
|
+
): number {
|
|
416
|
+
if (block.resolvedNumbering?.geometry.textColumn) {
|
|
417
|
+
// When text column is resolved, the numbering marker is placed in its
|
|
418
|
+
// own lane — it doesn't consume characters from the text line.
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const prefix = block.numberingPrefix ?? "";
|
|
423
|
+
const suffix =
|
|
424
|
+
block.numberingSuffix === "space"
|
|
425
|
+
? 1
|
|
426
|
+
: block.numberingSuffix === "tab"
|
|
427
|
+
? 4
|
|
428
|
+
: 0;
|
|
429
|
+
return prefix.length + suffix;
|
|
430
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table render plan (P3e).
|
|
3
|
+
*
|
|
4
|
+
* Per `docs/reference/docx/runtime-rendering-and-chrome-phase.md` §1, the
|
|
5
|
+
* render kernel consumes a `TableRenderPlan` per table per page so the
|
|
6
|
+
* chrome can:
|
|
7
|
+
* - render band-aware cell styling without pre-flattening into inline
|
|
8
|
+
* CSS strings,
|
|
9
|
+
* - position column-resize grips at logical column edges without
|
|
10
|
+
* re-measuring the DOM,
|
|
11
|
+
* - show repeated header rows on continuation pages (when row-level
|
|
12
|
+
* pagination lands; today the field is empty),
|
|
13
|
+
* - walk vertical-merge chains for merged-cell hit-testing.
|
|
14
|
+
*
|
|
15
|
+
* The plan is a pure function of the table block + resolved style
|
|
16
|
+
* resolution + gridColumns + the effective page column width. It does
|
|
17
|
+
* not read DOM or mutate state.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
SurfaceBlockSnapshot,
|
|
22
|
+
SurfaceTableCellSnapshot,
|
|
23
|
+
SurfaceTableRowSnapshot,
|
|
24
|
+
} from "../../api/public-types";
|
|
25
|
+
import type { TableStyleConditionalRegion } from "../../model/canonical-document.ts";
|
|
26
|
+
import type { ResolvedTableStyleResolution } from "../table-style-resolver.ts";
|
|
27
|
+
|
|
28
|
+
// ─── Public shapes ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export type TableBandRegion = TableStyleConditionalRegion;
|
|
31
|
+
|
|
32
|
+
export interface TableCellBandAssignment {
|
|
33
|
+
rowIndex: number;
|
|
34
|
+
columnIndex: number;
|
|
35
|
+
regions: readonly TableBandRegion[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TableRowBandAssignment {
|
|
39
|
+
rowIndex: number;
|
|
40
|
+
regions: readonly TableBandRegion[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TableBandClasses {
|
|
44
|
+
/** Active regions per row (firstRow, lastRow, band1Horz, band2Horz). */
|
|
45
|
+
rows: readonly TableRowBandAssignment[];
|
|
46
|
+
/** Active regions per cell (row regions ∪ column regions). */
|
|
47
|
+
cells: readonly TableCellBandAssignment[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VerticalMergeRef {
|
|
51
|
+
/** Logical column the chain lives in. */
|
|
52
|
+
columnIndex: number;
|
|
53
|
+
/** Row where the chain starts (verticalMerge: "restart"). */
|
|
54
|
+
startRowIndex: number;
|
|
55
|
+
/** Inclusive row where the chain ends. */
|
|
56
|
+
endRowIndex: number;
|
|
57
|
+
/** Horizontal span of the merged cell. */
|
|
58
|
+
columnSpan: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RepeatedHeaderRowRef {
|
|
62
|
+
/** Row index in the source table whose header is repeated. */
|
|
63
|
+
sourceRowIndex: number;
|
|
64
|
+
/** Virtual fragment id the render kernel uses to address this repeat. */
|
|
65
|
+
virtualFragmentId: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ColumnResizeHandle {
|
|
69
|
+
/** Logical column index the handle sits at (0-based, right edge of column i). */
|
|
70
|
+
columnIndex: number;
|
|
71
|
+
/** X-origin of the handle in twips, measured from the table's left edge. */
|
|
72
|
+
originTwips: number;
|
|
73
|
+
/** Handle height in twips (table visual height). */
|
|
74
|
+
heightTwips: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TableRenderPlan {
|
|
78
|
+
blockId: string;
|
|
79
|
+
pageIndex: number;
|
|
80
|
+
/** Logical column widths in twips (may be scaled from canonical). */
|
|
81
|
+
columnsTwips: readonly number[];
|
|
82
|
+
/** Band-class assignments derived from resolved table style. */
|
|
83
|
+
bandClasses: TableBandClasses;
|
|
84
|
+
/** Vertical-merge chains in the visible table range. */
|
|
85
|
+
verticalMerges: readonly VerticalMergeRef[];
|
|
86
|
+
/** Header rows repeated on this page (empty until row-level split lands). */
|
|
87
|
+
repeatedHeaderRows: readonly RepeatedHeaderRowRef[];
|
|
88
|
+
/** Column-resize handle origins for chrome grip placement. */
|
|
89
|
+
columnResizeHandles: readonly ColumnResizeHandle[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Builder ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface BuildTableRenderPlanInput {
|
|
95
|
+
blockId: string;
|
|
96
|
+
pageIndex: number;
|
|
97
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
|
|
98
|
+
resolved: ResolvedTableStyleResolution;
|
|
99
|
+
/** Total height of the table in twips (for grip geometry). */
|
|
100
|
+
tableHeightTwips: number;
|
|
101
|
+
/** If `columnsTwips` was scaled to the canvas width, pass the scale
|
|
102
|
+
* factor here; otherwise leave undefined. */
|
|
103
|
+
columnsTwipsScale?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildTableRenderPlan(
|
|
107
|
+
input: BuildTableRenderPlanInput,
|
|
108
|
+
): TableRenderPlan {
|
|
109
|
+
const { blockId, pageIndex, block, resolved, tableHeightTwips } = input;
|
|
110
|
+
const scale = input.columnsTwipsScale ?? 1;
|
|
111
|
+
const columnsTwips = block.gridColumns.map((w) => Math.round(w * scale));
|
|
112
|
+
|
|
113
|
+
const bandClasses = buildBandClasses(block.rows, resolved);
|
|
114
|
+
const verticalMerges = collectVerticalMerges(block.rows);
|
|
115
|
+
const repeatedHeaderRows: RepeatedHeaderRowRef[] = [];
|
|
116
|
+
const columnResizeHandles = buildColumnResizeHandles(columnsTwips, tableHeightTwips);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
blockId,
|
|
120
|
+
pageIndex,
|
|
121
|
+
columnsTwips,
|
|
122
|
+
bandClasses,
|
|
123
|
+
verticalMerges,
|
|
124
|
+
repeatedHeaderRows,
|
|
125
|
+
columnResizeHandles,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Internals ───────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function buildBandClasses(
|
|
132
|
+
rows: readonly SurfaceTableRowSnapshot[],
|
|
133
|
+
resolved: ResolvedTableStyleResolution,
|
|
134
|
+
): TableBandClasses {
|
|
135
|
+
const rowAssignments: TableRowBandAssignment[] = [];
|
|
136
|
+
const cellAssignments: TableCellBandAssignment[] = [];
|
|
137
|
+
|
|
138
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
|
139
|
+
const resolvedRow = resolved.rows[rowIndex];
|
|
140
|
+
const rowRegions = resolvedRow?.style.activeConditionalRegions ?? [];
|
|
141
|
+
rowAssignments.push({ rowIndex, regions: rowRegions });
|
|
142
|
+
|
|
143
|
+
const resolvedCells = resolvedRow?.cells ?? [];
|
|
144
|
+
const sourceRow = rows[rowIndex]!;
|
|
145
|
+
let columnCursor = sourceRow.gridBefore ?? 0;
|
|
146
|
+
for (let cellIndex = 0; cellIndex < sourceRow.cells.length; cellIndex += 1) {
|
|
147
|
+
const cell = sourceRow.cells[cellIndex]!;
|
|
148
|
+
const columnSpan = Math.max(1, cell.colspan ?? 1);
|
|
149
|
+
const resolvedCell = resolvedCells[cellIndex];
|
|
150
|
+
const regions = resolvedCell?.activeConditionalRegions ?? [];
|
|
151
|
+
cellAssignments.push({
|
|
152
|
+
rowIndex,
|
|
153
|
+
columnIndex: columnCursor,
|
|
154
|
+
regions,
|
|
155
|
+
});
|
|
156
|
+
columnCursor += columnSpan;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { rows: rowAssignments, cells: cellAssignments };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function collectVerticalMerges(
|
|
164
|
+
rows: readonly SurfaceTableRowSnapshot[],
|
|
165
|
+
): VerticalMergeRef[] {
|
|
166
|
+
const merges: VerticalMergeRef[] = [];
|
|
167
|
+
// Track active chains by column: maps startColumn → { startRow, span }.
|
|
168
|
+
const active = new Map<number, { startRow: number; span: number }>();
|
|
169
|
+
|
|
170
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
|
171
|
+
const row = rows[rowIndex]!;
|
|
172
|
+
let columnCursor = row.gridBefore ?? 0;
|
|
173
|
+
const seenThisRow = new Set<number>();
|
|
174
|
+
|
|
175
|
+
for (const cell of row.cells) {
|
|
176
|
+
const span = Math.max(1, cell.colspan ?? 1);
|
|
177
|
+
if (cell.verticalMerge === "restart") {
|
|
178
|
+
active.set(columnCursor, { startRow: rowIndex, span });
|
|
179
|
+
seenThisRow.add(columnCursor);
|
|
180
|
+
} else if (cell.verticalMerge === "continue") {
|
|
181
|
+
seenThisRow.add(columnCursor);
|
|
182
|
+
// chain stays open; ended rowIndex extends via the flush below
|
|
183
|
+
}
|
|
184
|
+
columnCursor += span;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Flush chains that did NOT appear as a continue in this row.
|
|
188
|
+
for (const [startColumn, chain] of [...active.entries()]) {
|
|
189
|
+
if (!seenThisRow.has(startColumn)) {
|
|
190
|
+
merges.push({
|
|
191
|
+
columnIndex: startColumn,
|
|
192
|
+
startRowIndex: chain.startRow,
|
|
193
|
+
endRowIndex: rowIndex - 1,
|
|
194
|
+
columnSpan: chain.span,
|
|
195
|
+
});
|
|
196
|
+
active.delete(startColumn);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Flush any still-open chains at end of table.
|
|
202
|
+
for (const [startColumn, chain] of active.entries()) {
|
|
203
|
+
merges.push({
|
|
204
|
+
columnIndex: startColumn,
|
|
205
|
+
startRowIndex: chain.startRow,
|
|
206
|
+
endRowIndex: rows.length - 1,
|
|
207
|
+
columnSpan: chain.span,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return merges;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildColumnResizeHandles(
|
|
215
|
+
columnsTwips: readonly number[],
|
|
216
|
+
heightTwips: number,
|
|
217
|
+
): ColumnResizeHandle[] {
|
|
218
|
+
const handles: ColumnResizeHandle[] = [];
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
for (let i = 0; i < columnsTwips.length - 1; i += 1) {
|
|
221
|
+
cursor += columnsTwips[i] ?? 0;
|
|
222
|
+
handles.push({
|
|
223
|
+
columnIndex: i,
|
|
224
|
+
originTwips: cursor,
|
|
225
|
+
heightTwips,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return handles;
|
|
229
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block fragment projection (P4b).
|
|
3
|
+
*
|
|
4
|
+
* Classifies each `PublicBlockFragment` produced by the layout facet into
|
|
5
|
+
* a `RenderBlock["kind"]` so the render kernel's output is accurate across
|
|
6
|
+
* paragraph / table / opaque / synthetic blocks. Previously the kernel's
|
|
7
|
+
* `classifyBlockKind` stub always returned `"paragraph"`.
|
|
8
|
+
*
|
|
9
|
+
* The classification is driven by the deterministic blockId prefix scheme
|
|
10
|
+
* emitted by `src/runtime/surface-projection.ts`:
|
|
11
|
+
* - `paragraph-*` → "paragraph"
|
|
12
|
+
* - `table-*` → "table"
|
|
13
|
+
* - `opaque-*` → "opaque"
|
|
14
|
+
* - `section-break-*` → "opaque" (read-only boundary marker)
|
|
15
|
+
* - `sdt-*`, `sdt-wrapper-*` → "opaque"
|
|
16
|
+
* - `custom-xml-*` → "opaque"
|
|
17
|
+
* - `alt-chunk-*` → "opaque"
|
|
18
|
+
* - `synthetic-*` → "synthetic"
|
|
19
|
+
* Unknown prefixes fall back to `"paragraph"` so the render frame stays
|
|
20
|
+
* usable while a new surface node type is in development.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { RenderBlock } from "./render-frame-types.ts";
|
|
24
|
+
|
|
25
|
+
export function classifyBlockKind(blockId: string): RenderBlock["kind"] {
|
|
26
|
+
if (blockId.startsWith("paragraph")) return "paragraph";
|
|
27
|
+
if (blockId.startsWith("table")) return "table";
|
|
28
|
+
if (blockId.startsWith("opaque")) return "opaque";
|
|
29
|
+
if (blockId.startsWith("section-break")) return "opaque";
|
|
30
|
+
if (blockId.startsWith("sdt")) return "opaque";
|
|
31
|
+
if (blockId.startsWith("custom-xml")) return "opaque";
|
|
32
|
+
if (blockId.startsWith("alt-chunk")) return "opaque";
|
|
33
|
+
if (blockId.startsWith("synthetic")) return "synthetic";
|
|
34
|
+
return "paragraph";
|
|
35
|
+
}
|