@superdoc/sdk 2.21.0-next.8 → 2.21.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.
@@ -518,6 +518,17 @@ function evaluateChecks(pre, post, checks) {
518
518
  }
519
519
  return results;
520
520
  }
521
+ function assertReplacementTextPayloads(edits) {
522
+ for (const [index, edit] of edits.entries()) {
523
+ const text = edit.replace ?? '';
524
+ for (let offset = 0; offset < text.length; offset += 1) {
525
+ const code = text.charCodeAt(offset);
526
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a) {
527
+ throw new errors.SuperDocCliError(`edits[${index}].replace contains unsupported control character U+${code.toString(16).padStart(4, '0')}. Use plain text, tabs or preserved manual line breaks.`, { code: 'INVALID_PAYLOAD' });
528
+ }
529
+ }
530
+ }
531
+ }
521
532
  function preserveRewriteStyle() {
522
533
  return {
523
534
  inline: { mode: 'preserve' },
@@ -964,6 +975,7 @@ async function runReplaceTextUnchecked(doc, args) {
964
975
  ? await docSnapshot.buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
965
976
  : snapshotFromIdentity(preIdentity);
966
977
  try {
978
+ assertReplacementTextPayloads(args.edits);
967
979
  if (args.edits.length === 0) {
968
980
  return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
969
981
  }
@@ -2493,115 +2505,6 @@ async function applyInlineQuiet(formatFn, params) {
2493
2505
  throw err;
2494
2506
  }
2495
2507
  }
2496
- /** Best-effort read of a block's current run layout (null when unavailable). */
2497
- async function captureBlockRuns(doc, nodeId, text) {
2498
- const queryFn = maybeMethod(doc, ['query', 'match']);
2499
- const trimmed = text.trim();
2500
- if (!queryFn || !trimmed || text.includes('\n'))
2501
- return null;
2502
- try {
2503
- const runs = await docSnapshot.matchRunsForBlock(queryFn, trimmed, nodeId);
2504
- return runs.length > 0 ? runs : null;
2505
- }
2506
- catch {
2507
- return null;
2508
- }
2509
- }
2510
- /**
2511
- * Restore scoped replacements from their known spans: each untouched interval
2512
- * keeps its run formatting, and replacements inherit the marks at their start.
2513
- * `spans` are FULL-TEXT coordinates, ascending, non-overlapping.
2514
- */
2515
- async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
2516
- if (!oldRuns || oldRuns.length === 0 || spans.length === 0)
2517
- return null;
2518
- if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
2519
- return null;
2520
- const formatFn = maybeMethod(doc, ['format', 'apply']);
2521
- if (!formatFn)
2522
- return null;
2523
- const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
2524
- // Run offsets (captureBlockRuns) are relative to the TRIMMED text; spans are
2525
- // full-text. Convert spans to trimmed coordinates and clamp.
2526
- const oldTrimStart = oldText.length - oldText.trimStart().length;
2527
- const oldTrimLen = oldText.trim().length;
2528
- const trimSpans = spans
2529
- .map((s) => ({
2530
- start: Math.max(0, s.start - oldTrimStart),
2531
- end: Math.min(oldTrimLen, s.end - oldTrimStart),
2532
- replaceLen: (s.replace ?? '').length,
2533
- }))
2534
- .filter((s) => s.end > s.start || s.replaceLen > 0);
2535
- if (trimSpans.length === 0)
2536
- return null;
2537
- const newText = spans.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), oldText);
2538
- const newTrimStart = newText.length - newText.trimStart().length;
2539
- // Cumulative old→new offset shift for positions AT OR AFTER a given old offset.
2540
- const deltaBefore = (oldPos) => {
2541
- let d = 0;
2542
- for (const s of trimSpans) {
2543
- if (s.end <= oldPos)
2544
- d += s.replaceLen - (s.end - s.start);
2545
- else
2546
- break;
2547
- }
2548
- return d;
2549
- };
2550
- let applied = 0;
2551
- const applyRange = async (start, end, inline) => {
2552
- if (end <= start || Object.keys(inline).length === 0)
2553
- return;
2554
- // Always direct, even under tracked mode. This pass RESTORES the marks the
2555
- // rewrite flattened; it is not new authorial formatting, and the text
2556
- // revision already owns the span's accept/reject fate. Applying it tracked
2557
- // adds a second reviewable change per rewrite (SD-4309's conformance matrix
2558
- // counts one per action), and rejecting that change alone would strand the
2559
- // text in the flattened look the rewrite produced.
2560
- const changed = await applyInlineQuiet(formatFn, {
2561
- blockId: nodeId,
2562
- start: newTrimStart + start,
2563
- end: newTrimStart + end,
2564
- inline,
2565
- });
2566
- if (changed)
2567
- applied += 1;
2568
- };
2569
- try {
2570
- // Untouched intervals between/around the spans, in old trimmed coords.
2571
- const intervals = [];
2572
- let cursor = 0;
2573
- for (const s of trimSpans) {
2574
- if (s.start > cursor)
2575
- intervals.push({ start: cursor, end: s.start });
2576
- cursor = Math.max(cursor, s.end);
2577
- }
2578
- if (cursor < oldTrimLen)
2579
- intervals.push({ start: cursor, end: oldTrimLen });
2580
- for (const run of oldRuns) {
2581
- const inline = inlineFromSnapshotRun(run, normalize);
2582
- for (const iv of intervals) {
2583
- const a = Math.max(run.start, iv.start);
2584
- const b = Math.min(run.end, iv.end);
2585
- if (b <= a)
2586
- continue;
2587
- const d = deltaBefore(a); // constant across an untouched interval
2588
- await applyRange(a + d, b + d, inline);
2589
- }
2590
- }
2591
- // Each replacement span inherits the marks of the run it starts in.
2592
- for (const s of trimSpans) {
2593
- if (s.replaceLen === 0)
2594
- continue;
2595
- const anchorRun = oldRuns.find((run) => s.start >= run.start && s.start < run.end) ?? oldRuns[oldRuns.length - 1];
2596
- const newStart = s.start + deltaBefore(s.start);
2597
- await applyRange(newStart, newStart + s.replaceLen, inlineFromSnapshotRun(anchorRun, normalize));
2598
- }
2599
- return applied > 0 ? { restoredRunRanges: applied } : null;
2600
- }
2601
- catch (err) {
2602
- return { skipped: reasonOf(err) };
2603
- }
2604
- }
2605
2508
  const NAMED_COLORS = {
2606
2509
  black: '000000',
2607
2510
  blue: '0000FF',
@@ -4397,6 +4300,7 @@ function parseScopedReplaceArgs(args, changeMode) {
4397
4300
  };
4398
4301
  }
