web-doc 0.7.0 → 0.9.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.
Files changed (65) hide show
  1. package/THIRD_PARTY_NOTICES.md +10 -3
  2. package/dist/contracts.d.ts +6 -0
  3. package/dist/edit/ai/outline.d.ts +47 -0
  4. package/dist/edit/ai/outline.js +338 -0
  5. package/dist/edit/ai/targets.d.ts +16 -0
  6. package/dist/edit/ai/targets.js +309 -0
  7. package/dist/edit/ai/tools.d.ts +28 -0
  8. package/dist/edit/ai/tools.js +607 -0
  9. package/dist/edit/ai/types.d.ts +175 -0
  10. package/dist/edit/ai/types.js +1 -0
  11. package/dist/edit/docx/elements.js +19 -1
  12. package/dist/edit/docx/engine.d.ts +10 -4
  13. package/dist/edit/docx/engine.js +80 -6
  14. package/dist/edit/docx/model.d.ts +2 -0
  15. package/dist/edit/docx/model.js +11 -0
  16. package/dist/edit/docx/operations.d.ts +10 -0
  17. package/dist/edit/docx/provider.d.ts +4 -1
  18. package/dist/edit/docx/provider.js +3 -0
  19. package/dist/edit/docx/session.d.ts +12 -1
  20. package/dist/edit/docx/session.js +41 -0
  21. package/dist/edit/docx/structure-ops.js +39 -4
  22. package/dist/edit/docx/table-ops.js +26 -4
  23. package/dist/edit/docx/text-ops.d.ts +22 -0
  24. package/dist/edit/docx/text-ops.js +49 -16
  25. package/dist/edit/docx/text.js +21 -0
  26. package/dist/edit/docx/tracked.d.ts +52 -0
  27. package/dist/edit/docx/tracked.js +347 -0
  28. package/dist/edit/docx/types.d.ts +24 -1
  29. package/dist/edit/docx/write.d.ts +19 -5
  30. package/dist/edit/docx/write.js +31 -8
  31. package/dist/edit/engine.d.ts +14 -3
  32. package/dist/edit/history.d.ts +27 -3
  33. package/dist/edit/history.js +29 -7
  34. package/dist/edit/pdf/engine/document.js +48 -3
  35. package/dist/edit/pdf/engine/elements.d.ts +6 -0
  36. package/dist/edit/pdf/engine/fonts.js +13 -10
  37. package/dist/edit/pdf/engine/pages.js +9 -1
  38. package/dist/edit/pdf/range-map.d.ts +4 -2
  39. package/dist/edit/pdf/schemas.js +4 -1
  40. package/dist/edit/pdf/session.d.ts +10 -0
  41. package/dist/edit/pdf/session.js +39 -0
  42. package/dist/edit/pdf/types.d.ts +15 -3
  43. package/dist/edit/pptx/handler.js +10 -2
  44. package/dist/edit/pptx/session.d.ts +10 -0
  45. package/dist/edit/pptx/session.js +31 -0
  46. package/dist/edit/session.d.ts +11 -0
  47. package/dist/edit/session.js +269 -27
  48. package/dist/edit/types.d.ts +17 -3
  49. package/dist/edit/worker-engine.d.ts +2 -2
  50. package/dist/edit/worker-engine.js +2 -2
  51. package/dist/fonts/THIRD_PARTY_NOTICES.md +6 -3
  52. package/dist/fonts/manifest.json +4 -4
  53. package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
  54. package/dist/fuzzy-alignment.d.ts +11 -4
  55. package/dist/fuzzy-alignment.js +3 -9
  56. package/dist/headless.d.ts +1 -1
  57. package/dist/headless.js +4 -1
  58. package/dist/index.d.ts +5 -2
  59. package/dist/index.js +11 -2
  60. package/dist/limits.js +3 -0
  61. package/dist/worker-protocol.d.ts +1 -1
  62. package/dist/workers/fuzzy-search-worker.js +1 -1
  63. package/dist/workers/ooxml-edit-worker.js +1415 -860
  64. package/dist/workers/pdf-edit-worker.js +64 -16
  65. package/package.json +1 -1
