@ansonlai/docx-redline-js 0.7.1 → 0.8.0
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/AGENTS.md +38 -49
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +63 -3
- package/README.md +35 -23
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +7969 -600
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +115 -102
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +27385 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +27376 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +32 -34
- package/docs/AGENT_KNOWLEDGE_BASE.md +408 -380
- package/docs/SKILL_AUTHORING.md +85 -101
- package/docs/TESTING.md +2 -1
- package/docs/validation-reports/2026-09-14-agent-surface-simplification.md +75 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +616 -0
- package/document/zip-archive.js +258 -0
- package/index.d.ts +45 -1
- package/index.js +8 -0
- package/node/cli-help.js +1 -1
- package/node/cli.js +12 -4
- package/node/docx-document.js +11 -355
- package/node/zip-archive.js +28 -38
- package/package.json +16 -11
- package/scripts/build.mjs +40 -5
- package/services/localized-replacement-compiler.js +73 -7
- package/services/revision-token.js +11 -7
- package/services/standalone-operation-runner.d.ts +1 -0
|
@@ -41,6 +41,65 @@ function occurrenceOffsets(sourceText, find) {
|
|
|
41
41
|
return offsets;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function equivalentSpace(left, right) {
|
|
45
|
+
return (left === ' ' || left === '\u00a0')
|
|
46
|
+
&& (right === ' ' || right === '\u00a0');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function spaceEquivalentOffsets(sourceText, find) {
|
|
50
|
+
const offsets = [];
|
|
51
|
+
for (let start = 0; start <= sourceText.length - find.length; start += 1) {
|
|
52
|
+
let matches = true;
|
|
53
|
+
for (let offset = 0; offset < find.length; offset += 1) {
|
|
54
|
+
const sourceCharacter = sourceText[start + offset];
|
|
55
|
+
const findCharacter = find[offset];
|
|
56
|
+
if (sourceCharacter !== findCharacter && !equivalentSpace(sourceCharacter, findCharacter)) {
|
|
57
|
+
matches = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (matches) offsets.push(start);
|
|
62
|
+
}
|
|
63
|
+
return offsets;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve exact offsets first, then the narrow ASCII-space/NBSP equivalent fallback. */
|
|
67
|
+
export function localizedReplacementOffsets(sourceText, find) {
|
|
68
|
+
const exact = occurrenceOffsets(sourceText, find);
|
|
69
|
+
if (exact.length > 0) return { offsets: exact, matchMode: 'exact' };
|
|
70
|
+
return { offsets: spaceEquivalentOffsets(sourceText, find), matchMode: 'space_equivalent' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function preserveEquivalentBoundarySpaces(sourceSlice, find, replace) {
|
|
74
|
+
const effective = replace.split('');
|
|
75
|
+
let prefix = 0;
|
|
76
|
+
while (
|
|
77
|
+
prefix < find.length
|
|
78
|
+
&& prefix < replace.length
|
|
79
|
+
&& (find[prefix] === replace[prefix] || equivalentSpace(find[prefix], replace[prefix]))
|
|
80
|
+
) {
|
|
81
|
+
if (equivalentSpace(sourceSlice[prefix], replace[prefix])) effective[prefix] = sourceSlice[prefix];
|
|
82
|
+
prefix += 1;
|
|
83
|
+
}
|
|
84
|
+
let sourceOffset = find.length - 1;
|
|
85
|
+
let replacementOffset = replace.length - 1;
|
|
86
|
+
while (
|
|
87
|
+
sourceOffset >= prefix
|
|
88
|
+
&& replacementOffset >= prefix
|
|
89
|
+
&& (
|
|
90
|
+
find[sourceOffset] === replace[replacementOffset]
|
|
91
|
+
|| equivalentSpace(find[sourceOffset], replace[replacementOffset])
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
if (equivalentSpace(sourceSlice[sourceOffset], replace[replacementOffset])) {
|
|
95
|
+
effective[replacementOffset] = sourceSlice[sourceOffset];
|
|
96
|
+
}
|
|
97
|
+
sourceOffset -= 1;
|
|
98
|
+
replacementOffset -= 1;
|
|
99
|
+
}
|
|
100
|
+
return effective.join('');
|
|
101
|
+
}
|
|
102
|
+
|
|
44
103
|
function offsetCandidate(sourceText, start, length) {
|
|
45
104
|
const excerptStart = Math.max(0, start - 40);
|
|
46
105
|
const excerptEnd = Math.min(sourceText.length, start + length + 40);
|
|
@@ -168,15 +227,15 @@ export function compileExactReplacements(
|
|
|
168
227
|
const replacement = validation.replacements[index];
|
|
169
228
|
const itemField = `${field}[${index}]`;
|
|
170
229
|
const occurrence = replacement.occurrence ?? null;
|
|
171
|
-
const offsets =
|
|
230
|
+
const { offsets, matchMode } = localizedReplacementOffsets(sourceText, replacement.find);
|
|
172
231
|
if (offsets.length === 0 || (occurrence != null && occurrence > offsets.length)) {
|
|
173
232
|
return {
|
|
174
233
|
ok: false,
|
|
175
234
|
error: patchError(
|
|
176
235
|
'PATCH_SOURCE_NOT_FOUND',
|
|
177
236
|
occurrence == null
|
|
178
|
-
? `
|
|
179
|
-
: `Occurrence ${occurrence} of
|
|
237
|
+
? `Patch source was not found: "${replacement.find}".`
|
|
238
|
+
: `Occurrence ${occurrence} of patch source was not found: "${replacement.find}".`,
|
|
180
239
|
itemField,
|
|
181
240
|
'change_patch',
|
|
182
241
|
{ matchCount: offsets.length }
|
|
@@ -188,7 +247,7 @@ export function compileExactReplacements(
|
|
|
188
247
|
ok: false,
|
|
189
248
|
error: patchError(
|
|
190
249
|
'AMBIGUOUS_PATCH_SOURCE',
|
|
191
|
-
`
|
|
250
|
+
`Patch source matched ${offsets.length} locations; provide occurrence.`,
|
|
192
251
|
itemField,
|
|
193
252
|
'choose_occurrence',
|
|
194
253
|
{ candidates: offsets.map(start => offsetCandidate(sourceText, start, replacement.find.length)) }
|
|
@@ -197,12 +256,17 @@ export function compileExactReplacements(
|
|
|
197
256
|
}
|
|
198
257
|
|
|
199
258
|
const start = offsets[(occurrence || 1) - 1];
|
|
259
|
+
const sourceSlice = sourceText.slice(start, start + replacement.find.length);
|
|
200
260
|
resolved.push({
|
|
201
261
|
requestIndex: index,
|
|
202
262
|
start,
|
|
203
263
|
end: start + replacement.find.length,
|
|
204
264
|
find: replacement.find,
|
|
205
265
|
replace: replacement.replace,
|
|
266
|
+
effectiveReplace: matchMode === 'space_equivalent'
|
|
267
|
+
? preserveEquivalentBoundarySpaces(sourceSlice, replacement.find, replacement.replace)
|
|
268
|
+
: replacement.replace,
|
|
269
|
+
matchMode,
|
|
206
270
|
occurrence: occurrence || 1
|
|
207
271
|
});
|
|
208
272
|
}
|
|
@@ -212,7 +276,7 @@ export function compileExactReplacements(
|
|
|
212
276
|
for (const replacement of resolved) {
|
|
213
277
|
const previous = unique.at(-1);
|
|
214
278
|
if (previous && replacement.start === previous.start && replacement.end === previous.end) {
|
|
215
|
-
if (replacement.
|
|
279
|
+
if (replacement.effectiveReplace !== previous.effectiveReplace) {
|
|
216
280
|
return {
|
|
217
281
|
ok: false,
|
|
218
282
|
error: patchError(
|
|
@@ -245,7 +309,7 @@ export function compileExactReplacements(
|
|
|
245
309
|
let desiredText = '';
|
|
246
310
|
for (const replacement of unique) {
|
|
247
311
|
desiredText += sourceText.slice(cursor, replacement.start);
|
|
248
|
-
desiredText += replacement.
|
|
312
|
+
desiredText += replacement.effectiveReplace;
|
|
249
313
|
cursor = replacement.end;
|
|
250
314
|
}
|
|
251
315
|
desiredText += sourceText.slice(cursor);
|
|
@@ -259,9 +323,10 @@ export function compileExactReplacements(
|
|
|
259
323
|
end: replacement.end,
|
|
260
324
|
find: replacement.find,
|
|
261
325
|
replace: replacement.replace,
|
|
326
|
+
matchMode: replacement.matchMode,
|
|
262
327
|
occurrence: replacement.occurrence,
|
|
263
328
|
removedLength: replacement.end - replacement.start,
|
|
264
|
-
insertedLength: replacement.
|
|
329
|
+
insertedLength: replacement.effectiveReplace.length
|
|
265
330
|
}))
|
|
266
331
|
};
|
|
267
332
|
}
|
|
@@ -280,6 +345,7 @@ export function buildLocalizedReplacementChange(compilation, outputText, target
|
|
|
280
345
|
return {
|
|
281
346
|
find: replacement.find,
|
|
282
347
|
replace: replacement.replace,
|
|
348
|
+
...(replacement.matchMode === 'space_equivalent' ? { matchMode: replacement.matchMode } : {}),
|
|
283
349
|
occurrence: replacement.occurrence,
|
|
284
350
|
beforeExcerpt: boundedExcerpt(sourceText, replacement.start, replacement.end),
|
|
285
351
|
afterExcerpt: boundedExcerpt(desiredText, afterStart, afterEnd)
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* revision tokens over package entries or document parts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { sha256 } from '../core/sha256.js';
|
|
9
|
+
|
|
8
10
|
const textEncoder = new TextEncoder();
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -145,6 +147,8 @@ export function buildRevisionTokenFraming({ scope, entries = [] }) {
|
|
|
145
147
|
/**
|
|
146
148
|
* Calculates a SHA-256 revision token asynchronously using Web Crypto or a custom digest function.
|
|
147
149
|
*
|
|
150
|
+
* Defaults to the built-in pure-JS SHA-256 implementation when Web Crypto is unavailable.
|
|
151
|
+
*
|
|
148
152
|
* @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => Promise<string> }} options
|
|
149
153
|
* @returns {Promise<{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }>}
|
|
150
154
|
*/
|
|
@@ -159,7 +163,7 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
|
|
|
159
163
|
const hashBytes = new Uint8Array(hashBuf);
|
|
160
164
|
hashHex = Array.from(hashBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
161
165
|
} else {
|
|
162
|
-
|
|
166
|
+
hashHex = sha256(framing);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
return {
|
|
@@ -172,17 +176,17 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
|
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/**
|
|
175
|
-
* Calculates a SHA-256 revision token synchronously using a
|
|
179
|
+
* Calculates a SHA-256 revision token synchronously using pure-JS SHA-256 or a custom digest function.
|
|
176
180
|
*
|
|
177
|
-
* @param {{ scope: string, entries: Array<any>, digestFn
|
|
181
|
+
* @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => string }} options
|
|
178
182
|
* @returns {{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }}
|
|
179
183
|
*/
|
|
180
|
-
export function computeRevisionTokenSync({ scope, entries = [], digestFn }) {
|
|
181
|
-
if (typeof digestFn !== 'function') {
|
|
182
|
-
throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn.');
|
|
184
|
+
export function computeRevisionTokenSync({ scope, entries = [], digestFn = null }) {
|
|
185
|
+
if (digestFn != null && typeof digestFn !== 'function') {
|
|
186
|
+
throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn when specified.');
|
|
183
187
|
}
|
|
184
188
|
const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
|
|
185
|
-
const hashHex = digestFn(framing);
|
|
189
|
+
const hashHex = typeof digestFn === 'function' ? digestFn(framing) : sha256(framing);
|
|
186
190
|
return {
|
|
187
191
|
algorithm: 'sha256',
|
|
188
192
|
version,
|