4399
4302
  async function runScopedReplaceUnchecked(doc, args) {
4303
+ assertReplacementTextPayloads(args.edits);
4400
4304
  const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4401
4305
  const matched = docSnapshot.resolveSnapshotSelector(pre, args.selector);
4402
4306
  if (matched.length !== 1) {
@@ -4462,12 +4366,8 @@ async function runScopedReplaceUnchecked(doc, args) {
4462
4366
  // "requires a ref produced by query.match/find".
4463
4367
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4464
4368
  const tracked = (args.changeMode ?? 'direct') === 'tracked';
4465
- // Tracked mode: redline ONLY the spans. A whole-block rewrite in tracked
4466
- // mode records the entire paragraph as deleted+reinserted, which buries a
4467
- // one-word edit in a full-paragraph redline. Each span becomes a
4468
- // select-scoped rewrite pinned to this block via `within`; duplicate find
4469
- // strings would re-resolve ambiguously against tracked-visible deleted
4470
- // text, so those fall back to the whole-block path below.
4369
+ // Unique tracked finds can be resolved within the block. Repeated finds
4370
+ // use one whole-block rewrite, whose engine diff preserves unchanged runs.
4471
4371
  let result = null;
4472
4372
  // Dedupe on the same basis the selector will match on: under a
4473
4373
  // case-sensitive request `Foo` and `foo` are distinct targets, and folding
@@ -4487,26 +4387,18 @@ async function runScopedReplaceUnchecked(doc, args) {
4487
4387
  require: 'first',
4488
4388
  within: { kind: 'block', nodeType: block.nodeType, nodeId },
4489
4389
  },
4490
- args: { replacement: { text: span.replace } },
4390
+ args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
4491
4391
  }));
