@beyondwork/docx-react-component 1.0.14 → 1.0.15
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 +10 -2
- package/src/api/public-types.ts +6 -0
- package/src/compare/diff-engine.ts +84 -7
- package/src/compare/index.ts +25 -0
- package/src/compare/snapshot.ts +31 -0
- package/src/formats/xlsx/io/serialize-shared-strings.ts +72 -0
- package/src/formats/xlsx/io/serialize-sheet.ts +333 -0
- package/src/formats/xlsx/io/serialize-styles.ts +98 -0
- package/src/formats/xlsx/io/serialize-workbook.ts +429 -0
- package/src/formats/xlsx/runtime/cell-commands.ts +567 -0
- package/src/formats/xlsx/runtime/sheet-commands.ts +206 -0
- package/src/formats/xlsx/runtime/workbook-runtime.ts +177 -0
- package/src/formats/xlsx/runtime/workbook-transaction.ts +822 -0
- package/src/io/ooxml/parse-main-document.ts +6 -6
- package/src/io/ooxml/parse-revisions.ts +18 -24
- package/src/legal/bookmarks.ts +35 -0
- package/src/legal/index.ts +32 -0
- package/src/legal/signature-blocks.ts +259 -0
- package/src/runtime/numbering-prefix.ts +195 -0
- package/src/runtime/surface-projection.ts +287 -8
- package/src/ui/WordReviewEditor.tsx +105 -4
- package/src/ui-tailwind/editor-surface/pm-schema.ts +148 -13
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +15 -29
|
@@ -754,17 +754,17 @@ function readTableGridColumns(node: XmlElementNode): number[] {
|
|
|
754
754
|
* Check if a table's raw XML contains content that cannot safely round-trip
|
|
755
755
|
* through the parsed table path yet. This includes:
|
|
756
756
|
* - Revision markup (tracked changes inside cells)
|
|
757
|
-
* -
|
|
758
|
-
* -
|
|
757
|
+
* - Field syntax and smart tags that still lack a safe table-local live model
|
|
758
|
+
* - Grid geometry tags that the table serializer does not reconstruct yet
|
|
759
759
|
*
|
|
760
760
|
* Tables matching this check stay opaque until the respective features
|
|
761
761
|
* are implemented in the table editing path.
|
|
762
762
|
*/
|
|
763
763
|
function tableRequiresOpaquePreservation(rawXml: string): boolean {
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
return /<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|
|
|
764
|
+
// Safe table-local content now includes hyperlinks, bookmarks, comments,
|
|
765
|
+
// nested tables, floating images, and VML preview atoms because the parser
|
|
766
|
+
// and serializer can preserve them without degrading the whole table.
|
|
767
|
+
return /<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|fldChar|fldSimple|smartTag|gridAfter|gridBefore|tblCellSpacing)\b/.test(rawXml);
|
|
768
768
|
}
|
|
769
769
|
|
|
770
770
|
function readCellGridSpan(node: XmlElementNode): number | undefined {
|
|
@@ -68,7 +68,7 @@ interface RevisionMetadata {
|
|
|
68
68
|
|
|
69
69
|
const SUPPORTED_CONTAINER_TYPES = new Set(["ins", "del"]);
|
|
70
70
|
const PRESERVE_ONLY_CONTAINER_TYPES = new Set(["moveFrom", "moveTo"]);
|
|
71
|
-
const
|
|
71
|
+
const PROPERTY_CHANGE_REVISION_TYPES = new Set(["rPrChange", "pPrChange"]);
|
|
72
72
|
|
|
73
73
|
export function parseRevisionsFromDocumentXml(
|
|
74
74
|
documentXml: string,
|
|
@@ -158,16 +158,17 @@ function parseParagraphMarkRevisions(
|
|
|
158
158
|
|
|
159
159
|
const type = localName(child.name);
|
|
160
160
|
if (type === "pPrChange") {
|
|
161
|
-
const metadata = readRevisionMetadata(child, state, "
|
|
161
|
+
const metadata = readRevisionMetadata(child, state, "property-change");
|
|
162
162
|
const innerPPr = findChildElement(child, "pPr");
|
|
163
|
+
const beforeXml = innerPPr ? state.documentXml.slice(innerPPr.start, innerPPr.end) : "";
|
|
163
164
|
const propertyChangeData: PropertyChangeData = {
|
|
164
165
|
xmlTag: "pPrChange",
|
|
165
|
-
beforeXml
|
|
166
|
+
beforeXml,
|
|
166
167
|
};
|
|
167
168
|
state.revisions.push(
|
|
168
169
|
createRevisionRecord({
|
|
169
170
|
revisionId: metadata.revisionId,
|
|
170
|
-
kind: "
|
|
171
|
+
kind: "property-change",
|
|
171
172
|
anchor: paragraphRange,
|
|
172
173
|
authorId: metadata.authorId,
|
|
173
174
|
createdAt: metadata.createdAt,
|
|
@@ -175,7 +176,6 @@ function parseParagraphMarkRevisions(
|
|
|
175
176
|
source: "import",
|
|
176
177
|
originalRevisionType: "pPrChange",
|
|
177
178
|
ooxmlRevisionId: metadata.ooxmlRevisionId,
|
|
178
|
-
preserveOnlyReason: "Imported preserve-only revision.",
|
|
179
179
|
propertyChangeData,
|
|
180
180
|
},
|
|
181
181
|
}),
|
|
@@ -186,12 +186,9 @@ function parseParagraphMarkRevisions(
|
|
|
186
186
|
xmlStart: child.start,
|
|
187
187
|
xmlEnd: child.end,
|
|
188
188
|
originalRevisionType: "pPrChange",
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
code: "preserve_only_formatting_revision",
|
|
193
|
-
message: "Paragraph property revisions remain preserve-only for Wave 6.",
|
|
194
|
-
featureClass: "preserve-only",
|
|
189
|
+
containerXmlStart: paragraphProperties.start,
|
|
190
|
+
containerXmlEnd: paragraphProperties.end,
|
|
191
|
+
beforeContainerXml: beforeXml,
|
|
195
192
|
});
|
|
196
193
|
}
|
|
197
194
|
}
|
|
@@ -356,7 +353,7 @@ function walkContentNode(
|
|
|
356
353
|
state.diagnostics.push({
|
|
357
354
|
revisionId: metadata.revisionId,
|
|
358
355
|
code: "preserve_only_move_revision",
|
|
359
|
-
message: "Tracked move revisions
|
|
356
|
+
message: "Tracked move revisions are preserve-only. Content is preserved for round-trip but cannot be accepted or rejected.",
|
|
360
357
|
featureClass: "preserve-only",
|
|
361
358
|
});
|
|
362
359
|
advanceCursor(node, setCursor, getCursor);
|
|
@@ -432,21 +429,22 @@ function parseRunFormattingRevisions(
|
|
|
432
429
|
: createDetachedAnchor({ from: runStart, to: runStart }, "importAmbiguity");
|
|
433
430
|
|
|
434
431
|
for (const child of runProperties.children) {
|
|
435
|
-
if (child.type !== "element" || !
|
|
432
|
+
if (child.type !== "element" || !PROPERTY_CHANGE_REVISION_TYPES.has(localName(child.name))) {
|
|
436
433
|
continue;
|
|
437
434
|
}
|
|
438
435
|
|
|
439
436
|
const childLocalName = localName(child.name) as "rPrChange" | "pPrChange";
|
|
440
|
-
const metadata = readRevisionMetadata(child, state, "
|
|
437
|
+
const metadata = readRevisionMetadata(child, state, "property-change");
|
|
441
438
|
const innerRPr = findChildElement(child, "rPr");
|
|
439
|
+
const beforeXml = innerRPr ? state.documentXml.slice(innerRPr.start, innerRPr.end) : "";
|
|
442
440
|
const propertyChangeData: PropertyChangeData = {
|
|
443
441
|
xmlTag: childLocalName === "rPrChange" ? "rPrChange" : "pPrChange",
|
|
444
|
-
beforeXml
|
|
442
|
+
beforeXml,
|
|
445
443
|
};
|
|
446
444
|
state.revisions.push(
|
|
447
445
|
createRevisionRecord({
|
|
448
446
|
revisionId: metadata.revisionId,
|
|
449
|
-
kind: "
|
|
447
|
+
kind: "property-change",
|
|
450
448
|
anchor,
|
|
451
449
|
authorId: metadata.authorId,
|
|
452
450
|
createdAt: metadata.createdAt,
|
|
@@ -454,7 +452,6 @@ function parseRunFormattingRevisions(
|
|
|
454
452
|
source: "import",
|
|
455
453
|
originalRevisionType: childLocalName,
|
|
456
454
|
ooxmlRevisionId: metadata.ooxmlRevisionId,
|
|
457
|
-
preserveOnlyReason: "Imported preserve-only revision.",
|
|
458
455
|
propertyChangeData,
|
|
459
456
|
},
|
|
460
457
|
}),
|
|
@@ -465,12 +462,9 @@ function parseRunFormattingRevisions(
|
|
|
465
462
|
xmlStart: child.start,
|
|
466
463
|
xmlEnd: child.end,
|
|
467
464
|
originalRevisionType: childLocalName,
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
code: "preserve_only_formatting_revision",
|
|
472
|
-
message: "Formatting revisions remain preserve-only for Wave 6.",
|
|
473
|
-
featureClass: "preserve-only",
|
|
465
|
+
containerXmlStart: runProperties.start,
|
|
466
|
+
containerXmlEnd: runProperties.end,
|
|
467
|
+
beforeContainerXml: beforeXml,
|
|
474
468
|
});
|
|
475
469
|
}
|
|
476
470
|
}
|
|
@@ -616,7 +610,7 @@ function containsNestedRevision(node: XmlElementNode): boolean {
|
|
|
616
610
|
child.type === "element" &&
|
|
617
611
|
(SUPPORTED_CONTAINER_TYPES.has(localName(child.name)) ||
|
|
618
612
|
PRESERVE_ONLY_CONTAINER_TYPES.has(localName(child.name)) ||
|
|
619
|
-
|
|
613
|
+
PROPERTY_CHANGE_REVISION_TYPES.has(localName(child.name)) ||
|
|
620
614
|
containsNestedRevision(child)),
|
|
621
615
|
);
|
|
622
616
|
}
|
package/src/legal/bookmarks.ts
CHANGED
|
@@ -167,6 +167,41 @@ export function isHiddenBookmarkName(name: string | undefined): boolean {
|
|
|
167
167
|
return Boolean(name && name.startsWith("_"));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
export interface BookmarkIntegrityResult {
|
|
171
|
+
totalCount: number;
|
|
172
|
+
pairedCount: number;
|
|
173
|
+
startOnlyCount: number;
|
|
174
|
+
endOnlyCount: number;
|
|
175
|
+
hiddenCount: number;
|
|
176
|
+
namedCount: number;
|
|
177
|
+
integrityScore: "complete" | "partial" | "degraded";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function assessBookmarkIntegrity(bookmarks: LegalBookmark[]): BookmarkIntegrityResult {
|
|
181
|
+
const pairedCount = bookmarks.filter((b) => b.status === "paired").length;
|
|
182
|
+
const startOnlyCount = bookmarks.filter((b) => b.status === "start-only").length;
|
|
183
|
+
const endOnlyCount = bookmarks.filter((b) => b.status === "end-only").length;
|
|
184
|
+
const hiddenCount = bookmarks.filter((b) => b.hidden).length;
|
|
185
|
+
const namedCount = bookmarks.filter((b) => b.name && !b.hidden).length;
|
|
186
|
+
const totalCount = bookmarks.length;
|
|
187
|
+
const unpairedCount = startOnlyCount + endOnlyCount;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
totalCount,
|
|
191
|
+
pairedCount,
|
|
192
|
+
startOnlyCount,
|
|
193
|
+
endOnlyCount,
|
|
194
|
+
hiddenCount,
|
|
195
|
+
namedCount,
|
|
196
|
+
integrityScore:
|
|
197
|
+
unpairedCount === 0
|
|
198
|
+
? "complete"
|
|
199
|
+
: unpairedCount <= pairedCount
|
|
200
|
+
? "partial"
|
|
201
|
+
: "degraded",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
function compareBookmarks(left: LegalBookmark, right: LegalBookmark): number {
|
|
171
206
|
return (
|
|
172
207
|
(left.startIndex ?? left.endIndex ?? Number.MAX_SAFE_INTEGER) -
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export {
|
|
2
|
+
parseBookmarksFromDocumentXml,
|
|
3
|
+
collectBookmarksFromCanonicalDocument,
|
|
4
|
+
assessBookmarkIntegrity,
|
|
5
|
+
isHiddenBookmarkName,
|
|
6
|
+
type BookmarkIntegrityResult,
|
|
7
|
+
type LegalBookmark,
|
|
8
|
+
} from "./bookmarks.ts";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
parseCrossReferencesFromDocumentXml,
|
|
12
|
+
collectCrossReferencesFromCanonicalDocument,
|
|
13
|
+
detectCrossReferencePatterns,
|
|
14
|
+
parseFieldReferenceInstruction,
|
|
15
|
+
type CrossReference,
|
|
16
|
+
type CrossReferencePattern,
|
|
17
|
+
} from "./cross-references.ts";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
collectDefinedTermsFromDocumentXml,
|
|
21
|
+
collectDefinedTermsFromCanonicalDocument,
|
|
22
|
+
buildDefinedTermCatalog,
|
|
23
|
+
type DefinedTerm,
|
|
24
|
+
type DefinedTermOccurrence,
|
|
25
|
+
} from "./defined-terms.ts";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
detectSignatureBlocksFromCanonicalDocument,
|
|
29
|
+
type SignatureBlockCandidate,
|
|
30
|
+
type SignatureBlockReport,
|
|
31
|
+
type SignatureParty,
|
|
32
|
+
} from "./signature-blocks.ts";
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlockNode,
|
|
3
|
+
CanonicalDocument,
|
|
4
|
+
DocumentNode,
|
|
5
|
+
ParagraphNode,
|
|
6
|
+
} from "../model/canonical-document.ts";
|
|
7
|
+
|
|
8
|
+
export interface SignatureBlockCandidate {
|
|
9
|
+
startIndex: number;
|
|
10
|
+
endIndex: number;
|
|
11
|
+
kind: "execution-block" | "witness-block" | "notary-block";
|
|
12
|
+
parties: SignatureParty[];
|
|
13
|
+
confidence: "high" | "medium";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SignatureParty {
|
|
17
|
+
roleLabel?: string;
|
|
18
|
+
nameLabel?: string;
|
|
19
|
+
paragraphIndex: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SignatureBlockReport {
|
|
23
|
+
reportVersion: "signature-block-report/1";
|
|
24
|
+
candidates: SignatureBlockCandidate[];
|
|
25
|
+
preservationSafe: boolean;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EXECUTION_TRIGGER_PATTERN =
|
|
30
|
+
/\b(?:IN WITNESS WHEREOF|EXECUTED|AGREED AND ACCEPTED|SIGNATURES FOLLOW)\b/i;
|
|
31
|
+
const ROLE_LINE_PATTERN =
|
|
32
|
+
/^\s*(?:By|Name|Title|Date|Witness|Authorized Signatory|Signature)\s*[:\-]?\s*/i;
|
|
33
|
+
const PARTY_LABEL_PATTERN =
|
|
34
|
+
/^(?:(?:The |)[A-Z][A-Za-z0-9 ,.'&()-]{2,60})\s*$/;
|
|
35
|
+
const WITNESS_PATTERN = /\bWITNESS(?:ED|ES)?\b/i;
|
|
36
|
+
const NOTARY_PATTERN = /\bNOTARY\s+PUBLIC\b/i;
|
|
37
|
+
const UNDERLINE_PLACEHOLDER_PATTERN = /_{4,}|\.{4,}/;
|
|
38
|
+
|
|
39
|
+
export function detectSignatureBlocksFromCanonicalDocument(
|
|
40
|
+
document: Pick<CanonicalDocument, "content" | "preservation"> | DocumentNode,
|
|
41
|
+
): SignatureBlockReport {
|
|
42
|
+
const root = "content" in document ? document.content : document;
|
|
43
|
+
const paragraphs: Array<{ text: string; node: ParagraphNode }> = [];
|
|
44
|
+
const warnings: string[] = [];
|
|
45
|
+
|
|
46
|
+
walkBlocks(root, (node) => {
|
|
47
|
+
if (node.type === "paragraph") {
|
|
48
|
+
paragraphs.push({ text: flattenParagraphText(node), node });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const candidates = findSignatureBlockCandidates(paragraphs);
|
|
53
|
+
|
|
54
|
+
const hasPreservation =
|
|
55
|
+
"preservation" in document &&
|
|
56
|
+
document.preservation !== undefined;
|
|
57
|
+
const preservationSafe = hasPreservation
|
|
58
|
+
? !hasOpaqueFragmentsInSignatureRanges(
|
|
59
|
+
document as Pick<CanonicalDocument, "content" | "preservation">,
|
|
60
|
+
candidates,
|
|
61
|
+
)
|
|
62
|
+
: true;
|
|
63
|
+
|
|
64
|
+
if (!preservationSafe) {
|
|
65
|
+
warnings.push(
|
|
66
|
+
"One or more signature block regions overlap with preserve-only opaque fragments. Export fidelity may be degraded.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
reportVersion: "signature-block-report/1",
|
|
72
|
+
candidates,
|
|
73
|
+
preservationSafe,
|
|
74
|
+
warnings,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findSignatureBlockCandidates(
|
|
79
|
+
paragraphs: ReadonlyArray<{ text: string; node: ParagraphNode }>,
|
|
80
|
+
): SignatureBlockCandidate[] {
|
|
81
|
+
const candidates: SignatureBlockCandidate[] = [];
|
|
82
|
+
let index = 0;
|
|
83
|
+
|
|
84
|
+
while (index < paragraphs.length) {
|
|
85
|
+
const text = paragraphs[index].text;
|
|
86
|
+
|
|
87
|
+
if (EXECUTION_TRIGGER_PATTERN.test(text)) {
|
|
88
|
+
const block = scanSignatureBlock(paragraphs, index);
|
|
89
|
+
if (block) {
|
|
90
|
+
candidates.push(block);
|
|
91
|
+
index = block.endIndex + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
index += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return candidates;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function scanSignatureBlock(
|
|
103
|
+
paragraphs: ReadonlyArray<{ text: string; node: ParagraphNode }>,
|
|
104
|
+
triggerIndex: number,
|
|
105
|
+
): SignatureBlockCandidate | undefined {
|
|
106
|
+
const parties: SignatureParty[] = [];
|
|
107
|
+
let endIndex = triggerIndex;
|
|
108
|
+
let kind: SignatureBlockCandidate["kind"] = "execution-block";
|
|
109
|
+
let hasSignatureContent = false;
|
|
110
|
+
|
|
111
|
+
for (
|
|
112
|
+
let index = triggerIndex + 1;
|
|
113
|
+
index < paragraphs.length && index <= triggerIndex + 40;
|
|
114
|
+
index += 1
|
|
115
|
+
) {
|
|
116
|
+
const text = paragraphs[index].text.trim();
|
|
117
|
+
|
|
118
|
+
if (text.length === 0) {
|
|
119
|
+
endIndex = index;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (WITNESS_PATTERN.test(text)) {
|
|
124
|
+
kind = "witness-block";
|
|
125
|
+
}
|
|
126
|
+
if (NOTARY_PATTERN.test(text)) {
|
|
127
|
+
kind = "notary-block";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (ROLE_LINE_PATTERN.test(text)) {
|
|
131
|
+
hasSignatureContent = true;
|
|
132
|
+
endIndex = index;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (UNDERLINE_PLACEHOLDER_PATTERN.test(text)) {
|
|
137
|
+
hasSignatureContent = true;
|
|
138
|
+
endIndex = index;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (PARTY_LABEL_PATTERN.test(text) && !isBodyParagraph(text)) {
|
|
143
|
+
parties.push({
|
|
144
|
+
roleLabel: text.trim(),
|
|
145
|
+
paragraphIndex: index,
|
|
146
|
+
});
|
|
147
|
+
hasSignatureContent = true;
|
|
148
|
+
endIndex = index;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (hasSignatureContent && isBodyParagraph(text)) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
endIndex = index;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!hasSignatureContent) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
startIndex: triggerIndex,
|
|
165
|
+
endIndex,
|
|
166
|
+
kind,
|
|
167
|
+
parties,
|
|
168
|
+
confidence: parties.length > 0 ? "high" : "medium",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isBodyParagraph(text: string): boolean {
|
|
173
|
+
return text.length > 120 && /[.;]/.test(text);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function hasOpaqueFragmentsInSignatureRanges(
|
|
177
|
+
document: Pick<CanonicalDocument, "content" | "preservation">,
|
|
178
|
+
candidates: readonly SignatureBlockCandidate[],
|
|
179
|
+
): boolean {
|
|
180
|
+
if (candidates.length === 0) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const opaqueCount = Object.keys(document.preservation.opaqueFragments).length;
|
|
185
|
+
if (opaqueCount === 0) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let paragraphIndex = 0;
|
|
190
|
+
const opaqueIndices = new Set<number>();
|
|
191
|
+
|
|
192
|
+
for (const block of document.content.children) {
|
|
193
|
+
if (block.type === "paragraph") {
|
|
194
|
+
paragraphIndex += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (block.type === "opaque_block") {
|
|
198
|
+
opaqueIndices.add(paragraphIndex);
|
|
199
|
+
}
|
|
200
|
+
paragraphIndex += 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return candidates.some((candidate) => {
|
|
204
|
+
for (let index = candidate.startIndex; index <= candidate.endIndex; index += 1) {
|
|
205
|
+
if (opaqueIndices.has(index)) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function flattenParagraphText(paragraph: ParagraphNode): string {
|
|
214
|
+
return paragraph.children
|
|
215
|
+
.map((child) => {
|
|
216
|
+
switch (child.type) {
|
|
217
|
+
case "text":
|
|
218
|
+
return child.text;
|
|
219
|
+
case "hyperlink":
|
|
220
|
+
case "field":
|
|
221
|
+
return child.children
|
|
222
|
+
.map((nested) =>
|
|
223
|
+
nested.type === "text" ? nested.text : nested.type === "tab" ? "\t" : "",
|
|
224
|
+
)
|
|
225
|
+
.join("");
|
|
226
|
+
case "tab":
|
|
227
|
+
return "\t";
|
|
228
|
+
case "hard_break":
|
|
229
|
+
case "column_break":
|
|
230
|
+
return "\n";
|
|
231
|
+
default:
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
.join("");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function walkBlocks(
|
|
239
|
+
node: DocumentNode,
|
|
240
|
+
visit: (node: DocumentNode) => void,
|
|
241
|
+
): void {
|
|
242
|
+
visit(node);
|
|
243
|
+
|
|
244
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
245
|
+
for (const child of node.children) {
|
|
246
|
+
walkBlocks(child, visit);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (node.type === "table") {
|
|
251
|
+
for (const row of node.rows) {
|
|
252
|
+
walkBlocks(row, visit);
|
|
253
|
+
}
|
|
254
|
+
} else if (node.type === "table_row") {
|
|
255
|
+
for (const cell of node.cells) {
|
|
256
|
+
walkBlocks(cell, visit);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NumberingCatalog,
|
|
3
|
+
NumberingLevelDefinition,
|
|
4
|
+
ParagraphNode,
|
|
5
|
+
} from "../model/canonical-document.ts";
|
|
6
|
+
|
|
7
|
+
interface NumberingSequenceState {
|
|
8
|
+
counters: Array<number | undefined>;
|
|
9
|
+
lastLevel: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NumberingPrefixResolver {
|
|
13
|
+
resolve(numbering: ParagraphNode["numbering"] | undefined): string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_START_AT = 1;
|
|
17
|
+
|
|
18
|
+
export function createNumberingPrefixResolver(
|
|
19
|
+
catalog: NumberingCatalog,
|
|
20
|
+
): NumberingPrefixResolver {
|
|
21
|
+
const sequenceStates = new Map<string, NumberingSequenceState>();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
resolve(numbering) {
|
|
25
|
+
if (!numbering) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const instance = catalog.instances[numbering.numberingInstanceId];
|
|
30
|
+
if (!instance) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const definition = catalog.abstractDefinitions[instance.abstractNumberingId];
|
|
35
|
+
if (!definition) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const levelDefinitions = new Map(
|
|
40
|
+
definition.levels.map((level) => [level.level, level] as const),
|
|
41
|
+
);
|
|
42
|
+
const levelDefinition = levelDefinitions.get(numbering.level);
|
|
43
|
+
if (!levelDefinition || levelDefinition.format === "none") {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sequenceState = getSequenceState(sequenceStates, numbering.numberingInstanceId);
|
|
48
|
+
advanceSequence(sequenceState, numbering.level, levelDefinitions, instance.overrides);
|
|
49
|
+
|
|
50
|
+
return renderLevelText(levelDefinition.text, sequenceState.counters, levelDefinitions);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getSequenceState(
|
|
56
|
+
states: Map<string, NumberingSequenceState>,
|
|
57
|
+
numberingInstanceId: string,
|
|
58
|
+
): NumberingSequenceState {
|
|
59
|
+
const existing = states.get(numberingInstanceId);
|
|
60
|
+
if (existing) {
|
|
61
|
+
return existing;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const created: NumberingSequenceState = {
|
|
65
|
+
counters: [],
|
|
66
|
+
lastLevel: null,
|
|
67
|
+
};
|
|
68
|
+
states.set(numberingInstanceId, created);
|
|
69
|
+
return created;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function advanceSequence(
|
|
73
|
+
state: NumberingSequenceState,
|
|
74
|
+
currentLevel: number,
|
|
75
|
+
levelDefinitions: Map<number, NumberingLevelDefinition>,
|
|
76
|
+
overrides: NumberingCatalog["instances"][string]["overrides"],
|
|
77
|
+
): void {
|
|
78
|
+
if (state.lastLevel !== null && currentLevel <= state.lastLevel) {
|
|
79
|
+
state.counters.length = currentLevel + 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const startAt = getLevelStartAt(currentLevel, levelDefinitions, overrides);
|
|
83
|
+
const currentValue = state.counters[currentLevel];
|
|
84
|
+
state.counters[currentLevel] =
|
|
85
|
+
currentValue === undefined ? startAt : currentValue + 1;
|
|
86
|
+
state.lastLevel = currentLevel;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getLevelStartAt(
|
|
90
|
+
level: number,
|
|
91
|
+
levelDefinitions: Map<number, NumberingLevelDefinition>,
|
|
92
|
+
overrides: NumberingCatalog["instances"][string]["overrides"],
|
|
93
|
+
): number {
|
|
94
|
+
const override = overrides.find((entry) => entry.level === level);
|
|
95
|
+
if (override?.startAt !== undefined) {
|
|
96
|
+
return override.startAt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return levelDefinitions.get(level)?.startAt ?? DEFAULT_START_AT;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderLevelText(
|
|
103
|
+
text: string,
|
|
104
|
+
counters: Array<number | undefined>,
|
|
105
|
+
levelDefinitions: Map<number, NumberingLevelDefinition>,
|
|
106
|
+
): string | null {
|
|
107
|
+
if (!text) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!text.includes("%")) {
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rendered = text.replace(/%([1-9])/g, (_match, token) => {
|
|
116
|
+
const level = Number.parseInt(token, 10) - 1;
|
|
117
|
+
const counter = counters[level];
|
|
118
|
+
if (counter === undefined) {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const format = levelDefinitions.get(level)?.format ?? "decimal";
|
|
123
|
+
return formatCounter(counter, format);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return rendered.trim().length > 0 ? rendered : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatCounter(value: number, format: string): string {
|
|
130
|
+
switch (format) {
|
|
131
|
+
case "decimal":
|
|
132
|
+
return String(value);
|
|
133
|
+
case "decimalZero":
|
|
134
|
+
return String(value).padStart(2, "0");
|
|
135
|
+
case "upperLetter":
|
|
136
|
+
return toAlphabetic(value).toUpperCase();
|
|
137
|
+
case "lowerLetter":
|
|
138
|
+
return toAlphabetic(value).toLowerCase();
|
|
139
|
+
case "upperRoman":
|
|
140
|
+
return toRoman(value).toUpperCase();
|
|
141
|
+
case "lowerRoman":
|
|
142
|
+
return toRoman(value).toLowerCase();
|
|
143
|
+
case "none":
|
|
144
|
+
return "";
|
|
145
|
+
default:
|
|
146
|
+
return String(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toAlphabetic(value: number): string {
|
|
151
|
+
if (value <= 0) {
|
|
152
|
+
return String(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let remainder = value;
|
|
156
|
+
let result = "";
|
|
157
|
+
while (remainder > 0) {
|
|
158
|
+
remainder -= 1;
|
|
159
|
+
result = String.fromCharCode(65 + (remainder % 26)) + result;
|
|
160
|
+
remainder = Math.floor(remainder / 26);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toRoman(value: number): string {
|
|
166
|
+
if (value <= 0 || value >= 4000) {
|
|
167
|
+
return String(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const numerals: Array<[number, string]> = [
|
|
171
|
+
[1000, "M"],
|
|
172
|
+
[900, "CM"],
|
|
173
|
+
[500, "D"],
|
|
174
|
+
[400, "CD"],
|
|
175
|
+
[100, "C"],
|
|
176
|
+
[90, "XC"],
|
|
177
|
+
[50, "L"],
|
|
178
|
+
[40, "XL"],
|
|
179
|
+
[10, "X"],
|
|
180
|
+
[9, "IX"],
|
|
181
|
+
[5, "V"],
|
|
182
|
+
[4, "IV"],
|
|
183
|
+
[1, "I"],
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
let remaining = value;
|
|
187
|
+
let result = "";
|
|
188
|
+
for (const [amount, numeral] of numerals) {
|
|
189
|
+
while (remaining >= amount) {
|
|
190
|
+
remaining -= amount;
|
|
191
|
+
result += numeral;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|