bun-docx 0.20.3 → 0.20.4

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 (3) hide show
  1. package/README.md +7 -0
  2. package/dist/index.js +666 -153
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -246,9 +246,16 @@ docx styles set-default-font FILE "Font Name" [--size N] [--all] # document-wi
246
246
  docx replace FILE PATTERN REPLACEMENT [--at pN] [--regex] [--ignore-case] [--all] [--limit N] [--current | --baseline] [--exact] [--track] [--dry-run]
247
247
  # Keeps the run's formatting (bold/font) and any tabs — the no-rebuild way to fill a
248
248
  # formatted/tabbed template line (e.g. "**Org Name**⇥Date"); don't hand-build --runs to refill it.
249
+ # A TAB matches as one character (pattern "City State Zip" fills a tab-separated line), and
250
+ # TAB/NBSP/bullet-glyph variants match their plain equivalents unless --exact.
249
251
  # --at pN (or a cell paragraph tT:rRcC:pN) CONFINES the replace to one paragraph — use it when the
250
252
  # SAME placeholder repeats across the doc (a résumé's "City, State" in every entry) and you want THE
251
253
  # one in a specific paragraph, instead of find → edit --at pN:S-E span surgery. Batch entries take "at" too.
254
+ # MULTI-LINE (editor-style): "\n" in PATTERN matches a line break OR a paragraph boundary
255
+ # (consecutive paragraphs in the body or one table cell; never across a table/section break/cell wall); the REPLACEMENT's own
256
+ # "\n"s then define the result — single-line replacement MERGES the paragraphs (first one's
257
+ # formatting governs), "\n" in the replacement inserts a paragraph mark (splits). Untracked
258
+ # only: refuses under tracking rather than skip the journal. Block ids shift; re-read after.
252
259
 
253
260
  # Batch — apply many changes from ONE read (no re-reading between edits). Keys
254
261
  # on each JSONL line mirror the command's flags; all locators address the doc as
package/dist/index.js CHANGED
@@ -4513,8 +4513,8 @@ function runTextLength(run) {
4513
4513
  for (const child of run.children) {
4514
4514
  if (child.tag === "w:t" || child.tag === "w:delText") {
4515
4515
  total += child.collectText().length;
4516
- } else if (SINGLE_CHAR_TAGS.has(child.tag)) {
4517
- total += 1;
4516
+ } else {
4517
+ total += inlineMarkerWidth(child);
4518
4518
  }
4519
4519
  }
4520
4520
  return total;
@@ -4539,7 +4539,7 @@ function sliceRun(run, start, end) {
4539
4539
  offset += text.length;
4540
4540
  continue;
4541
4541
  }
4542
- if (SINGLE_CHAR_TAGS.has(child.tag)) {
4542
+ if (inlineMarkerWidth(child) === 1) {
4543
4543
  if (offset >= start && offset < end) {
4544
4544
  sliced.children.push(child.clone());
4545
4545
  }
@@ -4552,6 +4552,23 @@ function sliceRun(run, start, end) {
4552
4552
  }
4553
4553
  return sliced;
4554
4554
  }
4555
+ function inlineMarkerWidth(child) {
4556
+ switch (child.tag) {
4557
+ case "w:noBreakHyphen":
4558
+ case "w:softHyphen":
4559
+ case "w:sym":
4560
+ case "w:tab":
4561
+ case "w:ptab":
4562
+ case "w:cr":
4563
+ return 1;
4564
+ case "w:br": {
4565
+ const type = child.getAttribute("w:type");
4566
+ return type === "page" || type === "column" ? 0 : 1;
4567
+ }
4568
+ default:
4569
+ return 0;
4570
+ }
4571
+ }
4555
4572
  function isRunBearingWrapper(tag) {
4556
4573
  return RUN_BEARING_WRAPPER_TAGS.has(tag);
4557
4574
  }
@@ -4595,10 +4612,9 @@ function collectInnerRuns(wrapper, out) {
4595
4612
  }
4596
4613
  }
4597
4614
  }