4492
- // Restore the known edit spans directly so the formatting pass adds no
4493
- // separate tracked change.
4494
- const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4495
4392
  try {
4496
4393
  result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4497
- if (result != null)
4498
- await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
4499
4394
  }
4500
4395
  catch {
4501
4396
  result = null; // select lane unavailable (e.g. case mismatch) — fall back
4502
4397
  }
4503
4398
  }
4504
4399
  if (result == null) {
4505
- // Direct mode (or tracked fallback): rewrite the whole block once, then
4506
- // restore the intra-paragraph run pattern span-aware — every interval the
4507
- // spans did NOT touch keeps its original run formatting at the mapped
4508
- // offsets, and each replacement inherits the marks of the run it starts in.
4509
- const preRuns = await captureBlockRuns(doc, nodeId, block.text);
4400
+ // The engine preserves runs while applying the text diff. A separate
4401
+ // formatting pass would restyle unchanged text and split the undo unit.
4510
4402
  const steps = [
4511
4403
  {
4512
4404
  id: 'scoped-replace-1',
@@ -4516,7 +4408,6 @@ async function runScopedReplaceUnchecked(doc, args) {
4516
4408
  },
4517
4409
  ];
4518
4410
  result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4519
- await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
4520
4411
  }
4521
4412
  const post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4522
4413
  const postBlock = findTextTarget(post, nodeId);
@@ -515,6 +515,17 @@ function evaluateChecks(pre, post, checks) {
515
515
  }
516
516
  return results;
517
517
  }