@@ -0,0 +1,309 @@
1
+ import { alignFuzzyPassage } from "../../fuzzy-alignment.js";
2
+ import { DEFAULT_FUZZY_SEARCH_OPTIONS, FuzzyPageIndex, } from "../../fuzzy-search.js";
3
+ import { graphemeSegments, normalizeSearchText } from "../../search-text.js";
4
+ import { foldText } from "./outline.js";
5
+ /*
6
+ * A model names what it wants to edit by quoting it ("the sentence that
7
+ * starts with…"), by citing it with a page, or by kind ("the first table on
8
+ * slide 3"). Resolution turns that into element ids and ranges in named
9
+ * steps, so a host knows how much to trust a candidate: the engine's exact
10
+ * search first, then a match with whitespace, case, quotes and compatibility
11
+ * forms folded, then the viewer's fuzzy citation matching over the
12
+ * elements' text, and a kind lookup when there is no text at all.
13
+ */
14
+ const DEFAULT_MAX_RESULTS = 5;
15
+ const SNIPPET_CONTEXT = 40;
16
+ /** Fuzzy candidates score from this down to 0.5 at the loosest accepted edit ratio. */
17
+ const FUZZY_TOP = 0.85;
18
+ export async function resolveTargets(session, query, options = {}) {
19
+ const needle = (query.text ?? query.citation?.text ?? "").trim();
20
+ const hint = query.citation?.pageNumber !== undefined &&
21
+ Number.isFinite(query.citation.pageNumber)
22
+ ? Math.max(0, Math.trunc(query.citation.pageNumber) - 1)
23
+ : undefined;
24
+ const limit = Math.max(1, Math.trunc(query.maxResults ?? DEFAULT_MAX_RESULTS));
25
+ const readOptions = options.signal ? { signal: options.signal } : {};
26
+ const elementQuery = query.pageIndex === undefined ? {} : { pageIndex: query.pageIndex };
27
+ // The two reads queue one after the other; a change landing between them
28
+ // would make the elements stale, so they are read again until they agree.
29
+ let elements;
30
+ let found;
31
+ for (let attempt = 0;; attempt += 1) {
32
+ elements = await session.getElements(elementQuery, readOptions);
33
+ found = needle
34
+ ? await session.findText(needle, {
35
+ ...readOptions,
36
+ ...(query.pageIndex === undefined
37
+ ? {}
38
+ : { pageRange: [query.pageIndex, query.pageIndex] }),
39
+ })
40
+ : undefined;
41
+ if (!found || found.revision === elements.revision || attempt >= 2)
42
+ break;
43
+ }
44
+ const envelope = {
45
+ sessionId: (found ?? elements).sessionId,
46
+ revision: (found ?? elements).revision,
47
+ };
48
+ const allowed = allowedElements(elements.items, query);
49
+ const byId = new Map(allowed.map((element) => [element.id, element]));
50
+ let candidates = [];
51
+ if (needle && found) {
52
+ candidates = exactCandidates(found.items, byId);
53
+ if (candidates.length === 0) {
54
+ const corpus = buildCorpus(allowed);
55
+ candidates = normalizedCandidates(corpus, needle);
56
+ if (candidates.length === 0)
57
+ candidates = fuzzyCandidates(corpus, needle);
58
+ }
59
+ }
60
+ else if (!needle && query.kinds && query.kinds.length > 0) {
61
+ candidates = allowed.map((element) => ({
62
+ elementId: element.id,
63
+ pageIndex: element.pageIndex,
64
+ score: 0.5,
65
+ reason: "kind-only",
66
+ snippet: snippetOf(element.text ?? "", 0, 0),
67
+ }));
68
+ }
69
+ if (hint !== undefined)
70
+ candidates = candidates
71
+ .map((candidate, index) => ({ candidate, index }))
72
+ .sort((a, b) => distance(a.candidate.pageIndex, hint) -
73
+ distance(b.candidate.pageIndex, hint) || a.index - b.index)
74
+ .map((entry) => entry.candidate);
75
+ return Object.freeze({
76
+ ...envelope,
77
+ items: Object.freeze(candidates.slice(0, limit).map((candidate) => Object.freeze(candidate))),
78
+ });
79
+ }
80
+ function distance(pageIndex, hint) {
81
+ return pageIndex < 0 ? Number.POSITIVE_INFINITY : Math.abs(pageIndex - hint);
82
+ }
83
+ /** The elements a query may name: by kind, inside `within`, on the page. */
84
+ function allowedElements(elements, query) {
85
+ let allowed = elements;
86
+ if (query.within !== undefined) {
87
+ const inside = new Set([query.within]);
88
+ // Children follow their parents in reading order, so one pass closes
89
+ // the set; a second catches a child listed before its parent.
90
+ for (let pass = 0; pass < 2; pass += 1)
91
+ for (const element of elements)
92
+ if (element.parentId !== undefined && inside.has(element.parentId))
93
+ inside.add(element.id);
94
+ allowed = allowed.filter((element) => inside.has(element.id));
95
+ }
96
+ if (query.kinds) {
97
+ const kinds = new Set(query.kinds);
98
+ allowed = allowed.filter((element) => kinds.has(element.kind));
99
+ }
100
+ return allowed;
101
+ }
102
+ function exactCandidates(targets, byId) {
103
+ const out = [];
104
+ for (const target of targets) {
105
+ if (target.elementIds.length === 0)
106
+ continue;
107
+ if (!target.elementIds.every((id) => byId.has(id)))
108
+ continue;
109
+ const first = target.ranges[0];
110
+ const last = target.ranges.at(-1);
111
+ const element = byId.get(target.elementIds[0]);
112
+ const range = first && last ? { start: first.start, end: last.end } : undefined;
113
+ out.push({
114
+ elementId: element.id,
115
+ ...(range && !coversWhole(range, element) ? { range } : {}),
116
+ pageIndex: target.pageIndex,
117
+ score: 1,
118
+ reason: "exact",
119
+ snippet: range &&
120
+ element.text !== undefined &&
121
+ range.start.elementId === element.id
122
+ ? snippetOf(element.text, range.start.offset, range.end.elementId === element.id
123
+ ? range.end.offset
124
+ : element.text.length)
125
+ : foldText(target.text),
126
+ });
127
+ }
128
+ return out;
129
+ }
130
+ function coversWhole(range, element) {
131
+ return (range.start.elementId === element.id &&
132
+ range.end.elementId === element.id &&
133
+ range.start.offset === 0 &&
134
+ range.end.offset === (element.text?.length ?? 0));
135
+ }
136
+ /**
137
+ * The elements' text in reading order, cut into chunks that never span a
138
+ * page. A container whose children carry text (a Word table, whose cells are
139
+ * paragraphs) contributes nothing itself, so a match names the cell.
140
+ */
141
+ function buildCorpus(elements) {
142
+ const textChildren = new Set();
143
+ for (const element of elements)
144
+ if (element.parentId !== undefined && element.text)
145
+ textChildren.add(element.parentId);
146
+ const chunks = [];
147
+ let pageIndex;
148
+ let text = "";
149
+ let segments = [];
150
+ const flush = () => {
151
+ if (segments.length > 0 && pageIndex !== undefined)
152
+ chunks.push({ pageIndex, text, segments });
153
+ text = "";
154
+ segments = [];
155
+ };
156
+ const budget = DEFAULT_FUZZY_SEARCH_OPTIONS.maxPageTextLength;
157
+ for (const element of elements) {
158
+ if (!element.text || textChildren.has(element.id))
159
+ continue;
160
+ if (pageIndex !== element.pageIndex ||
161
+ text.length + 1 + element.text.length > budget) {
162
+ flush();
163
+ pageIndex = element.pageIndex;
164
+ }
165
+ const start = text.length === 0 ? 0 : text.length + 1;
166
+ text = text.length === 0 ? element.text : `${text} ${element.text}`;
167
+ segments.push({ element, start, end: start + element.text.length });
168
+ }
169
+ flush();
170
+ return chunks;
171
+ }
172
+ /** Whitespace runs to one space, quotes and dashes to ASCII, NFKC, case folded; offsets kept. */
173
+ export function foldWithMap(text) {
174
+ const out = [];
175
+ const starts = [];
176
+ const ends = [];
177
+ let spaceStart = -1;
178
+ let spaceEnd = -1;
179
+ for (const segment of graphemeSegments(text)) {
180
+ const value = foldPunctuation(normalizeSearchText(segment.value));
181
+ if (value.length === 0)
182
+ continue;
183
+ if (/^\s+$/u.test(value)) {
184
+ if (out.length === 0)
185
+ continue;
186
+ if (spaceStart < 0)
187
+ spaceStart = segment.start;
188
+ spaceEnd = segment.end;
189
+ continue;
190
+ }
191
+ if (spaceStart >= 0) {
192
+ out.push(" ");
193
+ starts.push(spaceStart);
194
+ ends.push(spaceEnd);
195
+ spaceStart = -1;
196
+ }
197
+ for (const char of value) {
198
+ out.push(char);
199
+ starts.push(segment.start);
200
+ ends.push(segment.end);
201
+ }
202
+ }
203
+ return { text: out.join(""), starts, ends };
204
+ }
205
+ const PUNCTUATION = {
206
+ "‘": "'",
207
+ "’": "'",
208
+ "‚": "'",
209
+ "‛": "'",
210
+ "′": "'",
211
+ "“": '"',
212
+ "”": '"',
213
+ "„": '"',
214
+ "‟": '"',
215
+ "″": '"',
216
+ "«": '"',
217
+ "»": '"',
218
+ "‐": "-",
219
+ "‑": "-",
220
+ "‒": "-",
221
+ "–": "-",
222
+ "—": "-",
223
+ "―": "-",
224
+ "−": "-",
225
+ "…": "...",
226
+ " ": " ",
227
+ };
228
+ function foldPunctuation(value) {
229
+ let out = "";
230
+ for (const char of value)
231
+ out += PUNCTUATION[char] ?? char;
232
+ return out;
233
+ }
234
+ function normalizedCandidates(corpus, needle) {
235
+ const pattern = foldWithMap(needle).text;
236
+ if (pattern.length === 0)
237
+ return [];
238
+ const out = [];
239
+ for (const chunk of corpus) {
240
+ const folded = foldWithMap(chunk.text);
241
+ let from = 0;
242
+ while (from <= folded.text.length - pattern.length) {
243
+ const at = folded.text.indexOf(pattern, from);
244
+ if (at < 0)
245
+ break;
246
+ const start = folded.starts[at];
247
+ const end = folded.ends[at + pattern.length - 1];
248
+ const candidate = candidateOf(chunk, start, end, 0.9, "normalized");
249
+ if (candidate)
250
+ out.push(candidate);
251
+ from = at + Math.max(1, pattern.length);
252
+ }
253
+ }
254
+ return out;
255
+ }
256
+ function fuzzyCandidates(corpus, needle) {
257
+ if (corpus.length === 0)
258
+ return [];
259
+ const fuzzy = DEFAULT_FUZZY_SEARCH_OPTIONS;
260
+ const pattern = needle.slice(0, fuzzy.maxQueryLength);
261
+ const pages = corpus.map((chunk, index) => ({
262
+ pageIndex: index,
263
+ text: chunk.text,
264
+ }));
265
+ const index = new FuzzyPageIndex(pages, fuzzy, false);
266
+ const out = [];
267
+ for (const match of index.search(pattern, fuzzy.maxScore)) {
268
+ const chunk = corpus[match.pageIndex];
269
+ const passage = alignFuzzyPassage(chunk.text, pattern, false, fuzzy.maxScore);
270
+ if (!passage)
271
+ continue;
272
+ const ratio = passage.length === 0 ? 0 : passage.cost / passage.length;
273
+ const score = FUZZY_TOP - (FUZZY_TOP - 0.5) * Math.min(1, ratio / fuzzy.maxScore);
274
+ const candidate = candidateOf(chunk, passage.start, passage.end, Math.round(score * 100) / 100, "fuzzy");
275
+ if (candidate)
276
+ out.push(candidate);
277
+ }
278
+ return out;
279
+ }
280
+ /** The candidate for a span of a chunk: the first element it touches, with a range unless it covers the whole element. */
281
+ function candidateOf(chunk, start, end, score, reason) {
282
+ const touched = chunk.segments.filter((segment) => segment.end > start && segment.start < end);
283
+ const first = touched[0];
284
+ const last = touched.at(-1);
285
+ if (!first || !last)
286
+ return undefined;
287
+ const startOffset = Math.max(0, start - first.start);
288
+ const endOffset = Math.min(last.end, end) - last.start;
289
+ const range = {
290
+ start: { elementId: first.element.id, offset: startOffset },
291
+ end: { elementId: last.element.id, offset: endOffset },
292
+ };
293
+ return {
294
+ elementId: first.element.id,
295
+ ...(coversWhole(range, first.element) ? {} : { range }),
296
+ pageIndex: first.element.pageIndex,
297
+ score,
298
+ reason,
299
+ snippet: snippetOf(chunk.text, start, end),
300
+ };
301
+ }
302
+ /** The matched text with a little context on each side, on one line. */
303
+ export function snippetOf(text, start, end) {
304
+ const from = Math.max(0, start - SNIPPET_CONTEXT);
305
+ const to = Math.min(text.length, end + SNIPPET_CONTEXT);
306
+ const head = from > 0 ? "…" : "";
307
+ const tail = to < text.length ? "…" : "";
308
+ return `${head}${foldText(text.slice(from, to))}${tail}`;
309
+ }
@@ -0,0 +1,28 @@
1
+ import type { ApplyOptions, EditableFormat, EditElement, EditOperation, EditReceipt, EditState, HistoryOptions, OperationSchemaSet, ReadItem, ReadOptions, ReadResult } from "../types.js";
2
+ import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "./types.js";
3
+ export declare const TOOL_SET_VERSION = 1;
4
+ /** What the tools need of a session. */
5
+ export interface ToolSource {
6
+ readonly format: EditableFormat;
7
+ readonly state: EditState;
8
+ readonly schemas: OperationSchemaSet;
9
+ describe(options?: DescribeOptions): Promise<ReadItem<DocumentDescription>>;
10
+ getOutline(options?: OutlineOptions): Promise<OutlineResult>;
11
+ resolveTargets(query: TargetQuery, options?: ReadOptions): Promise<ReadResult<TargetCandidate>>;
12
+ getElement(id: string, options?: ReadOptions): Promise<ReadItem<EditElement>>;
13
+ applyJson(operations: readonly EditOperation[], options?: ApplyOptions): Promise<EditReceipt>;
14
+ undo(options?: HistoryOptions): Promise<EditReceipt>;
15
+ createCheckpoint(label?: string): Promise<EditCheckpoint>;
16
+ listCheckpoints(): readonly EditCheckpoint[];
17
+ restoreCheckpoint(id: string, options?: HistoryOptions): Promise<EditReceipt>;
18
+ }
19
+ /** The tool definitions of a format, built from its operation schemas. */
20
+ export declare function buildToolSet(format: EditableFormat, schemas: OperationSchemaSet): ToolSet;
21
+ /** Runs one tool call on a session. */
22
+ export declare function callTool(session: ToolSource, tools: ToolSet, call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
23
+ /**
24
+ * What a batch did, in the format's words: one sentence per operation with
25
+ * the id it created when the receipt names one per creating operation,
26
+ * then what else the receipt reports.
27
+ */
28
+ export declare function describeReceipt(format: EditableFormat, operations: readonly EditOperation[], receipt: EditReceipt): string;