@beyondwork/docx-react-component 1.0.38 → 1.0.39
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/package.json +41 -31
- package/src/api/public-types.ts +183 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +134 -18
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +40 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/ui/WordReviewEditor.tsx +285 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +4 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +1 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
LayoutEngineInstance,
|
|
43
43
|
LayoutEngineQueryInput,
|
|
44
44
|
} from "./layout-engine-instance.ts";
|
|
45
|
+
import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
|
|
45
46
|
import type { PageFragmentMapper } from "./page-fragment-mapper.ts";
|
|
46
47
|
import {
|
|
47
48
|
PAGE_FORMAT_CATALOG,
|
|
@@ -64,6 +65,7 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
|
|
|
64
65
|
import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
65
66
|
import { resolveTableStyleResolution } from "../table-style-resolver.ts";
|
|
66
67
|
import { buildTableRenderPlan } from "./table-render-plan.ts";
|
|
68
|
+
import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
|
|
67
69
|
import type {
|
|
68
70
|
SurfaceBlockSnapshot,
|
|
69
71
|
} from "../../api/public-types";
|
|
@@ -396,6 +398,28 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
396
398
|
getMeasurement(blockId: string): PublicBlockMeasurement | null;
|
|
397
399
|
getMeasurementFidelity(): PublicMeasurementFidelity;
|
|
398
400
|
whenMeasurementReady(): Promise<void>;
|
|
401
|
+
swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
|
|
402
|
+
|
|
403
|
+
// Table render plan (P3e consumed by the render kernel, P4) ------------
|
|
404
|
+
/**
|
|
405
|
+
* 0-based page index where the first fragment of `blockId` lands, or
|
|
406
|
+
* `null` when the block has no fragments in the current page graph.
|
|
407
|
+
* Used by the tables facet to determine which page to request plans for
|
|
408
|
+
* without needing PM document offsets.
|
|
409
|
+
*/
|
|
410
|
+
getFirstPageIndexForBlock(blockId: string): number | null;
|
|
411
|
+
/**
|
|
412
|
+
* Build a `TableRenderPlan` for a table block on a given page. Returns
|
|
413
|
+
* `null` when the blockId does not resolve to a table in the current
|
|
414
|
+
* surface. The plan carries columnsTwips, bandClasses, verticalMerges,
|
|
415
|
+
* repeatedHeaderRows, and columnResizeHandles so chrome can render
|
|
416
|
+
* band-aware cell styling and place column-resize grips without
|
|
417
|
+
* walking canonical state.
|
|
418
|
+
*/
|
|
419
|
+
getTableRenderPlan(
|
|
420
|
+
blockId: string,
|
|
421
|
+
pageIndex: number,
|
|
422
|
+
): import("./table-render-plan.ts").TableRenderPlan | null;
|
|
399
423
|
|
|
400
424
|
// Table render plan (P3e consumed by the render kernel, P4) ------------
|
|
401
425
|
/**
|
|
@@ -659,8 +683,11 @@ export function createLayoutFacet(
|
|
|
659
683
|
hitTest(pointInRoot) {
|
|
660
684
|
const kernel = input.renderKernel?.();
|
|
661
685
|
if (!kernel) return null;
|
|
686
|
+
const t0 = typeof performance !== "undefined" ? performance.now() : 0;
|
|
662
687
|
const frame = kernel.getRenderFrame();
|
|
663
|
-
|
|
688
|
+
const result = resolveHitTest(frame, pointInRoot);
|
|
689
|
+
if (t0 > 0) recordPerfSample("chrome.hit_test", performance.now() - t0);
|
|
690
|
+
return result;
|
|
664
691
|
},
|
|
665
692
|
|
|
666
693
|
getAnchorRects(query) {
|
|
@@ -736,6 +763,18 @@ export function createLayoutFacet(
|
|
|
736
763
|
return engine.whenMeasurementReady();
|
|
737
764
|
},
|
|
738
765
|
|
|
766
|
+
getFirstPageIndexForBlock(blockId) {
|
|
767
|
+
const graph = currentGraph();
|
|
768
|
+
const fragment = graph.fragments.find((f) => f.blockId === blockId);
|
|
769
|
+
if (!fragment) return null;
|
|
770
|
+
const page = graph.pages.find((p) => p.pageId === fragment.pageId);
|
|
771
|
+
return page?.pageIndex ?? null;
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
swapMeasurementProvider(provider) {
|
|
775
|
+
engine.swapMeasurementProvider(provider);
|
|
776
|
+
},
|
|
777
|
+
|
|
739
778
|
getTableRenderPlan(blockId, pageIndex) {
|
|
740
779
|
const graph = currentGraph();
|
|
741
780
|
const fragment = graph.fragments.find((f) => f.blockId === blockId);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `PAGE` and `NUMPAGES` field values per page.
|
|
3
|
+
*
|
|
4
|
+
* These two field families (now in `SupportedFieldFamily`) compute differently
|
|
5
|
+
* from `REF` / `PAGEREF` because the resolved value depends on the *owning
|
|
6
|
+
* page* of each header/footer instance, not on a single document-level
|
|
7
|
+
* computation. A header part is shared across many pages; the same PAGE
|
|
8
|
+
* field must render "1" on page 1, "2" on page 2, and so on.
|
|
9
|
+
*
|
|
10
|
+
* The resolver is a pure function over the page graph — no DOM, no side
|
|
11
|
+
* effects. Consumers (per-page header/footer bands) call
|
|
12
|
+
* `resolvePageFieldDisplayText` at render time to substitute the cached
|
|
13
|
+
* preserve-only text that currently sits inside the paragraph inline atom.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
|
|
17
|
+
import type { SupportedFieldFamily } from "../../model/canonical-document.ts";
|
|
18
|
+
|
|
19
|
+
export interface PageFieldContext {
|
|
20
|
+
/** The page this header/footer copy renders on. */
|
|
21
|
+
page: RuntimePageNode;
|
|
22
|
+
/** The graph the page belongs to (for NUMPAGES resolution). */
|
|
23
|
+
graph: RuntimePageGraph;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the display text for a PAGE / NUMPAGES field on a specific page.
|
|
28
|
+
*
|
|
29
|
+
* Returns the original cached `displayText` for unsupported families so the
|
|
30
|
+
* caller can use this as a single resolution helper.
|
|
31
|
+
*/
|
|
32
|
+
export function resolvePageFieldDisplayText(
|
|
33
|
+
family: SupportedFieldFamily | string,
|
|
34
|
+
cachedDisplayText: string,
|
|
35
|
+
context: PageFieldContext,
|
|
36
|
+
): string {
|
|
37
|
+
switch (family) {
|
|
38
|
+
case "PAGE":
|
|
39
|
+
return String(context.page.stories.displayPageNumber);
|
|
40
|
+
case "NUMPAGES":
|
|
41
|
+
// Blank fillers (evenPage/oddPage section breaks) don't count toward
|
|
42
|
+
// NUMPAGES — the graph already excludes them from contentPageCount.
|
|
43
|
+
return String(context.graph.contentPageCount);
|
|
44
|
+
default:
|
|
45
|
+
return cachedDisplayText;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a lookup keyed by pageId → (fieldFamily → resolvedText). Useful when
|
|
51
|
+
* a renderer wants to bulk-resolve every page's fields before emitting DOM.
|
|
52
|
+
* Absent keys mean no override; the cached field text should be used.
|
|
53
|
+
*/
|
|
54
|
+
export function buildPageFieldResolutionTable(
|
|
55
|
+
graph: RuntimePageGraph,
|
|
56
|
+
families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES"]),
|
|
57
|
+
): Map<string, Map<string, string>> {
|
|
58
|
+
const result = new Map<string, Map<string, string>>();
|
|
59
|
+
for (const page of graph.pages) {
|
|
60
|
+
const perPage = new Map<string, string>();
|
|
61
|
+
for (const family of families) {
|
|
62
|
+
perPage.set(
|
|
63
|
+
family,
|
|
64
|
+
resolvePageFieldDisplayText(family, "", { page, graph }),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
result.set(page.pageId, perPage);
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten header/footer sub-part blocks to short text previews with PAGE /
|
|
3
|
+
* NUMPAGES fields resolved for the owning page. Used by the in-flow page
|
|
4
|
+
* chrome widgets so the bands show live content ("Page 3 of 5") rather
|
|
5
|
+
* than a static "Header" / "Footer" placeholder.
|
|
6
|
+
*
|
|
7
|
+
* This is a **preview**, not a full renderer — we flatten to plain text
|
|
8
|
+
* and cap the length. A per-page band is ~32 px tall; anything more
|
|
9
|
+
* elaborate belongs to the full editing path, which is entered by double-
|
|
10
|
+
* clicking the band.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
BlockNode,
|
|
15
|
+
HeaderDocument,
|
|
16
|
+
FooterDocument,
|
|
17
|
+
InlineNode,
|
|
18
|
+
} from "../../model/canonical-document.ts";
|
|
19
|
+
import type {
|
|
20
|
+
RuntimePageGraph,
|
|
21
|
+
RuntimePageNode,
|
|
22
|
+
} from "./page-graph.ts";
|
|
23
|
+
import { resolvePageFieldDisplayText } from "./resolve-page-fields.ts";
|
|
24
|
+
|
|
25
|
+
const MAX_PREVIEW_CHARS = 140;
|
|
26
|
+
|
|
27
|
+
export interface PagePreviewMaps {
|
|
28
|
+
/** pageId → short text preview of the resolved header story (if any). */
|
|
29
|
+
headerPreviewByPageId: Map<string, string>;
|
|
30
|
+
/** pageId → short text preview of the resolved footer story (if any). */
|
|
31
|
+
footerPreviewByPageId: Map<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildPagePreviewMaps(
|
|
35
|
+
graph: RuntimePageGraph,
|
|
36
|
+
subParts: {
|
|
37
|
+
headers?: ReadonlyArray<HeaderDocument>;
|
|
38
|
+
footers?: ReadonlyArray<FooterDocument>;
|
|
39
|
+
},
|
|
40
|
+
): PagePreviewMaps {
|
|
41
|
+
const headerPreviewByPageId = new Map<string, string>();
|
|
42
|
+
const footerPreviewByPageId = new Map<string, string>();
|
|
43
|
+
|
|
44
|
+
for (const page of graph.pages) {
|
|
45
|
+
if (page.isBlankFiller) continue;
|
|
46
|
+
const headerStory = page.stories.header;
|
|
47
|
+
if (headerStory && headerStory.kind === "header") {
|
|
48
|
+
const source = findSubPart(subParts.headers, headerStory.relationshipId);
|
|
49
|
+
if (source) {
|
|
50
|
+
headerPreviewByPageId.set(
|
|
51
|
+
page.pageId,
|
|
52
|
+
flattenBlocksToPreview(source.blocks, page, graph),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const footerStory = page.stories.footer;
|
|
57
|
+
if (footerStory && footerStory.kind === "footer") {
|
|
58
|
+
const source = findSubPart(subParts.footers, footerStory.relationshipId);
|
|
59
|
+
if (source) {
|
|
60
|
+
footerPreviewByPageId.set(
|
|
61
|
+
page.pageId,
|
|
62
|
+
flattenBlocksToPreview(source.blocks, page, graph),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { headerPreviewByPageId, footerPreviewByPageId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findSubPart<T extends HeaderDocument | FooterDocument>(
|
|
72
|
+
parts: ReadonlyArray<T> | undefined,
|
|
73
|
+
relationshipId: string,
|
|
74
|
+
): T | undefined {
|
|
75
|
+
if (!parts) return undefined;
|
|
76
|
+
return parts.find((p) => p.relationshipId === relationshipId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flattenBlocksToPreview(
|
|
80
|
+
blocks: ReadonlyArray<BlockNode>,
|
|
81
|
+
page: RuntimePageNode,
|
|
82
|
+
graph: RuntimePageGraph,
|
|
83
|
+
): string {
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
collectBlockText(block, page, graph, parts);
|
|
87
|
+
if (joinPreview(parts).length >= MAX_PREVIEW_CHARS) break;
|
|
88
|
+
}
|
|
89
|
+
return truncate(joinPreview(parts));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectBlockText(
|
|
93
|
+
block: BlockNode,
|
|
94
|
+
page: RuntimePageNode,
|
|
95
|
+
graph: RuntimePageGraph,
|
|
96
|
+
out: string[],
|
|
97
|
+
): void {
|
|
98
|
+
switch (block.type) {
|
|
99
|
+
case "paragraph":
|
|
100
|
+
for (const child of block.children) {
|
|
101
|
+
collectInlineText(child, page, graph, out);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case "table":
|
|
105
|
+
for (const row of block.rows) {
|
|
106
|
+
for (const cell of row.cells) {
|
|
107
|
+
for (const childBlock of cell.children) {
|
|
108
|
+
collectBlockText(childBlock, page, graph, out);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "sdt":
|
|
114
|
+
for (const child of block.children) {
|
|
115
|
+
collectBlockText(child, page, graph, out);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
// opaque_block / section_break / custom_xml / alt_chunk — no preview text.
|
|
119
|
+
default:
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function collectInlineText(
|
|
125
|
+
inline: InlineNode,
|
|
126
|
+
page: RuntimePageNode,
|
|
127
|
+
graph: RuntimePageGraph,
|
|
128
|
+
out: string[],
|
|
129
|
+
): void {
|
|
130
|
+
switch (inline.type) {
|
|
131
|
+
case "text":
|
|
132
|
+
out.push(inline.text);
|
|
133
|
+
break;
|
|
134
|
+
case "tab":
|
|
135
|
+
out.push(" ");
|
|
136
|
+
break;
|
|
137
|
+
case "hard_break":
|
|
138
|
+
out.push(" ");
|
|
139
|
+
break;
|
|
140
|
+
case "field": {
|
|
141
|
+
const family = inline.fieldFamily ?? classifyFieldInstructionLocal(inline.instruction);
|
|
142
|
+
const cached = flattenInline(inline.children);
|
|
143
|
+
if (family === "PAGE" || family === "NUMPAGES") {
|
|
144
|
+
out.push(
|
|
145
|
+
resolvePageFieldDisplayText(family, cached, { page, graph }),
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
out.push(cached);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "hyperlink":
|
|
153
|
+
for (const child of inline.children) {
|
|
154
|
+
collectInlineText(child, page, graph, out);
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
// bookmark_start/end, opaque_inline, note_ref etc — skip.
|
|
158
|
+
default:
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function flattenInline(inlines: ReadonlyArray<InlineNode> | undefined): string {
|
|
164
|
+
if (!inlines) return "";
|
|
165
|
+
let buf = "";
|
|
166
|
+
for (const inline of inlines) {
|
|
167
|
+
if (inline.type === "text") buf += inline.text;
|
|
168
|
+
else if (inline.type === "hard_break" || inline.type === "tab") buf += " ";
|
|
169
|
+
}
|
|
170
|
+
return buf;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function classifyFieldInstructionLocal(instr: string): string {
|
|
174
|
+
const match = /^\s*(\w+)/.exec(instr);
|
|
175
|
+
return match ? match[1]!.toUpperCase() : "UNKNOWN";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function joinPreview(parts: readonly string[]): string {
|
|
179
|
+
return parts.join("").replace(/\s+/g, " ").trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function truncate(s: string): string {
|
|
183
|
+
if (s.length <= MAX_PREVIEW_CHARS) return s;
|
|
184
|
+
return s.slice(0, MAX_PREVIEW_CHARS - 1).trimEnd() + "…";
|
|
185
|
+
}
|
|
@@ -151,11 +151,11 @@ export function resolveBlockFormatting(
|
|
|
151
151
|
fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
|
|
152
152
|
averageCharWidthTwips: fontInfo.avgCharWidth,
|
|
153
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),
|
|
154
|
+
keepNext: Boolean(block.keepNext ?? block.resolvedParagraphFormatting?.keepNext),
|
|
155
|
+
keepLines: Boolean(block.keepLines ?? block.resolvedParagraphFormatting?.keepLines),
|
|
156
|
+
pageBreakBefore: Boolean(block.pageBreakBefore ?? block.resolvedParagraphFormatting?.pageBreakBefore),
|
|
157
|
+
widowControl: (block.widowControl ?? block.resolvedParagraphFormatting?.widowControl) !== false, // default true in Word
|
|
158
|
+
contextualSpacing: Boolean(block.contextualSpacing ?? block.resolvedParagraphFormatting?.contextualSpacing),
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -166,9 +166,10 @@ export function resolveBlockFormatting(
|
|
|
166
166
|
function resolveSpacing(
|
|
167
167
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
168
168
|
): ParagraphSpacing {
|
|
169
|
-
//
|
|
169
|
+
// Priority: numbering geometry > direct paragraph > style cascade
|
|
170
170
|
const numbering = block.resolvedNumbering?.geometry.spacing;
|
|
171
171
|
const direct = block.spacing;
|
|
172
|
+
const cascade = block.resolvedParagraphFormatting?.spacing;
|
|
172
173
|
|
|
173
174
|
const normalizeLineRule = (
|
|
174
175
|
rule: string | undefined,
|
|
@@ -177,21 +178,15 @@ function resolveSpacing(
|
|
|
177
178
|
return undefined;
|
|
178
179
|
};
|
|
179
180
|
|
|
180
|
-
if (numbering) {
|
|
181
|
+
if (numbering || direct || cascade) {
|
|
181
182
|
return {
|
|
182
|
-
before: numbering
|
|
183
|
-
after: numbering
|
|
184
|
-
line: numbering
|
|
185
|
-
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),
|
|
183
|
+
before: numbering?.before ?? direct?.before ?? cascade?.before,
|
|
184
|
+
after: numbering?.after ?? direct?.after ?? cascade?.after,
|
|
185
|
+
line: numbering?.line ?? direct?.line ?? cascade?.line,
|
|
186
|
+
lineRule:
|
|
187
|
+
normalizeLineRule(numbering?.lineRule) ??
|
|
188
|
+
normalizeLineRule(direct?.lineRule) ??
|
|
189
|
+
normalizeLineRule(cascade?.lineRule),
|
|
195
190
|
};
|
|
196
191
|
}
|
|
197
192
|
|
|
@@ -228,16 +223,25 @@ function resolveIndentation(
|
|
|
228
223
|
};
|
|
229
224
|
}
|
|
230
225
|
|
|
231
|
-
const
|
|
232
|
-
|
|
226
|
+
const direct = block.indentation;
|
|
227
|
+
const cascade = block.resolvedParagraphFormatting?.indentation;
|
|
228
|
+
|
|
229
|
+
if (!direct && !cascade) {
|
|
233
230
|
return { left: 0, right: 0, firstLine: 0, hanging: 0 };
|
|
234
231
|
}
|
|
235
232
|
|
|
236
233
|
return {
|
|
237
|
-
left:
|
|
238
|
-
right:
|
|
239
|
-
firstLine:
|
|
240
|
-
hanging:
|
|
234
|
+
left: direct?.left ?? cascade?.left ?? 0,
|
|
235
|
+
right: direct?.right ?? cascade?.right ?? 0,
|
|
236
|
+
firstLine: direct?.firstLine ?? cascade?.firstLine ?? 0,
|
|
237
|
+
hanging:
|
|
238
|
+
direct?.hanging ??
|
|
239
|
+
cascade?.hanging ??
|
|
240
|
+
(typeof direct?.firstLine === "number" && direct.firstLine < 0
|
|
241
|
+
? Math.abs(direct.firstLine)
|
|
242
|
+
: typeof cascade?.firstLine === "number" && cascade.firstLine < 0
|
|
243
|
+
? Math.abs(cascade.firstLine)
|
|
244
|
+
: 0),
|
|
241
245
|
};
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -101,6 +101,13 @@ export interface BuildTableRenderPlanInput {
|
|
|
101
101
|
/** If `columnsTwips` was scaled to the canvas width, pass the scale
|
|
102
102
|
* factor here; otherwise leave undefined. */
|
|
103
103
|
columnsTwipsScale?: number;
|
|
104
|
+
/**
|
|
105
|
+
* R3: true when this plan is for a continuation page of a multi-page
|
|
106
|
+
* table (i.e. a page other than the one the table first appears on).
|
|
107
|
+
* When true, header rows from the source table are emitted in
|
|
108
|
+
* `repeatedHeaderRows` so the consumer can prepend them on render.
|
|
109
|
+
*/
|
|
110
|
+
isContinuationPage?: boolean;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function buildTableRenderPlan(
|
|
@@ -112,7 +119,20 @@ export function buildTableRenderPlan(
|
|
|
112
119
|
|
|
113
120
|
const bandClasses = buildBandClasses(block.rows, resolved);
|
|
114
121
|
const verticalMerges = collectVerticalMerges(block.rows);
|
|
115
|
-
|
|
122
|
+
// R3: header rows are repeated on every continuation page. When the
|
|
123
|
+
// render-plan builder is invoked for a page with `isContinuationPage`,
|
|
124
|
+
// every row with `isHeader === true` gets an entry in repeatedHeaderRows.
|
|
125
|
+
// First-page plans (or tables that fit on a single page) return an empty
|
|
126
|
+
// array. `virtualFragmentId` is deterministic so consumers can key off it.
|
|
127
|
+
const repeatedHeaderRows: RepeatedHeaderRowRef[] = input.isContinuationPage
|
|
128
|
+
? block.rows
|
|
129
|
+
.map((row, rowIndex) => ({ row, rowIndex }))
|
|
130
|
+
.filter(({ row }) => row.isHeader === true)
|
|
131
|
+
.map(({ rowIndex }) => ({
|
|
132
|
+
sourceRowIndex: rowIndex,
|
|
133
|
+
virtualFragmentId: `fragment-${blockId}-vheader-p${pageIndex}-r${rowIndex}`,
|
|
134
|
+
}))
|
|
135
|
+
: [];
|
|
116
136
|
const columnResizeHandles = buildColumnResizeHandles(columnsTwips, tableHeightTwips);
|
|
117
137
|
|
|
118
138
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CanonicalRunFormatting,
|
|
2
3
|
NumberingCatalog,
|
|
3
4
|
NumberingLevelDefinition,
|
|
4
5
|
ParagraphNode,
|
|
@@ -23,6 +24,7 @@ export interface NumberingPrefixResult {
|
|
|
23
24
|
paragraphStyleId?: string;
|
|
24
25
|
isLegalNumbering?: boolean;
|
|
25
26
|
geometry: ResolvedNumberingGeometry;
|
|
27
|
+
markerRunProperties?: CanonicalRunFormatting;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface NumberingPrefixResolver {
|
|
@@ -90,6 +92,9 @@ export function createNumberingPrefixResolver(
|
|
|
90
92
|
? { paragraphStyleId: resolved.effectiveLevel.paragraphStyleId }
|
|
91
93
|
: {}),
|
|
92
94
|
...(resolved.effectiveLevel.isLegalNumbering ? { isLegalNumbering: true } : {}),
|
|
95
|
+
...(resolved.geometry.markerRunProperties
|
|
96
|
+
? { markerRunProperties: resolved.geometry.markerRunProperties }
|
|
97
|
+
: {}),
|
|
93
98
|
geometry: resolved.geometry,
|
|
94
99
|
};
|
|
95
100
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve style chains for paragraph and character styles by walking the
|
|
3
|
+
* `basedOn` reference, with cycle detection.
|
|
4
|
+
*
|
|
5
|
+
* Returned chains are leaf-first (most-specific style at index 0, root at the
|
|
6
|
+
* end). Cycles break at the first re-encounter. Dangling `basedOn` references
|
|
7
|
+
* stop the walk silently (the leaf is still returned).
|
|
8
|
+
*
|
|
9
|
+
* Tasks 9 and 10 extend this module with `resolveEffective*Formatting`
|
|
10
|
+
* helpers that cascade the chain into a flat formatting record.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CanonicalParagraphFormatting,
|
|
15
|
+
CanonicalRunFormatting,
|
|
16
|
+
CharacterStyleDefinition,
|
|
17
|
+
ParagraphStyleDefinition,
|
|
18
|
+
StylesCatalog,
|
|
19
|
+
} from "../model/canonical-document.ts";
|
|
20
|
+
|
|
21
|
+
export function resolveParagraphStyleChain(
|
|
22
|
+
styleId: string,
|
|
23
|
+
catalog: StylesCatalog | undefined,
|
|
24
|
+
): string[] {
|
|
25
|
+
const chain: string[] = [];
|
|
26
|
+
if (!catalog) return chain;
|
|
27
|
+
const visited = new Set<string>();
|
|
28
|
+
let current: string | undefined = styleId;
|
|
29
|
+
while (current && !visited.has(current)) {
|
|
30
|
+
visited.add(current);
|
|
31
|
+
const def: ParagraphStyleDefinition | undefined = catalog.paragraphs[current];
|
|
32
|
+
if (!def) break;
|
|
33
|
+
chain.push(current);
|
|
34
|
+
current = def.basedOn;
|
|
35
|
+
}
|
|
36
|
+
return chain;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveCharacterStyleChain(
|
|
40
|
+
styleId: string,
|
|
41
|
+
catalog: StylesCatalog | undefined,
|
|
42
|
+
): string[] {
|
|
43
|
+
const chain: string[] = [];
|
|
44
|
+
if (!catalog) return chain;
|
|
45
|
+
const visited = new Set<string>();
|
|
46
|
+
let current: string | undefined = styleId;
|
|
47
|
+
while (current && !visited.has(current)) {
|
|
48
|
+
visited.add(current);
|
|
49
|
+
const def: CharacterStyleDefinition | undefined = catalog.characters[current];
|
|
50
|
+
if (!def) break;
|
|
51
|
+
chain.push(current);
|
|
52
|
+
current = def.basedOn;
|
|
53
|
+
}
|
|
54
|
+
return chain;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mergeRun(
|
|
58
|
+
base: CanonicalRunFormatting | undefined,
|
|
59
|
+
over: CanonicalRunFormatting | undefined,
|
|
60
|
+
): CanonicalRunFormatting | undefined {
|
|
61
|
+
if (!base && !over) return undefined;
|
|
62
|
+
return { ...(base ?? {}), ...(over ?? {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mergeParagraph(
|
|
66
|
+
base: CanonicalParagraphFormatting | undefined,
|
|
67
|
+
over: CanonicalParagraphFormatting | undefined,
|
|
68
|
+
): CanonicalParagraphFormatting | undefined {
|
|
69
|
+
if (!base && !over) return undefined;
|
|
70
|
+
const merged: CanonicalParagraphFormatting = { ...(base ?? {}), ...(over ?? {}) };
|
|
71
|
+
if (base?.spacing || over?.spacing) {
|
|
72
|
+
merged.spacing = { ...(base?.spacing ?? {}), ...(over?.spacing ?? {}) };
|
|
73
|
+
}
|
|
74
|
+
if (base?.indentation || over?.indentation) {
|
|
75
|
+
merged.indentation = { ...(base?.indentation ?? {}), ...(over?.indentation ?? {}) };
|
|
76
|
+
}
|
|
77
|
+
if (base?.borders || over?.borders) {
|
|
78
|
+
merged.borders = { ...(base?.borders ?? {}), ...(over?.borders ?? {}) };
|
|
79
|
+
}
|
|
80
|
+
if (base?.shading || over?.shading) {
|
|
81
|
+
merged.shading = { ...(base?.shading ?? {}), ...(over?.shading ?? {}) };
|
|
82
|
+
}
|
|
83
|
+
if (base?.paragraphMarkRunProperties || over?.paragraphMarkRunProperties) {
|
|
84
|
+
merged.paragraphMarkRunProperties = mergeRun(
|
|
85
|
+
base?.paragraphMarkRunProperties,
|
|
86
|
+
over?.paragraphMarkRunProperties,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ParagraphResolveInput {
|
|
93
|
+
styleId: string | undefined;
|
|
94
|
+
direct: CanonicalParagraphFormatting | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve paragraph formatting cascade: docDefaults.paragraph → basedOn chain
|
|
99
|
+
* (root-to-leaf) → direct. Returns `{}` when nothing is present.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveEffectiveParagraphFormatting(
|
|
102
|
+
input: ParagraphResolveInput,
|
|
103
|
+
catalog: StylesCatalog | undefined,
|
|
104
|
+
): CanonicalParagraphFormatting {
|
|
105
|
+
let acc: CanonicalParagraphFormatting | undefined = catalog?.docDefaults?.paragraph;
|
|
106
|
+
if (catalog && input.styleId) {
|
|
107
|
+
const chain = resolveParagraphStyleChain(input.styleId, catalog);
|
|
108
|
+
// Walk root-to-leaf (most-general first) so the most-specific style wins
|
|
109
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
110
|
+
const styleId = chain[i]!;
|
|
111
|
+
acc = mergeParagraph(acc, catalog.paragraphs[styleId]?.paragraphProperties);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
acc = mergeParagraph(acc, input.direct);
|
|
115
|
+
return acc ?? {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RunResolveInput {
|
|
119
|
+
paragraphStyleId: string | undefined;
|
|
120
|
+
characterStyleId: string | undefined;
|
|
121
|
+
direct: CanonicalRunFormatting | undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve run formatting cascade: docDefaults.run → paragraph-style-chain rPr
|
|
126
|
+
* (root-to-leaf) → character-style-chain (root-to-leaf) → direct. Returns
|
|
127
|
+
* `{}` when nothing is present.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveEffectiveRunFormatting(
|
|
130
|
+
input: RunResolveInput,
|
|
131
|
+
catalog: StylesCatalog | undefined,
|
|
132
|
+
): CanonicalRunFormatting {
|
|
133
|
+
let acc: CanonicalRunFormatting | undefined = catalog?.docDefaults?.run;
|
|
134
|
+
if (catalog && input.paragraphStyleId) {
|
|
135
|
+
const chain = resolveParagraphStyleChain(input.paragraphStyleId, catalog);
|
|
136
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
137
|
+
const styleId = chain[i]!;
|
|
138
|
+
acc = mergeRun(acc, catalog.paragraphs[styleId]?.runProperties);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (catalog && input.characterStyleId) {
|
|
142
|
+
const chain = resolveCharacterStyleChain(input.characterStyleId, catalog);
|
|
143
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
144
|
+
const styleId = chain[i]!;
|
|
145
|
+
acc = mergeRun(acc, catalog.characters[styleId]?.runProperties);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
acc = mergeRun(acc, input.direct);
|
|
149
|
+
return acc ?? {};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface MarkerResolveInput {
|
|
153
|
+
paragraphStyleId: string | undefined;
|
|
154
|
+
levelRunProperties: CanonicalRunFormatting | undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve the numbering marker's character formatting — the rPr that styles
|
|
159
|
+
* the number/bullet glyph itself (bold, color, font, size, etc.).
|
|
160
|
+
*
|
|
161
|
+
* Cascade order (lowest to highest priority):
|
|
162
|
+
* 1. `docDefaults.run` — Word's baseline run formatting.
|
|
163
|
+
* 2. Paragraph style chain's `runProperties` — walked root-to-leaf; the
|
|
164
|
+
* numbered paragraph's styleId contributes each ancestor's rPr.
|
|
165
|
+
* 3. The leaf paragraph style's `paragraphProperties.paragraphMarkRunProperties`
|
|
166
|
+
* — Word stores the paragraph-mark rPr separately from the body rPr,
|
|
167
|
+
* and the mark's formatting is what the numbering marker inherits from.
|
|
168
|
+
* 4. `levelRunProperties` — `<w:lvl><w:rPr>` on the numbering level. Per
|
|
169
|
+
* ECMA-376, this is the most specific formatting for the marker and
|
|
170
|
+
* overrides the paragraph's run formatting entirely for the marker.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveNumberingMarkerRunFormatting(
|
|
173
|
+
input: MarkerResolveInput,
|
|
174
|
+
catalog: StylesCatalog | undefined,
|
|
175
|
+
): CanonicalRunFormatting {
|
|
176
|
+
// Start with docDefaults baseline
|
|
177
|
+
let acc: CanonicalRunFormatting | undefined = catalog?.docDefaults?.run;
|
|
178
|
+
|
|
179
|
+
// Walk the paragraph style chain's rPr (root-to-leaf, most-specific wins)
|
|
180
|
+
if (catalog && input.paragraphStyleId) {
|
|
181
|
+
const chain = resolveParagraphStyleChain(input.paragraphStyleId, catalog);
|
|
182
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
183
|
+
const styleId = chain[i]!;
|
|
184
|
+
acc = mergeRun(acc, catalog.paragraphs[styleId]?.runProperties);
|
|
185
|
+
}
|
|
186
|
+
// Leaf paragraph style's paragraph-mark rPr layers on top of the chain
|
|
187
|
+
const leaf = catalog.paragraphs[input.paragraphStyleId];
|
|
188
|
+
acc = mergeRun(acc, leaf?.paragraphProperties?.paragraphMarkRunProperties);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Level rPr has the final say (per ECMA-376 17.9)
|
|
192
|
+
acc = mergeRun(acc, input.levelRunProperties);
|
|
193
|
+
return acc ?? {};
|
|
194
|
+
}
|