4598
- var SINGLE_CHAR_TAGS, RUN_BEARING_WRAPPER_TAGS;
4615
+ var RUN_BEARING_WRAPPER_TAGS;
4599
4616
  var init_run_ops = __esm(() => {
4600
4617
  init_xml_node();
4601
- SINGLE_CHAR_TAGS = new Set(["w:noBreakHyphen", "w:softHyphen", "w:sym"]);
4602
4618
  RUN_BEARING_WRAPPER_TAGS = new Set([
4603
4619
  "w:ins",
4604
4620
  "w:del",
@@ -22986,12 +23002,18 @@ function readRun(document2, node, activeComments, trackedChange, hyperlink, stat
22986
23002
  if (child2.tag === "w:br" || child2.tag === "w:cr") {
22987
23003
  flushText();
22988
23004
  const kind = child2.tag === "w:cr" ? "line" : child2.getAttribute("w:type") ?? "line";
22989
- out.push({ type: "break", kind });
23005
+ const breakRun = { type: "break", kind };
23006
+ if (trackedChange)
23007
+ breakRun.trackedChange = trackedChange;
23008
+ out.push(breakRun);
22990
23009
  continue;
22991
23010
  }
22992
23011
  if (child2.tag === "w:tab" || child2.tag === "w:ptab") {
22993
23012
  flushText();
22994
- out.push({ type: "tab" });
23013
+ const tabRun = { type: "tab" };
23014
+ if (trackedChange)
23015
+ tabRun.trackedChange = trackedChange;
23016
+ out.push(tabRun);
22995
23017
  continue;
22996
23018
  }
22997
23019
  if (child2.tag === "w:footnoteReference") {
@@ -34790,7 +34812,7 @@ var init_forms = __esm(() => {
34790
34812
  crossSpan: {
34791
34813
  syntax: "pN:S-pM:E",
34792
34814
  example: "p3:5-p5:10",
34793
- summary: "from char S of paragraph N to char E of paragraph M"
34815
+ summary: "from char S of paragraph N to char E of paragraph M (cell paragraphs too \u2014 tN:rRcC:pA:S-tN:rRcC:pB:E, same cell)"
34794
34816
  },
34795
34817
  cell: {
34796
34818
  syntax: "tN:rRcC",
@@ -34883,6 +34905,21 @@ function parseLocator(input) {
34883
34905
  if (endnoteMatch) {
34884
34906
  return { kind: "endnote", endnoteId: `en${endnoteMatch[1]}` };
34885
34907
  }
34908
+ const rangeMatch = trimmed.match(RANGE_RE);
34909
+ if (rangeMatch) {
34910
+ const [, startBlock, startCapture, endBlock, endCapture] = rangeMatch;
34911
+ if (startBlock && endBlock && cellPrefixOf(startBlock) !== cellPrefixOf(endBlock)) {
34912
+ throw new LocatorParseError(input, "range endpoints must share a container \u2014 both body paragraphs, or paragraphs of the SAME table cell");
34913
+ }
34914
+ const startOffset = Number(startCapture);
34915
+ const endOffset = Number(endCapture);
34916
+ validateOffsets(input, startOffset, endOffset, startBlock !== endBlock);
34917
+ return {
34918
+ kind: "range",
34919
+ start: { blockId: startBlock ?? "", offset: startOffset },
34920
+ end: { blockId: endBlock ?? "", offset: endOffset }
34921
+ };
34922
+ }
34886
34923
  const cellRangeMatch = trimmed.match(CELL_RANGE_RE);
34887
34924
  if (cellRangeMatch) {
34888
34925
  const [, tableIndex, startRow, startCol, endRow, endCol] = cellRangeMatch;
@@ -34924,18 +34961,6 @@ function parseLocator(input) {
34924
34961
  col: Number(columnIndex)
34925
34962
  };
34926
34963
  }
34927
- const rangeMatch = trimmed.match(RANGE_RE);
34928
- if (rangeMatch) {
34929
- const [, startBlock, startCapture, endBlock, endCapture] = rangeMatch;
34930
- const startOffset = Number(startCapture);
34931
- const endOffset = Number(endCapture);
34932
- validateOffsets(input, startOffset, endOffset, startBlock !== endBlock);
34933
- return {
34934
- kind: "range",
34935
- start: { blockId: `p${startBlock}`, offset: startOffset },
34936
- end: { blockId: `p${endBlock}`, offset: endOffset }
34937
- };
34938
- }
34939
34964
  const blockRangeMatch = trimmed.match(BLOCK_RANGE_RE);
34940
34965
  if (blockRangeMatch) {
34941
34966
  const [, startIndex, endIndex] = blockRangeMatch;
@@ -34965,6 +34990,10 @@ function parseLocator(input) {
34965
34990
  }
34966
34991
  throw new LocatorParseError(input, "unrecognized syntax");
34967
34992
  }
34993
+ function cellPrefixOf(blockId) {
34994
+ const lastColon = blockId.lastIndexOf(":");
34995
+ return lastColon === -1 ? "" : blockId.slice(0, lastColon);
34996
+ }
34968
34997
  function validateOffsets(input, start, end, crossBlock) {
34969
34998
  if (start < 0 || end < 0) {
34970
34999
  throw new LocatorParseError(input, "offsets must be non-negative");
@@ -34985,7 +35014,7 @@ var init_parse = __esm(() => {
34985
35014
  };
34986
35015
  BLOCK_RE = /^(p|t|s)(\d+)$/;
34987
35016
  SPAN_RE = /^p(\d+):(\d+)-(\d+)$/;
34988
- RANGE_RE = /^p(\d+):(\d+)-p(\d+):(\d+)$/;
35017
+ RANGE_RE = /^((?:t\d+:r\d+c\d+:)?p\d+):(\d+)-((?:t\d+:r\d+c\d+:)?p\d+):(\d+)$/;
34989
35018
  BLOCK_RANGE_RE = /^p(\d+)-p(\d+)$/;
34990
35019
  COMMENT_RE = /^c(\d+)$/;
34991
35020
  IMAGE_RE = /^img(\d+)$/;
@@ -50155,10 +50184,7 @@ function replaceSpanInParagraph(paragraph, span, replacement, tracked, view = "a
50155
50184
  const overlapping = slots.filter((slot) => slot.offsetBefore + slot.length > span.start && slot.offsetBefore < span.end);
50156
50185
  const firstSlot = overlapping[0];
50157
50186
  if (!firstSlot) {
50158
- paragraph.children.push(/* @__PURE__ */ jsxDEV(ReplacementRun, {
50159
- runProperties: null,
50160
- text: replacement
50161
- }, undefined, false, undefined, this));
50187
+ paragraph.children.push(...replacementRuns(null, replacement));
50162
50188
  return;
50163
50189
  }
50164
50190
  const inheritedProperties = firstSlot.run.findChild("w:rPr")?.clone() ?? null;
@@ -50203,14 +50229,15 @@ function rebuildContainer(container, baseOffset, span, replacement, runPropertie
50203
50229
  if (placed)
50204
50230
  return;
50205
50231
  placed = true;
50206
- const run = /* @__PURE__ */ jsxDEV(ReplacementRun, {
50207
- runProperties,
50208
- text: replacement
50209
- }, undefined, false, undefined, this);
50210
- newChildren.push(tracked && isParagraph ? /* @__PURE__ */ jsxDEV(Ins, {
50211
- meta: mintMeta(tracked),
50212
- children: run
50213
- }, undefined, false, undefined, this) : run);
50232
+ const runs = replacementRuns(runProperties, replacement);
50233
+ if (tracked && isParagraph) {
50234
+ newChildren.push(/* @__PURE__ */ jsxDEV(Ins, {
50235
+ meta: mintMeta(tracked),
50236
+ children: runs
50237
+ }, undefined, false, undefined, this));
50238
+ return;
50239
+ }
50240
+ newChildren.push(...runs);
50214
50241
  };
50215
50242
  for (const child2 of container.children) {
50216
50243
  if (child2.tag === "w:r") {
@@ -50265,14 +50292,15 @@ function rebuildAcrossBoundaries(paragraph, span, replacement, runProperties, tr
50265
50292
  if (placed)
50266
50293
  return;
50267
50294
  placed = true;
50268
- const run = /* @__PURE__ */ jsxDEV(ReplacementRun, {
50269
- runProperties,
50270
- text: replacement
50271
- }, undefined, false, undefined, this);
50272
- newChildren.push(tracked ? /* @__PURE__ */ jsxDEV(Ins, {
50273
- meta: mintMeta(tracked),
50274
- children: run
50275
- }, undefined, false, undefined, this) : run);
50295
+ const runs = replacementRuns(runProperties, replacement);
50296
+ if (tracked) {
50297
+ newChildren.push(/* @__PURE__ */ jsxDEV(Ins, {
50298
+ meta: mintMeta(tracked),
50299
+ children: runs
50300
+ }, undefined, false, undefined, this));
50301
+ return;
50302
+ }
50303
+ newChildren.push(...runs);
50276
50304
  };
50277
50305
  for (const child2 of paragraph.children) {
50278
50306
  if (child2.tag === "w:r") {
@@ -50497,14 +50525,15 @@ function splitHyperlinkAcrossSpan(wrapper, wrapperStart, span, runProperties, re
50497
50525
  }
50498
50526
  }
50499
50527
  if (startsInside) {
50500
- const innerReplacement = /* @__PURE__ */ jsxDEV(ReplacementRun, {
50501
- runProperties,
50502
- text: replacement
50503
- }, undefined, false, undefined, this);
50504
- preInner.push(tracked ? /* @__PURE__ */ jsxDEV(Ins, {
50505
- meta: mintMeta(tracked),
50506
- children: innerReplacement
50507
- }, undefined, false, undefined, this) : innerReplacement);
50528
+ const innerRuns = replacementRuns(runProperties, replacement);
50529
+ if (tracked) {
50530
+ preInner.push(/* @__PURE__ */ jsxDEV(Ins, {
50531
+ meta: mintMeta(tracked),
50532
+ children: innerRuns
50533
+ }, undefined, false, undefined, this));
50534
+ } else {
50535
+ preInner.push(...innerRuns);
50536
+ }
50508
50537
  markReplacementPlaced();
50509
50538
  }
50510
50539
  if (preInner.length > 0) {
@@ -50529,48 +50558,318 @@ function convertRunTextToDelText(run) {
50529
50558
  child2.tag = "w:delText";
50530
50559
  }
50531
50560
  }
50532
- function ReplacementRun({
50533
- runProperties,
50534
- text: text2
50535
- }) {
50536
- return /* @__PURE__ */ jsxDEV(w.r, {
50561
+ function replacementRuns(runProperties, text2) {
50562
+ if (text2.length === 0) {
50563
+ return [
50564
+ /* @__PURE__ */ jsxDEV(w.r, {
50565
+ children: [
50566
+ runProperties,
50567
+ /* @__PURE__ */ jsxDEV(w.t, {
50568
+ "xml:space": "preserve"
50569
+ }, undefined, false, undefined, this)
50570
+ ]
50571
+ }, undefined, true, undefined, this)
50572
+ ];
50573
+ }
50574
+ const runs = textToRunElements(text2);
50575
+ for (const run of runs) {
50576
+ if (runProperties)
50577
+ run.children.unshift(runProperties.clone());
50578
+ }
50579
+ return runs;
50580
+ }
50581
+ var init_replace_span = __esm(() => {
50582
+ init_blocks();
50583
+ init_jsx();
50584
+ init_parser();
50585
+ init_emit2();
50586
+ init_jsx_dev_runtime();
50587
+ });
50588
+
50589
+ // src/core/find/replace-across.tsx
50590
+ function replaceAcrossParagraphs(body, match, replacement, view = "accepted") {
50591
+ const startRef = body.resolveBlock(match.startBlockId);
50592
+ const endRef = body.resolveBlock(match.endBlockId);
50593
+ const { parent, startIndex, endIndex } = body.resolveBlockRange(match.startBlockId, match.endBlockId);
50594
+ const sameNode = startRef.node === endRef.node;
50595
+ const segments = replacement.split(/\r\n|\n/);
50596
+ let head;
50597
+ let tail;
50598
+ let cut;
50599
+ if (sameNode) {
50600
+ const endSplit = splitChildrenAt(startRef.node.children, match.endOffset, view);
50601
+ const startSplit = splitChildrenAt(endSplit.before, match.startOffset, view);
50602
+ head = startSplit.before;
50603
+ tail = endSplit.after;
50604
+ cut = startSplit.after;
50605
+ } else {
50606
+ const startSplit = splitChildrenAt(startRef.node.children, match.startOffset, view);
50607
+ const endSplit = splitChildrenAt(endRef.node.children, match.endOffset, view);
50608
+ head = startSplit.before;
50609
+ tail = endSplit.after;
50610
+ cut = [
50611
+ ...startSplit.after,
50612
+ ...endSplit.before.filter((child2) => child2.tag !== "w:pPr")
50613
+ ];
50614
+ }
50615
+ const inherited = firstVisibleRun(cut, view)?.findChild("w:rPr")?.clone() ?? null;
50616
+ const salvaged = salvageNonContent(cut, view);
50617
+ const segmentRuns = (segment) => segment.length === 0 ? [] : replacementRuns(inherited, segment);
50618
+ const firstSegment = segments[0] ?? "";
50619
+ const startChildren = [...head, ...segmentRuns(firstSegment), ...salvaged];
50620
+ if (segments.length === 1) {
50621
+ startRef.node.children = [...startChildren, ...tail];
50622
+ parent.splice(startIndex + 1, endIndex - startIndex);
50623
+ return;
50624
+ }
50625
+ startRef.node.children = startChildren;
50626
+ const template = paragraphPropertiesTemplate(startRef.node);
50627
+ const middle = segments.slice(1, -1).map((segment) => buildParagraph(template?.clone() ?? null, segmentRuns(segment)));
50628
+ const lastSegment = segments[segments.length - 1] ?? "";
50629
+ if (sameNode) {
50630
+ const finalParagraph = buildParagraph(template?.clone() ?? null, [
50631
+ ...segmentRuns(lastSegment),
50632
+ ...tail
50633
+ ]);
50634
+ parent.splice(startIndex + 1, 0, ...middle, finalParagraph);
50635
+ return;
50636
+ }
50637
+ const endProperties = endRef.node.findChild("w:pPr");
50638
+ endRef.node.children = [
50639
+ ...endProperties ? [endProperties] : [],
50640
+ ...segmentRuns(lastSegment),
50641
+ ...tail
50642
+ ];
50643
+ parent.splice(startIndex + 1, endIndex - startIndex - 1, ...middle);
50644
+ }
50645
+ function splitChildrenAt(children, offset2, view) {
50646
+ const before = [];
50647
+ const after = [];
50648
+ let cursor = 0;
50649
+ for (const child2 of children) {
50650
+ if (child2.tag === "w:pPr") {
50651
+ before.push(child2);
50652
+ continue;
50653
+ }
50654
+ if (child2.tag === "w:r") {
50655
+ const length = runTextLength(child2);
50656
+ if (cursor + length <= offset2) {
50657
+ before.push(child2);
50658
+ } else if (cursor >= offset2) {
50659
+ after.push(child2);
50660
+ } else {
50661
+ before.push(sliceRun(child2, 0, offset2 - cursor));
50662
+ after.push(sliceRun(child2, offset2 - cursor, length));
50663
+ }
50664
+ cursor += length;
50665
+ continue;
50666
+ }
50667
+ if (isRunBearingWrapper(child2.tag) && isWrapperVisibleInView2(child2.tag, view)) {
50668
+ const length = sumVisibleTextLength2(child2.children, view);
50669
+ if (cursor + length <= offset2) {
50670
+ before.push(child2);
50671
+ } else if (cursor >= offset2) {
50672
+ after.push(child2);
50673
+ } else {
50674
+ const inner2 = splitChildrenAt(child2.children, offset2 - cursor, view);
50675
+ if (inner2.before.length > 0) {
50676
+ const preWrapper = new XmlNode2(child2.tag, { ...child2.attributes });
50677
+ preWrapper.children = inner2.before;
50678
+ before.push(preWrapper);
50679
+ }
50680
+ if (inner2.after.length > 0) {
50681
+ const postWrapper = new XmlNode2(child2.tag, { ...child2.attributes });
50682
+ postWrapper.children = inner2.after;
50683
+ after.push(postWrapper);
50684
+ }
50685
+ }
50686
+ cursor += length;
50687
+ continue;
50688
+ }
50689
+ (cursor < offset2 ? before : after).push(child2);
50690
+ }
50691
+ return { before, after };
50692
+ }
50693
+ function firstVisibleRun(children, view) {
50694
+ for (const child2 of children) {
50695
+ if (child2.tag === "w:r")
50696
+ return child2;
50697
+ if (isRunBearingWrapper(child2.tag) && isWrapperVisibleInView2(child2.tag, view)) {
50698
+ const nested = firstVisibleRun(child2.children, view);
50699
+ if (nested)
50700
+ return nested;
50701
+ }
50702
+ }
50703
+ return null;
50704
+ }
50705
+ function salvageNonContent(children, view) {
50706
+ const out = [];
50707
+ for (const child2 of children) {
50708
+ if (child2.tag === "w:r" || child2.tag === "w:pPr")
50709
+ continue;
50710
+ if (isRunBearingWrapper(child2.tag)) {
50711
+ if (isWrapperVisibleInView2(child2.tag, view)) {
50712
+ out.push(...salvageNonContent(child2.children, view));
50713
+ } else {
50714
+ out.push(child2);
50715
+ }
50716
+ continue;
50717
+ }
50718
+ out.push(child2);
50719
+ }
50720
+ return out;
50721
+ }
50722
+ function paragraphPropertiesTemplate(paragraph) {
50723
+ const source2 = paragraph.findChild("w:pPr");
50724
+ if (!source2)
50725
+ return null;
50726
+ const clone = source2.clone();
50727
+ clone.children = clone.children.filter((child2) => child2.tag !== "w:sectPr");
50728
+ const markProperties = clone.findChild("w:rPr");
50729
+ if (markProperties) {
50730
+ markProperties.children = markProperties.children.filter((child2) => child2.tag !== "w:ins" && child2.tag !== "w:del");
50731
+ }
50732
+ return clone;
50733
+ }
50734
+ function buildParagraph(paragraphProperties, runs) {
50735
+ return /* @__PURE__ */ jsxDEV(w.p, {
50537
50736
  children: [
50538
- runProperties,
50539
- /* @__PURE__ */ jsxDEV(w.t, {
50540
- "xml:space": "preserve",
50541
- children: text2
50542
- }, undefined, false, undefined, this)
50737
+ paragraphProperties,
50738
+ runs
50543
50739
  ]
50544
50740
  }, undefined, true, undefined, this);
50545
50741
  }
50546
- var init_replace_span = __esm(() => {
50742
+ var init_replace_across = __esm(() => {
50547
50743
  init_jsx();
50548
50744
  init_parser();
50549
- init_emit2();
50745
+ init_replace_span();
50550
50746
  init_jsx_dev_runtime();
50551
50747
  });
50552
50748
 
50553
50749
  // src/core/find/index.ts
50554
50750
  function findTextSpans(doc, query, options = {}) {
50555
50751
  const view = options.view ?? "accepted";
50556
- const exact = options.exact ?? false;
50557
- const useRegex = options.regex ?? false;
50558
- let effectiveQuery = query;
50559
- const applied = [];
50560
- if (!useRegex && !exact) {
50561
- const norm = normalizeQuery(query);
50562
- effectiveQuery = norm.normalized;
50563
- applied.push(...norm.applied);
50564
- }
50565
- const matcher = useRegex ? regexMatcher(query, options.ignoreCase ?? false) : literalMatcher(effectiveQuery, options.ignoreCase ?? false, !exact);
50752
+ const { matcher, ...normalization } = buildMatcher(query, options);
50566
50753
  const out = [];
50567
50754
  collectMatches(doc.blocks, matcher, view, out);
50568
- const result = { matches: out };
50569
- if (applied.length > 0) {
50570
- result.normalizedQuery = effectiveQuery;
50571
- result.normalizationApplied = applied;
50755
+ return { matches: out, ...normalization };
50756
+ }
50757
+ function buildMatcher(query, options) {
50758
+ const ignoreCase = options.ignoreCase ?? false;
50759
+ if (options.regex)
50760
+ return { matcher: regexMatcher(query, ignoreCase) };
50761
+ if (options.exact) {
50762
+ return { matcher: literalMatcher(query, ignoreCase, false) };
50763
+ }
50764
+ const { normalized, applied } = normalizeQuery(query);
50765
+ const matcher = literalMatcher(normalized, ignoreCase, true);
50766
+ if (applied.length === 0)
50767
+ return { matcher };
50768
+ return {
50769
+ matcher,
50770
+ normalizedQuery: normalized,
50771
+ normalizationApplied: applied
50772
+ };
50773
+ }
50774
+ function findAcrossParagraphs(doc, query, options = {}) {
50775
+ const view = options.view ?? "accepted";
50776
+ const { matcher, ...normalization } = buildMatcher(query, options);
50777
+ const out = [];
50778
+ for (const group of consecutiveParagraphGroups(doc.blocks)) {
50779
+ const entries = [];
50780
+ let flat = "";
50781
+ for (const paragraph of group) {
50782
+ const text2 = paragraphTextForView(paragraph, view);
50783
+ entries.push({
50784
+ blockId: paragraph.id,
50785
+ flatStart: flat.length,
50786
+ length: text2.length
50787
+ });
50788
+ flat += `${text2}
50789
+ `;
50790
+ }
50791
+ flat = flat.slice(0, -1);
50792
+ let entryCursor = 0;
50793
+ for (const span of matcher(flat)) {
50794
+ const start = mapFlatOffset(span.start, entries, entryCursor);
50795
+ const end = mapFlatOffset(span.end, entries, start.entryIndex);
50796
+ entryCursor = end.entryIndex;
50797
+ out.push({
50798
+ startBlockId: start.blockId,
50799
+ startOffset: start.local,
50800
+ endBlockId: end.blockId,
50801
+ endOffset: end.local,
50802
+ text: span.text
50803
+ });
50804
+ }
50572
50805
  }
50573
- return result;
50806
+ return { matches: out, ...normalization };
50807
+ }
50808
+ function consecutiveParagraphGroups(blocks) {
50809
+ const groups = [];
50810
+ let current = [];
50811
+ const flush = () => {
50812
+ if (current.length > 0) {
50813
+ groups.push(current);
50814
+ current = [];
50815
+ }
50816
+ };
50817
+ for (const block of blocks) {
50818
+ if (block.type === "paragraph") {
50819
+ current.push(block);
50820
+ continue;
50821
+ }
50822
+ flush();
50823
+ if (block.type === "table") {
50824
+ for (const row of block.rows) {
50825
+ for (const cell of row.cells) {
50826
+ groups.push(...consecutiveParagraphGroups(cell.blocks));
50827
+ }
50828
+ }
50829
+ }
50830
+ }
50831
+ flush();
50832
+ return groups;
50833
+ }
50834
+ function mapFlatOffset(offset2, entries, fromIndex) {
50835
+ for (let index = fromIndex;index < entries.length; index++) {
50836
+ const entry = entries[index];
50837
+ if (entry && offset2 <= entry.flatStart + entry.length) {
50838
+ return {
50839
+ blockId: entry.blockId,
50840
+ local: Math.max(0, offset2 - entry.flatStart),
50841
+ entryIndex: index
50842
+ };
50843
+ }
50844
+ }
50845
+ throw new Error(`mapFlatOffset: offset ${offset2} out of range`);
50846
+ }
50847
+ function applyAcrossReplace(doc, spec, { dryRun = false } = {}) {
50848
+ const { matches, ...normalization } = findAcrossParagraphs(doc, spec.pattern, {
50849
+ regex: spec.regex,
50850
+ ignoreCase: spec.ignoreCase,
50851
+ view: spec.view,
50852
+ exact: spec.exact
50853
+ });
50854
+ const replaced = selectMatches(matches, spec);
50855
+ if (!dryRun) {
50856
+ const expand = replacementExpander(spec);
50857
+ for (const match of [...replaced].reverse()) {
50858
+ replaceAcrossParagraphs(doc, match, expand(match.text), spec.view);
50859
+ }
50860
+ }
50861
+ return { totalMatches: matches.length, replaced, ...normalization };
50862
+ }
50863
+ function selectMatches(matches, options) {
50864
+ if (options.limit !== undefined)
50865
+ return matches.slice(0, options.limit);
50866
+ return options.all ? matches : matches.slice(0, 1);
50867
+ }
50868
+ function replacementExpander(spec) {
50869
+ if (!spec.regex)
50870
+ return () => spec.replacement;
50871
+ const regex = new RegExp(spec.pattern, spec.ignoreCase ? "i" : "");
50872
+ return (matchText) => matchText.replace(regex, spec.replacement);
50574
50873
  }
50575
50874
  function findFormattedSpans(doc, filter, view = "accepted") {
50576
50875
  const out = [];
@@ -50590,6 +50889,7 @@ function findFormattedSpans(doc, filter, view = "accepted") {
50590
50889
  for (const run of block.runs) {
50591
50890
  if (run.type !== "text") {
50592
50891
  flush(offset2);
50892
+ offset2 += runViewText(run, view).length;
50593
50893
  continue;
50594
50894
  }
50595
50895
  if (!isRunVisibleInView(run.trackedChange?.kind, view))
@@ -50694,15 +50994,23 @@ function collectMatches(blocks, matcher, view, out) {
50694
50994
  }
50695
50995
  function paragraphTextForView(paragraph, view) {
50696
50996
  let out = "";
50697
- for (const run of paragraph.runs) {
50698
- if (run.type !== "text")
50699
- continue;
50700
- if (!isRunVisibleInView(run.trackedChange?.kind, view))
50701
- continue;
50702
- out += run.text;
50703
- }
50997
+ for (const run of paragraph.runs)
50998
+ out += runViewText(run, view);
50704
50999
  return out;
50705
51000
  }
51001
+ function runViewText(run, view) {
51002
+ if (run.type === "text") {
51003
+ return isRunVisibleInView(run.trackedChange?.kind, view) ? run.text : "";
51004
+ }
51005
+ if (run.type === "tab") {
51006
+ return isRunVisibleInView(run.trackedChange?.kind, view) ? "\t" : "";
51007
+ }
51008
+ if (run.type === "break") {
51009
+ return run.kind === "line" && isRunVisibleInView(run.trackedChange?.kind, view) ? `
51010
+ ` : "";
51011
+ }
51012
+ return "";
51013
+ }
50706
51014
  function isRunVisibleInView(kind, view) {
50707
51015
  if (view === "current")
50708
51016
  return true;
@@ -50728,10 +51036,20 @@ function normalizeQuery(query) {
50728
51036
  applied.push("dashes");
50729
51037
  result = dashNormalized;
50730
51038
  }
51039
+ const whitespaceNormalized = normalizeWhitespace(result);
51040
+ if (whitespaceNormalized !== result) {
51041
+ applied.push("whitespace");
51042
+ result = whitespaceNormalized;
51043
+ }
51044
+ const bulletNormalized = normalizeBullets(result);
51045
+ if (bulletNormalized !== result) {
51046
+ applied.push("bullets");
51047
+ result = bulletNormalized;
51048
+ }
50731
51049
  return { normalized: result, applied };
50732
51050
  }
50733
51051
  function normalizeHaystack(text2) {
50734
- return normalizeDashes(normalizeQuotes(text2));
51052
+ return normalizeBullets(normalizeWhitespace(normalizeDashes(normalizeQuotes(text2))));
50735
51053
  }
50736
51054
  function normalizeQuotes(text2) {
50737
51055
  return text2.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"');
@@ -50739,6 +51057,12 @@ function normalizeQuotes(text2) {
50739
51057
  function normalizeDashes(text2) {
50740
51058
  return text2.replace(/[\u2013\u2014]/g, "-");
50741
51059
  }
51060
+ function normalizeWhitespace(text2) {
51061
+ return text2.replace(/[\t\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g, " ");
51062
+ }
51063
+ function normalizeBullets(text2) {
51064
+ return text2.replace(/[\u00B7\u2022\u2023\u2219\u25AA\u25E6]/g, "\u2022");
51065
+ }
50742
51066
  function stripBalancedMarkdownEmphasis(text2) {
50743
51067
  const patterns = [
50744
51068
  /\*\*(\S(?:.*?\S)?)\*\*/g,
@@ -50758,17 +51082,15 @@ function trackedChangesOverlapping(paragraph, start, end, view) {
50758
51082
  const out = [];
50759
51083
  let offset2 = 0;
50760
51084
  for (const run of paragraph.runs) {
50761
- if (run.type !== "text")
50762
- continue;
50763
- if (!isRunVisibleInView(run.trackedChange?.kind, view))
51085
+ const length = runViewText(run, view).length;
51086
+ if (length === 0)
50764
51087
  continue;
50765
- const length = run.text.length;
50766
51088
  const runStart = offset2;
50767
51089
  const runEnd = offset2 + length;
50768
51090
  offset2 = runEnd;
50769
51091
  if (runEnd <= start || runStart >= end)
50770
51092
  continue;
50771
- const change = run.trackedChange;
51093
+ const change = run.type === "text" || run.type === "tab" || run.type === "break" ? run.trackedChange : undefined;
50772
51094
  if (!change)
50773
51095
  continue;
50774
51096
  if (seen.has(change.id))
@@ -50780,6 +51102,7 @@ function trackedChangesOverlapping(paragraph, start, end, view) {
50780
51102
  }
50781
51103
  var init_find = __esm(() => {
50782
51104
  init_body();
51105
+ init_replace_across();
50783
51106
  init_replace_span();
50784
51107
  });
50785
51108
 
@@ -83138,6 +83461,15 @@ function invalidLineSpacing(raw) {
83138
83461
  hint: "Use a multiple (1, 1.5, 2), a name (single, double), or an exact point value (e.g. 15pt, or '15pt atLeast')."
83139
83462
  };
83140
83463
  }
83464
+ function spanLocator(match) {
83465
+ if ("blockId" in match) {
83466
+ return `${match.blockId}:${match.start}-${match.end}`;
83467
+ }
83468
+ if (match.startBlockId === match.endBlockId) {
83469
+ return `${match.startBlockId}:${match.startOffset}-${match.endOffset}`;
83470
+ }
83471
+ return `${match.startBlockId}:${match.startOffset}-${match.endBlockId}:${match.endOffset}`;
83472
+ }
83141
83473
  var PAGE_SIZES, RUN_FORMAT_FLAGS, TWIPS_PER_POINT = 20, TWIPS_PER_INCH = 1440, LINE_UNITS_PER_MULTIPLE = 240;
83142
83474
  var init_parse_helpers = __esm(() => {
83143
83475
  init_core2();
@@ -87186,7 +87518,7 @@ async function run15(args) {
87186
87518
  return EXIT2.OK;
87187
87519
  }
87188
87520
  const path2 = parsed.positionals[0];
87189
- const query = parsed.positionals[1];
87521
+ const query = decodeInlineEscapes(parsed.positionals[1]);
87190
87522
  if (!path2)
87191
87523
  return fail("USAGE", "Missing FILE argument", HELP11);
87192
87524
  const formatFilter = {};
@@ -87222,56 +87554,63 @@ async function run15(args) {
87222
87554
  const document4 = await openOrFail(path2);
87223
87555
  if (typeof document4 === "number")
87224
87556
  return document4;
87225
- let result = { matches: [] };
87226
- if (hasFormatFilter) {
87227
- result = {
87228
- matches: findFormattedSpans(document4.body, formatFilter, findView)
87229
- };
87230
- } else if (query != null) {
87231
- try {
87232
- result = findTextSpans(document4.body, query, {
87557
+ let matches = [];
87558
+ let normalizationFields = {};
87559
+ try {
87560
+ if (query?.includes(`
87561
+ `)) {
87562
+ const result = findAcrossParagraphs(document4.body, query, {
87233
87563
  regex: useRegex,
87234
87564
  ignoreCase,
87235
87565
  view: findView,
87236
87566
  exact
87237
87567
  });
87238
- } catch (matcherError) {
87239
- const message = matcherError instanceof Error ? matcherError.message : String(matcherError);
87240
- return fail("USAGE", `Invalid query: ${message}`);
87568
+ matches = result.matches.map((match) => ({
87569
+ locator: spanLocator(match),
87570
+ ...match
87571
+ }));
87572
+ normalizationFields = normalizationPayload(result);
87573
+ } else if (hasFormatFilter) {
87574
+ matches = findFormattedSpans(document4.body, formatFilter, findView).map((match) => ({ locator: spanLocator(match), ...match }));
87575
+ } else if (query != null) {
87576
+ const result = findTextSpans(document4.body, query, {
87577
+ regex: useRegex,
87578
+ ignoreCase,
87579
+ view: findView,
87580
+ exact
87581
+ });
87582
+ matches = result.matches.map((match) => ({
87583
+ locator: spanLocator(match),
87584
+ ...match
87585
+ }));
87586
+ normalizationFields = normalizationPayload(result);
87241
87587
  }
87588
+ } catch (matcherError) {
87589
+ const message = matcherError instanceof Error ? matcherError.message : String(matcherError);
87590
+ return fail("USAGE", `Invalid query: ${message}`);
87242
87591
  }
87243
- const allMatches = result.matches;
87244
- let selected;
87592
+ let selected = matches;
87245
87593
  if (nth !== undefined) {
87246
- const single = allMatches[nth];
87594
+ const single = matches[nth];
87247
87595
  if (!single) {
87248
- return fail("MATCH_NOT_FOUND", `Only ${allMatches.length} match(es); --nth ${nth} is out of range`);
87596
+ return fail("MATCH_NOT_FOUND", `Only ${matches.length} match(es); --nth ${nth} is out of range`);
87249
87597
  }
87250
87598
  selected = [single];
87251
- } else {
87252
- selected = allMatches;
87253
87599
  }
87254
- const matches = selected.map((match) => ({
87255
- locator: `${match.blockId}:${match.start}-${match.end}`,
87256
- ...match
87257
- }));
87258
87600
  if (parsed.values.json) {
87259
87601
  await respond({
87260
- totalMatches: allMatches.length,
87602
+ totalMatches: matches.length,
87261
87603
  query,
87262
87604
  regex: useRegex,
87263
87605
  ignoreCase,
87264
87606
  view: findView,
87265
- matches,
87266
- ...result.normalizedQuery !== undefined ? {
87267
- normalizedQuery: result.normalizedQuery,
87268
- normalizationApplied: result.normalizationApplied
87269
- } : {}
87607
+ matches: selected,
87608
+ ...normalizationFields
87270
87609
  });
87271
87610
  return EXIT2.OK;
87272
87611
  }
87273
- if (matches.length > 0) {
87274
- await writeStdout(`${matches.map((match) => match.locator).join(`
87612
+ if (selected.length > 0) {
87613
+ await writeStdout(`${selected.map((match) => match.locator).join(`
87275
87614
  `)}
87276
87615
  `);
87277
87616
  } else {
@@ -87280,6 +87619,12 @@ async function run15(args) {
87280
87619
  }
87281
87620
  return EXIT2.OK;
87282
87621
  }
87622
+ function normalizationPayload(result) {
87623
+ return result.normalizedQuery !== undefined ? {
87624
+ normalizedQuery: result.normalizedQuery,
87625
+ normalizationApplied: result.normalizationApplied
87626
+ } : {};
87627
+ }
87283
87628
  var HELP11 = `docx find \u2014 locate text spans and return their locators
87284
87629
 
87285
87630
  Usage:
@@ -87317,15 +87662,21 @@ Options:
87317
87662
  --json emit the full match objects as JSON (default: bare locators)
87318
87663
  -h, --help show this help
87319
87664
 
87320
- Within-paragraph matches only \u2014 cross-paragraph ranges aren't supported
87321
- yet. Searches the concatenated text of each paragraph in document order,
87322
- including paragraphs nested in table cells (locators look like
87323
- tT:rRcC:pK:S-E for those).
87665
+ Searches the concatenated text of each paragraph in document order, including
87666
+ paragraphs nested in table cells (locators look like tT:rRcC:pK:S-E for those).
87667
+ A TAB in the document matches as one character, and an in-paragraph line break
87668
+ matches "\\n". A QUERY containing "\\n" may span lines: each "\\n" matches a
87669
+ line break OR a paragraph boundary (consecutive paragraphs in the body or
87670
+ within ONE table cell \u2014 never across a table, section break, or cell wall). A spanning
87671
+ match prints as pA:S-pB:E \u2014 the cross-paragraph range form \`comments add
87672
+ --at\` accepts, so a spanning find pipes straight into a spanning comment.
87324
87673
 
87325
87674
  By default the query is normalized: balanced markdown emphasis around
87326
87675
  non-whitespace (**X**, __X__, *X*, \`X\`) is stripped; smart quotes match
87327
- straight quotes; em-dash and en-dash match the hyphen. Pass --exact to
87328
- match the raw query verbatim. --regex is always verbatim.
87676
+ straight quotes; em-dash and en-dash match the hyphen; a TAB or non-breaking/
87677
+ typographic space matches a plain space; bullet glyph variants (\xB7\u2022\u2023\u2219\u25AA\u25E6) match
87678
+ each other. Pass --exact to match the raw query verbatim. --regex is always
87679
+ verbatim. \\n and \\t in the QUERY are decoded to real characters first.
87329
87680
 
87330
87681
  Output:
87331
87682
  Default: EVERY matched span locator (e.g. p3:5-8), one per line \u2014 feed them
@@ -90261,10 +90612,19 @@ export type ImageRun = {
90261
90612
  export type BreakRun = {
90262
90613
  type: "break";
90263
90614
  kind: "page" | "line" | "column";
90615
+ /** Set when the break sits inside a tracked-change wrapper (\`<w:ins>\`/
90616
+ * \`<w:del>\`/\u2026). A LINE break is one offset character (it renders as "\\n"),
90617
+ * so \`find\`/\`replace\` must know when to hide it in the accepted/baseline
90618
+ * view, exactly as they do for a deleted \`TextRun\`. */
90619
+ trackedChange?: TrackedChange;
90264
90620
  };
90265
90621
 
90266
90622
  export type TabRun = {
90267
90623
  type: "tab";
90624
+ /** Set when the tab sits inside a tracked-change wrapper. A tab is one offset
90625
+ * character (it renders as "\\t"), so its view-visibility must track with the
90626
+ * surrounding revision \u2014 see \`BreakRun.trackedChange\`. */
90627
+ trackedChange?: TrackedChange;
90268
90628
  };
90269
90629
 
90270
90630
  /** A math equation. \`latex\` is reconstructed by walking the underlying
@@ -93106,6 +93466,98 @@ var init_render2 = __esm(() => {
93106
93466
  };
93107
93467
  });
93108
93468
 
93469
+ // src/cli/replace/across.ts
93470
+ async function runReplaceAcross(path2, pattern, replacement, options) {
93471
+ const document4 = await openOrFail(path2);
93472
+ if (typeof document4 === "number")
93473
+ return document4;
93474
+ if (resolveTracked(document4, options.track)) {
93475
+ return fail("USAGE", ACROSS_TRACKED_MESSAGE, ACROSS_TRACKED_HINT);
93476
+ }
93477
+ let result;
93478
+ try {
93479
+ result = applyAcrossReplace(document4.body, {
93480
+ pattern,
93481
+ replacement,
93482
+ regex: options.regex,
93483
+ ignoreCase: options.ignoreCase,
93484
+ exact: options.exact,
93485
+ all: options.all,
93486
+ ...options.limit !== undefined ? { limit: options.limit } : {},
93487
+ view: options.view
93488
+ }, { dryRun: options.dryRun });
93489
+ } catch (matcherError) {
93490
+ const message = matcherError instanceof Error ? matcherError.message : String(matcherError);
93491
+ return fail("USAGE", `Invalid pattern: ${message}`);
93492
+ }
93493
+ const matchesPayload = result.replaced.map((match) => ({
93494
+ locator: spanLocator(match),
93495
+ text: match.text
93496
+ }));
93497
+ const normalizationFields = result.normalizedQuery !== undefined ? {
93498
+ normalizedPattern: result.normalizedQuery,
93499
+ normalizationApplied: result.normalizationApplied
93500
+ } : {};
93501
+ if (options.dryRun) {
93502
+ await respond({
93503
+ operation: "replace",
93504
+ dryRun: true,
93505
+ path: path2,
93506
+ pattern,
93507
+ replacement,
93508
+ regex: options.regex,
93509
+ ignoreCase: options.ignoreCase,
93510
+ view: options.view,
93511
+ crossParagraph: true,
93512
+ totalMatches: result.totalMatches,
93513
+ replaced: result.replaced.length,
93514
+ matches: matchesPayload,
93515
+ ...normalizationFields,
93516
+ ...options.output ? { output: options.output } : {}
93517
+ });
93518
+ return EXIT2.OK;
93519
+ }
93520
+ if (result.replaced.length === 0) {
93521
+ return await fail("MATCH_NOT_FOUND", `Pattern not found: ${JSON.stringify(pattern)} \u2014 0 occurrences, nothing changed.`, `Each \\n in the pattern matches a line break or the boundary between CONSECUTIVE paragraphs (never across a table, section break, or cell wall). \`docx read ${path2}\` shows the lines \u2014 check the exact text on each side of every \\n.`);
93522
+ }
93523
+ await document4.save(options.output);
93524
+ const hints = [
93525
+ "\u21B3 paragraph structure changed \u2014 block ids shifted; re-read before locator-addressed edits."
93526
+ ];
93527
+ const partialHint = partialReplaceHint(result.replaced.length, result.totalMatches);
93528
+ if (partialHint)
93529
+ hints.push(partialHint);
93530
+ await respondAck({
93531
+ ok: true,
93532
+ operation: "replace",
93533
+ path: options.output ?? path2,
93534
+ pattern,
93535
+ replacement,
93536
+ regex: options.regex,
93537
+ ignoreCase: options.ignoreCase,
93538
+ view: options.view,
93539
+ crossParagraph: true,
93540
+ totalMatches: result.totalMatches,
93541
+ replaced: result.replaced.length,
93542
+ matches: matchesPayload,
93543
+ ...normalizationFields
93544
+ }, hints.join(`
93545
+ `));
93546
+ return EXIT2.OK;
93547
+ }
93548
+ function partialReplaceHint(replaced, total) {
93549
+ const remaining = total - replaced;
93550
+ if (remaining <= 0)
93551
+ return;
93552
+ return `\u21B3 ${remaining} more match${remaining === 1 ? "" : "es"} left unreplaced (${replaced} of ${total} done) \u2014 pass --all to replace every match, or --limit N for a specific count.`;
93553
+ }
93554
+ var ACROSS_TRACKED_MESSAGE = "a replace that crosses or inserts paragraph boundaries can't be recorded as a tracked change yet (OOXML tracks a merge/split on the paragraph mark, which this path doesn't emit) \u2014 it will NOT run silently untracked", ACROSS_TRACKED_HINT = "Turn tracking off (docx track-changes off FILE) and re-run, or make within-paragraph replaces (no \\n) which track fine.";
93555
+ var init_across = __esm(() => {
93556
+ init_find();
93557
+ init_parse_helpers();
93558
+ init_respond();
93559
+ });
93560
+
93109
93561
  // src/cli/replace/scope.ts
93110
93562
  function validateScopeShape(at) {
93111
93563
  let parsed;
@@ -93188,6 +93640,36 @@ async function runReplaceBatch(filePath, batchSource, values2) {
93188
93640
  const spec = specs[index2];
93189
93641
  if (!spec)
93190
93642
  continue;
93643
+ if (spec.pattern.includes(`
93644
+ `) || spec.replacement.includes(`
93645
+ `)) {
93646
+ if (spec.at !== undefined) {
93647
+ return fail("USAGE", `entry ${index2}: "at" can't scope a multi-line pattern \u2014 it spans paragraph boundaries`);
93648
+ }
93649
+ if (allocator) {
93650
+ return fail("USAGE", `entry ${index2}: ${ACROSS_TRACKED_MESSAGE}`, ACROSS_TRACKED_HINT);
93651
+ }
93652
+ let acrossResult;
93653
+ try {
93654
+ acrossResult = applyAcrossReplace(document4.body, spec);
93655
+ } catch (matcherError) {
93656
+ const message = matcherError instanceof Error ? matcherError.message : String(matcherError);
93657
+ return fail("USAGE", `entry ${index2}: invalid pattern: ${message}`);
93658
+ }
93659
+ results.push({
93660
+ pattern: spec.pattern,
93661
+ replacement: spec.replacement,
93662
+ totalMatches: acrossResult.totalMatches,
93663
+ replaced: acrossResult.replaced.length,
93664
+ matches: acrossResult.replaced.map((match) => ({
93665
+ locator: spanLocator(match),
93666
+ text: match.text
93667
+ }))
93668
+ });
93669
+ if (acrossResult.replaced.length > 0)
93670
+ document4.reread();
93671
+ continue;
93672
+ }
93191
93673
  if (spec.at !== undefined) {
93192
93674
  try {
93193
93675
  document4.body.resolveBlock(spec.at);
@@ -93208,7 +93690,7 @@ async function runReplaceBatch(filePath, batchSource, values2) {
93208
93690
  return fail("USAGE", `entry ${index2}: invalid pattern: ${message}`);
93209
93691
  }
93210
93692
  const all2 = spec.at !== undefined ? matchesInScope(findResult.matches, spec.at) : findResult.matches;
93211
- const selected = spec.limit !== undefined ? all2.slice(0, spec.limit) : spec.all ? all2 : all2.slice(0, 1);
93693
+ const selected = selectMatches(all2, spec);
93212
93694
  const tracked = allocator ? {
93213
93695
  meta: {
93214
93696
  author: resolveAuthor(spec.author ?? authorFlag),
@@ -93222,11 +93704,10 @@ async function runReplaceBatch(filePath, batchSource, values2) {
93222
93704
  }
93223
93705
  return right.start - left.start;
93224
93706
  });
93225
- const regexFlags = spec.ignoreCase ? "i" : "";
93707
+ const expand = replacementExpander(spec);
93226
93708
  for (const match of reversed) {
93227
- const concreteReplacement = spec.regex ? match.text.replace(new RegExp(spec.pattern, regexFlags), spec.replacement) : spec.replacement;
93228
93709
  const blockRef = document4.body.resolveBlock(match.blockId);
93229
- replaceSpanInParagraph(blockRef.node, { start: match.start, end: match.end }, concreteReplacement, tracked, spec.view);
93710
+ replaceSpanInParagraph(blockRef.node, { start: match.start, end: match.end }, expand(match.text), tracked, spec.view);
93230
93711
  }
93231
93712
  results.push({
93232
93713
  pattern: spec.pattern,
@@ -93234,11 +93715,12 @@ async function runReplaceBatch(filePath, batchSource, values2) {
93234
93715
  totalMatches: all2.length,
93235
93716
  replaced: selected.length,
93236
93717
  matches: selected.map((match) => ({
93237
- locator: `${match.blockId}:${match.start}-${match.end}`,
93718
+ locator: spanLocator(match),
93238
93719
  text: match.text
93239
93720
  }))
93240
93721
  });
93241
- document4.reread();
93722
+ if (selected.length > 0)
93723
+ document4.reread();
93242
93724
  }
93243
93725
  if (dryRun) {
93244
93726
  await respond({
@@ -93319,6 +93801,7 @@ var init_batch4 = __esm(() => {
93319
93801
  init_find();
93320
93802
  init_parse_helpers();
93321
93803
  init_respond();
93804
+ init_across();
93322
93805
  init_scope();
93323
93806
  EntryError3 = class EntryError3 extends Error {
93324
93807
  code;
@@ -93372,8 +93855,8 @@ async function run40(args) {
93372
93855
  }
93373
93856
  return runReplaceBatch(path2, batchInput, parsed.values);
93374
93857
  }
93375
- const pattern = parsed.positionals[1];
93376
- const replacement = parsed.positionals[2];
93858
+ const pattern = decodeInlineEscapes(parsed.positionals[1]);
93859
+ const replacement = decodeInlineEscapes(parsed.positionals[2]);
93377
93860
  if (pattern == null)
93378
93861
  return fail("USAGE", "Missing PATTERN argument", HELP36);
93379
93862
  if (replacement == null) {
@@ -93392,6 +93875,24 @@ async function run40(args) {
93392
93875
  if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
93393
93876
  return fail("USAGE", `--limit must be a positive integer, got "${limitRaw}"`);
93394
93877
  }
93878
+ if (pattern.includes(`
93879
+ `) || replacement.includes(`
93880
+ `)) {
93881
+ if (parsed.values.at !== undefined) {
93882
+ return fail("USAGE", "--at scopes a replace to ONE paragraph, but a \\n-bearing pattern/replacement spans paragraph boundaries \u2014 drop --at, or remove the \\n");
93883
+ }
93884
+ return runReplaceAcross(path2, pattern, replacement, {
93885
+ regex: useRegex,
93886
+ ignoreCase,
93887
+ exact,
93888
+ all: wantAll,
93889
+ ...limit !== undefined ? { limit } : {},
93890
+ view: findView,
93891
+ track: Boolean(parsed.values.track),
93892
+ ...parsed.values.output !== undefined ? { output: parsed.values.output } : {},
93893
+ dryRun: Boolean(parsed.values["dry-run"])
93894
+ });
93895
+ }
93395
93896
  const document4 = await openOrFail(path2);
93396
93897
  if (typeof document4 === "number")
93397
93898
  return document4;
@@ -93424,16 +93925,9 @@ async function run40(args) {
93424
93925
  normalizedPattern: findResult.normalizedQuery,
93425
93926
  normalizationApplied: findResult.normalizationApplied
93426
93927
  } : {};
93427
- let selected;
93428
- if (limit !== undefined) {
93429
- selected = allMatches.slice(0, limit);
93430
- } else if (wantAll) {
93431
- selected = allMatches;
93432
- } else {
93433
- selected = allMatches.slice(0, 1);
93434
- }
93928
+ const selected = selectMatches(allMatches, { all: wantAll, limit });
93435
93929
  const matchesPayload = selected.map((match) => ({
93436
- locator: `${match.blockId}:${match.start}-${match.end}`,
93930
+ locator: spanLocator(match),
93437
93931
  blockId: match.blockId,
93438
93932
  start: match.start,
93439
93933
  end: match.end,
@@ -93474,15 +93968,18 @@ async function run40(args) {
93474
93968
  meta: { author: resolveAuthor(authorFlag), date: resolveDate() },
93475
93969
  allocator: new TrackChanges(document4).createAllocator()
93476
93970
  } : undefined;
93477
- const regexFlags = ignoreCase ? "i" : "";
93971
+ const expand = replacementExpander({
93972
+ pattern,
93973
+ replacement,
93974
+ regex: useRegex,
93975
+ ignoreCase
93976
+ });
93478
93977
  for (const match of reversed) {
93479
- const concreteReplacement = useRegex ? match.text.replace(new RegExp(pattern, regexFlags), replacement) : replacement;
93480
93978
  const blockRef = document4.body.resolveBlock(match.blockId);
93481
- replaceSpanInParagraph(blockRef.node, { start: match.start, end: match.end }, concreteReplacement, tracked, findView);
93979
+ replaceSpanInParagraph(blockRef.node, { start: match.start, end: match.end }, expand(match.text), tracked, findView);
93482
93980
  }
93483
93981
  await document4.save(outputPath);
93484
- const remaining = allMatches.length - selected.length;
93485
- const partialHint = remaining > 0 ? `\u21B3 ${remaining} more match${remaining === 1 ? "" : "es"} left unreplaced (${selected.length} of ${allMatches.length} done) \u2014 pass --all to replace every match, or --limit N for a specific count.` : undefined;
93982
+ const partialHint = partialReplaceHint(selected.length, allMatches.length);
93486
93983
  await respondAck({
93487
93984
  ok: true,
93488
93985
  operation: "replace",
@@ -93534,17 +94031,32 @@ Options:
93534
94031
  -v, --verbose print the success ack JSON (default: a one-line confirmation)
93535
94032
  -h, --help show this help
93536
94033
 
93537
- Within-paragraph matches only. Run formatting (rPr) on the surrounding text
93538
- is preserved; the replacement run inherits the rPr of the first run that
93539
- overlaps the matched span. Tabs and other runs in the paragraph are left in
93540
- place \u2014 only the matched text changes. So this is the no-rebuild way to FILL a
93541
- formatted/tabbed template line: \`replace "Organization Name" "Northwind Robotics"\`
94034
+ Run formatting (rPr) on the surrounding text is preserved; the replacement
94035
+ run inherits the rPr of the first run that overlaps the matched span. Tabs
94036
+ and other runs in the paragraph are left in place \u2014 only the matched text
94037
+ changes (a TAB matches as one character, so a pattern typed with spaces fills
94038
+ a tab-separated line). So this is the no-rebuild way to FILL a formatted/
94039
+ tabbed template line: \`replace "Organization Name" "Northwind Robotics"\`
93542
94040
  on a "**Organization Name**\u21E5Date" line keeps the bold and the tab; don't
93543
94041
  hand-build \`edit --runs\` JSON to refill a line. \`--batch\` fills many at once.
93544
94042
  When a single invocation produces multiple replacements in the same paragraph,
93545
94043
  they're applied in reverse offset order so earlier offsets don't shift before
93546
94044
  being applied.
93547
94045
 
94046
+ Multi-line (editor-style): \\n and \\t in PATTERN/REPLACEMENT are decoded to
94047
+ real characters. A "\\n" in the PATTERN matches an in-paragraph line break OR
94048
+ the boundary between consecutive paragraphs \u2014 in the body or within one table
94049
+ cell (never across a table, section break, or cell wall); the REPLACEMENT's
94050
+ newlines then define the resulting
94051
+ structure, exactly as if the span were selected in Word and the replacement
94052
+ typed \u2014 a single-line replacement MERGES the spanned paragraphs into one
94053
+ (first paragraph's formatting governs), "\\n"s in the replacement keep/create
94054
+ paragraph marks (so a "\\n" in the replacement splits a paragraph even when
94055
+ the pattern didn't cross one). \`replace FILE "\\n" "" --all\` merges every
94056
+ paragraph pair. Cross-paragraph replaces can't be tracked yet: with tracking
94057
+ on (or --track) they refuse loudly rather than skip the journal \u2014 turn
94058
+ tracking off first. Block ids shift afterward; re-read before more edits.
94059
+
93548
94060
  By default the PATTERN is normalized: balanced markdown emphasis around
93549
94061
  non-whitespace (**X**, __X__, *X*, \`X\`) is stripped; smart quotes match
93550
94062
  straight quotes; em-dash and en-dash match the hyphen. The REPLACEMENT
@@ -93591,6 +94103,7 @@ var init_replace4 = __esm(() => {
93591
94103
  init_find();
93592
94104
  init_parse_helpers();
93593
94105
  init_respond();
94106
+ init_across();
93594
94107
  init_batch4();
93595
94108
  init_scope();
93596
94109
  });
@@ -98015,7 +98528,7 @@ Examples:
98015
98528
  // package.json
98016
98529
  var package_default = {
98017
98530
  name: "bun-docx",
98018
- version: "0.20.3",
98531
+ version: "0.20.4",
98019
98532
  description: "Read, edit, redline, and comment on Microsoft Word .docx files from the command line \u2014 built for AI agents.",
98020
98533
  keywords: [
98021
98534
  "docx",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-docx",
3
- "version": "0.20.3",
3
+ "version": "0.20.4",
4
4
  "description": "Read, edit, redline, and comment on Microsoft Word .docx files from the command line — built for AI agents.",
5
5
  "keywords": [
6
6
  "docx",