518
+ function assertReplacementTextPayloads(edits) {
519
+ for (const [index, edit] of edits.entries()) {
520
+ const text = edit.replace ?? '';
521
+ for (let offset = 0; offset < text.length; offset += 1) {
522
+ const code = text.charCodeAt(offset);
523
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a) {
524
+ throw new SuperDocCliError(`edits[${index}].replace contains unsupported control character U+${code.toString(16).padStart(4, '0')}. Use plain text, tabs or preserved manual line breaks.`, { code: 'INVALID_PAYLOAD' });
525
+ }
526
+ }
527
+ }
528
+ }
518
529
  function preserveRewriteStyle() {
519
530
  return {
520
531
  inline: { mode: 'preserve' },
@@ -961,6 +972,7 @@ async function runReplaceTextUnchecked(doc, args) {
961
972
  ? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
962
973
  : snapshotFromIdentity(preIdentity);
963
974
  try {
975
+ assertReplacementTextPayloads(args.edits);
964
976
  if (args.edits.length === 0) {
965
977
  return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
966
978
  }
@@ -2490,115 +2502,6 @@ async function applyInlineQuiet(formatFn, params) {
2490
2502
  throw err;
2491
2503
  }
2492
2504
  }
2493
- /** Best-effort read of a block's current run layout (null when unavailable). */
2494
- async function captureBlockRuns(doc, nodeId, text) {
2495
- const queryFn = maybeMethod(doc, ['query', 'match']);
2496
- const trimmed = text.trim();
2497
- if (!queryFn || !trimmed || text.includes('\n'))
2498
- return null;
2499
- try {
2500
- const runs = await matchRunsForBlock(queryFn, trimmed, nodeId);
2501
- return runs.length > 0 ? runs : null;
2502
- }
2503
- catch {
2504
- return null;
2505
- }
2506
- }
2507
- /**
2508
- * Restore scoped replacements from their known spans: each untouched interval
2509
- * keeps its run formatting, and replacements inherit the marks at their start.
2510
- * `spans` are FULL-TEXT coordinates, ascending, non-overlapping.
2511
- */
2512
- async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
2513
- if (!oldRuns || oldRuns.length === 0 || spans.length === 0)
2514
- return null;
2515
- if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
2516
- return null;
2517
- const formatFn = maybeMethod(doc, ['format', 'apply']);
2518
- if (!formatFn)
2519
- return null;
2520
- const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
2521
- // Run offsets (captureBlockRuns) are relative to the TRIMMED text; spans are
2522
- // full-text. Convert spans to trimmed coordinates and clamp.
2523
- const oldTrimStart = oldText.length - oldText.trimStart().length;
2524
- const oldTrimLen = oldText.trim().length;
2525
- const trimSpans = spans
2526
- .map((s) => ({
2527
- start: Math.max(0, s.start - oldTrimStart),
2528
- end: Math.min(oldTrimLen, s.end - oldTrimStart),
2529
- replaceLen: (s.replace ?? '').length,
2530
- }))
2531
- .filter((s) => s.end > s.start || s.replaceLen > 0);
2532
- if (trimSpans.length === 0)
2533
- return null;
2534
- const newText = spans.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), oldText);
2535
- const newTrimStart = newText.length - newText.trimStart().length;
2536
- // Cumulative old→new offset shift for positions AT OR AFTER a given old offset.
2537
- const deltaBefore = (oldPos) => {
2538
- let d = 0;
2539
- for (const s of trimSpans) {
2540
- if (s.end <= oldPos)
2541
- d += s.replaceLen - (s.end - s.start);
2542
- else
2543
- break;
2544
- }
2545
- return d;
2546
- };
2547
- let applied = 0;
2548
- const applyRange = async (start, end, inline) => {
2549
- if (end <= start || Object.keys(inline).length === 0)
2550
- return;
2551
- // Always direct, even under tracked mode. This pass RESTORES the marks the
2552
- // rewrite flattened; it is not new authorial formatting, and the text
2553
- // revision already owns the span's accept/reject fate. Applying it tracked
2554
- // adds a second reviewable change per rewrite (SD-4309's conformance matrix
2555
- // counts one per action), and rejecting that change alone would strand the
2556
- // text in the flattened look the rewrite produced.
2557
- const changed = await applyInlineQuiet(formatFn, {
2558
- blockId: nodeId,
2559
- start: newTrimStart + start,
2560
- end: newTrimStart + end,
2561
- inline,
2562
- });
2563
- if (changed)
2564
- applied += 1;
2565
- };
2566
- try {
2567
- // Untouched intervals between/around the spans, in old trimmed coords.
2568
- const intervals = [];
2569
- let cursor = 0;
2570
- for (const s of trimSpans) {
2571
- if (s.start > cursor)
2572
- intervals.push({ start: cursor, end: s.start });
2573
- cursor = Math.max(cursor, s.end);
2574
- }
2575
- if (cursor < oldTrimLen)
2576
- intervals.push({ start: cursor, end: oldTrimLen });
2577
- for (const run of oldRuns) {
2578
- const inline = inlineFromSnapshotRun(run, normalize);
2579
- for (const iv of intervals) {
2580
- const a = Math.max(run.start, iv.start);
2581
- const b = Math.min(run.end, iv.end);
2582
- if (b <= a)
2583
- continue;
2584
- const d = deltaBefore(a); // constant across an untouched interval
2585
- await applyRange(a + d, b + d, inline);
2586
- }
2587
- }
2588
- // Each replacement span inherits the marks of the run it starts in.
2589
- for (const s of trimSpans) {
2590
- if (s.replaceLen === 0)
2591
- continue;
2592
- const anchorRun = oldRuns.find((run) => s.start >= run.start && s.start < run.end) ?? oldRuns[oldRuns.length - 1];
2593
- const newStart = s.start + deltaBefore(s.start);
2594
- await applyRange(newStart, newStart + s.replaceLen, inlineFromSnapshotRun(anchorRun, normalize));
2595
- }
2596
- return applied > 0 ? { restoredRunRanges: applied } : null;
2597
- }
2598
- catch (err) {
2599
- return { skipped: reasonOf(err) };
2600
- }
2601
- }
2602
2505
  const NAMED_COLORS = {
2603
2506
  black: '000000',
2604
2507
  blue: '0000FF',
@@ -4394,6 +4297,7 @@ function parseScopedReplaceArgs(args, changeMode) {
4394
4297
  };
4395
4298
  }
4396
4299
  async function runScopedReplaceUnchecked(doc, args) {
4300
+ assertReplacementTextPayloads(args.edits);
4397
4301
  const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4398
4302
  const matched = resolveSnapshotSelector(pre, args.selector);
4399
4303
  if (matched.length !== 1) {
@@ -4459,12 +4363,8 @@ async function runScopedReplaceUnchecked(doc, args) {
4459
4363
  // "requires a ref produced by query.match/find".
4460
4364
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4461
4365
  const tracked = (args.changeMode ?? 'direct') === 'tracked';
4462
- // Tracked mode: redline ONLY the spans. A whole-block rewrite in tracked
4463
- // mode records the entire paragraph as deleted+reinserted, which buries a
4464
- // one-word edit in a full-paragraph redline. Each span becomes a
4465
- // select-scoped rewrite pinned to this block via `within`; duplicate find
4466
- // strings would re-resolve ambiguously against tracked-visible deleted
4467
- // text, so those fall back to the whole-block path below.
4366
+ // Unique tracked finds can be resolved within the block. Repeated finds
4367
+ // use one whole-block rewrite, whose engine diff preserves unchanged runs.
4468
4368
  let result = null;
4469
4369
  // Dedupe on the same basis the selector will match on: under a
4470
4370
  // case-sensitive request `Foo` and `foo` are distinct targets, and folding
@@ -4484,26 +4384,18 @@ async function runScopedReplaceUnchecked(doc, args) {
4484
4384
  require: 'first',
4485
4385
  within: { kind: 'block', nodeType: block.nodeType, nodeId },
4486
4386
  },
4487
- args: { replacement: { text: span.replace } },
4387
+ args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
4488
4388
  }));
4489
- // Restore the known edit spans directly so the formatting pass adds no
4490
- // separate tracked change.
4491
- const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4492
4389
  try {
4493
4390
  result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4494
- if (result != null)
4495
- await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
4496
4391
  }
4497
4392
  catch {
4498
4393
  result = null; // select lane unavailable (e.g. case mismatch) — fall back
4499
4394
  }
4500
4395
  }
4501
4396
  if (result == null) {
4502
- // Direct mode (or tracked fallback): rewrite the whole block once, then
4503
- // restore the intra-paragraph run pattern span-aware — every interval the
4504
- // spans did NOT touch keeps its original run formatting at the mapped
4505
- // offsets, and each replacement inherits the marks of the run it starts in.
4506
- const preRuns = await captureBlockRuns(doc, nodeId, block.text);
4397
+ // The engine preserves runs while applying the text diff. A separate
4398
+ // formatting pass would restyle unchanged text and split the undo unit.
4507
4399
  const steps = [
4508
4400
  {
4509
4401
  id: 'scoped-replace-1',
@@ -4513,7 +4405,6 @@ async function runScopedReplaceUnchecked(doc, args) {
4513
4405
  },
4514
4406
  ];
4515
4407
  result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4516
- await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
4517
4408
  }
4518
4409
  const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4519
4410
  const postBlock = findTextTarget(post, nodeId);
@@ -108,7 +108,9 @@ function createDocApi(runtime) {
108
108
  },
109
109
  styles: {
110
110
  apply: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.apply"], params, options),
111
+ create: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.create"], params, options),
111
112
  getCatalog: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.getCatalog"], params, options),
113
+ getTheme: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.getTheme"], params, options),
112
114
  paragraph: {
113
115
  setStyle: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.paragraph.setStyle"], params, options),
114
116
  setStyleRef: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.paragraph.setStyleRef"], params, options),
@@ -689,7 +691,9 @@ function createBoundDocApi(runtime) {
689
691
  },
690
692
  styles: {
691
693
  apply: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.apply"], params, options),
694
+ create: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.create"], params, options),
692
695
  getCatalog: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.getCatalog"], params, options),
696
+ getTheme: (params = {}, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.getTheme"], params, options),
693
697
  paragraph: {
694
698
  setStyle: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.paragraph.setStyle"], params, options),
695
699
  setStyleRef: (params, options) => runtime.invoke(contract.CONTRACT.operations["doc.styles.paragraph.setStyleRef"], params, options),