@adeu/core 1.25.0 → 1.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { unzipSync } from "fflate";
1
2
  import diff_match_patch from "diff-match-patch";
2
3
  import { DeleteTableRow, InsertTableRow, ModifyText } from "./models.js";
3
4
  import type {
@@ -475,6 +476,55 @@ function _drop_image_marker_hunks(
475
476
  return kept;
476
477
  }
477
478
 
479
+ /**
480
+ * Removes generated hunks whose pinned range cuts INTO a read-only image
481
+ * marker without covering it whole — the shape an alt-text-only difference
482
+ * produces (`RED` -> `BLUE` inside `![RED logo](docx-image:1)`). The engine
483
+ * categorically rejects such edits, so emitting them makes diff output
484
+ * unappliable by apply (QA 2026-07-19 F-04/F-14). Hunks that contain
485
+ * complete markers are judged by _drop_image_marker_hunks instead.
486
+ */
487
+ function _drop_marker_interior_hunks(
488
+ edits: ModifyText[],
489
+ text_orig: string,
490
+ warnings: string[],
491
+ ): ModifyText[] {
492
+ const marker_spans: [number, number][] = [];
493
+ for (const m of text_orig.matchAll(_IMAGE_MARKER_RE)) {
494
+ marker_spans.push([m.index!, m.index! + m[0].length]);
495
+ }
496
+ if (marker_spans.length === 0) return edits;
497
+
498
+ const kept: ModifyText[] = [];
499
+ let warned = false;
500
+ for (const e of edits) {
501
+ const idx = e._match_start_index;
502
+ if (idx === undefined || idx === null) {
503
+ kept.push(e);
504
+ continue;
505
+ }
506
+ const end = idx + (e.target_text || "").length;
507
+ const cuts_into_marker = marker_spans.some(
508
+ ([m_start, m_end]) =>
509
+ m_start < end && idx < m_end && !(idx <= m_start && m_end <= end),
510
+ );
511
+ if (!cuts_into_marker) {
512
+ kept.push(e);
513
+ continue;
514
+ }
515
+ if (!warned) {
516
+ warnings.push(
517
+ "An image's alternative text differs between the documents. Image markers " +
518
+ "(![alt](docx-image:N)) are read-only projections, so this difference cannot be " +
519
+ "expressed as a text edit — it was skipped; update the image or its alt text " +
520
+ "manually in Word.",
521
+ );
522
+ warned = true;
523
+ }
524
+ }
525
+ return kept;
526
+ }
527
+
478
528
  /**
479
529
  * True when every projected row reads exactly as its cells joined by " | ".
480
530
  * Rows wrapped in tracked-row CriticMarkup ({++ … ++} / {-- … --}) do not,
@@ -704,8 +754,12 @@ export function generate_structured_edits(
704
754
  `${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer ` +
705
755
  "additions or removals cannot be expressed as text edits.",
706
756
  );
707
- const flat = _drop_image_marker_hunks(
708
- generate_edits_from_text(text_orig, text_mod),
757
+ const flat = _drop_marker_interior_hunks(
758
+ _drop_image_marker_hunks(
759
+ generate_edits_from_text(text_orig, text_mod),
760
+ warnings,
761
+ ),
762
+ text_orig,
709
763
  warnings,
710
764
  );
711
765
  return { edits: [...flat], warnings };
@@ -848,11 +902,18 @@ export function generate_structured_edits(
848
902
  }
849
903
 
850
904
  // Our own output must never trip the engine's read-only image-marker
851
- // validation: an added/removed image becomes a warning, not an edit.
905
+ // validation: an added/removed image becomes a warning, not an edit
906
+ // and so does an alt-text change, whose hunk lands INSIDE a marker.
852
907
  const modify_edits = edits.filter(
853
908
  (e): e is ModifyText => e.type === "modify",
854
909
  );
855
- const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
910
+ const kept_modifies = new Set(
911
+ _drop_marker_interior_hunks(
912
+ _drop_image_marker_hunks(modify_edits, warnings),
913
+ text_orig,
914
+ warnings,
915
+ ),
916
+ );
856
917
  const final_edits = edits.filter(
857
918
  (e) => e.type !== "modify" || kept_modifies.has(e as ModifyText),
858
919
  );
@@ -860,6 +921,70 @@ export function generate_structured_edits(
860
921
  return { edits: final_edits, warnings };
861
922
  }
862
923
 
924
+ /**
925
+ * Compares embedded media bytes (word/media/*) between two DOCX packages.
926
+ * Adeu's diff is a text comparison: two documents whose images differ but
927
+ * whose projections agree produce an empty diff, which reads as "visually
928
+ * identical" unless the caller says otherwise (QA 2026-07-19 F-04). Returns
929
+ * warning strings describing changed/added/removed media members; empty when
930
+ * the media sets are byte-identical (or either package is unreadable — the
931
+ * caller has already surfaced package-level errors).
932
+ */
933
+ export function collect_media_difference_warnings(
934
+ original_docx: Uint8Array,
935
+ modified_docx: Uint8Array,
936
+ ): string[] {
937
+ const media_hashes = (data: Uint8Array): Map<string, string> => {
938
+ const hashes = new Map<string, string>();
939
+ try {
940
+ const unzipped = unzipSync(data);
941
+ for (const [name, bytes] of Object.entries(unzipped)) {
942
+ if (!name.startsWith("word/media/")) continue;
943
+ // FNV-1a over the bytes: cheap, dependency-free content fingerprint.
944
+ let h1 = 0x811c9dc5;
945
+ let h2 = 0xcbf29ce4;
946
+ for (let i = 0; i < bytes.length; i++) {
947
+ h1 = Math.imul(h1 ^ bytes[i], 0x01000193) >>> 0;
948
+ h2 = Math.imul(h2 ^ bytes[bytes.length - 1 - i], 0x01000193) >>> 0;
949
+ }
950
+ hashes.set(name, `${bytes.length}:${h1.toString(16)}:${h2.toString(16)}`);
951
+ }
952
+ } catch {
953
+ return new Map();
954
+ }
955
+ return hashes;
956
+ };
957
+
958
+ const hashes_orig = media_hashes(original_docx);
959
+ const hashes_mod = media_hashes(modified_docx);
960
+
961
+ const changed: string[] = [];
962
+ const added: string[] = [];
963
+ const removed: string[] = [];
964
+ for (const [name, hash] of hashes_orig) {
965
+ if (!hashes_mod.has(name)) removed.push(name);
966
+ else if (hashes_mod.get(name) !== hash) changed.push(name);
967
+ }
968
+ for (const name of hashes_mod.keys()) {
969
+ if (!hashes_orig.has(name)) added.push(name);
970
+ }
971
+ changed.sort();
972
+ added.sort();
973
+ removed.sort();
974
+ if (changed.length + added.length + removed.length === 0) return [];
975
+
976
+ const parts: string[] = [];
977
+ if (changed.length) parts.push(`${changed.length} changed`);
978
+ if (added.length) parts.push(`${added.length} added`);
979
+ if (removed.length) parts.push(`${removed.length} removed`);
980
+ const names = [...changed, ...added, ...removed].slice(0, 5).join(", ");
981
+ return [
982
+ `The documents' embedded media differ (${parts.join(", ")}: ${names}). This diff compares ` +
983
+ "TEXT only — an empty edit list does not mean the documents are visually identical. " +
984
+ "Image changes must be applied manually in Word.",
985
+ ];
986
+ }
987
+
863
988
  export function create_unified_diff(
864
989
  original_text: string,
865
990
  modified_text: string,
package/src/engine.ts CHANGED
@@ -437,6 +437,26 @@ export class RedlineEngine {
437
437
  raw_sub_edits = [];
438
438
  }
439
439
 
440
+ // Hunks made purely of style markers are projection artifacts, never
441
+ // user intent: they arise when a PLAIN target fuzzy-matched styled
442
+ // document text ("Net 90 Days" against "**Net 90 Days**"), and the
443
+ // resulting `**`-deletion sub-edits target virtual spans that can never
444
+ // apply — phantom skips while the formatting silently stays (QA
445
+ // 2026-07-19 F-02 sibling). Edits that DO declare markers never reach
446
+ // this word-diff path (they resolve as whole-span markdown proxies).
447
+ const _marker_only = (text: string): boolean => {
448
+ const stripped = text.trim();
449
+ return stripped.length > 0 && /^[*_]+$/.test(stripped);
450
+ };
451
+ raw_sub_edits = raw_sub_edits.filter(
452
+ (e: any) =>
453
+ !(
454
+ (!e.target_text || _marker_only(e.target_text)) &&
455
+ (!e.new_text || _marker_only(e.new_text)) &&
456
+ (e.target_text || e.new_text)
457
+ ),
458
+ );
459
+
440
460
  if (!raw_sub_edits || raw_sub_edits.length === 0) {
441
461
  const fallback_edit: any = {
442
462
  type: "modify",
@@ -1399,6 +1419,10 @@ export class RedlineEngine {
1399
1419
  // runs into <w:del> and replaces the originals); suffix relocation for
1400
1420
  // paragraph-splitting insertions keys on this element instead.
1401
1421
  positional_anchor_el: Element | null = null,
1422
+ // When the edit declares explicit emphasis markers, the markers are
1423
+ // authoritative: strip inherited bold/italic from the anchor style
1424
+ // (QA 2026-07-19 F-02).
1425
+ suppress_emphasis: boolean = false,
1402
1426
  ): {
1403
1427
  first_node: Element | null;
1404
1428
  last_p: Element | null;
@@ -1497,6 +1521,7 @@ export class RedlineEngine {
1497
1521
  anchor_run,
1498
1522
  reuse_id,
1499
1523
  xmlDoc,
1524
+ suppress_emphasis,
1500
1525
  );
1501
1526
  first_node = inline_ins;
1502
1527
  // Caller will attach `inline_ins` to the DOM later — keep it for now.
@@ -1582,6 +1607,7 @@ export class RedlineEngine {
1582
1607
  anchor_run,
1583
1608
  reuse_id,
1584
1609
  xmlDoc,
1610
+ suppress_emphasis,
1585
1611
  );
1586
1612
  if (content_ins) {
1587
1613
  new_p.appendChild(content_ins);
@@ -1621,6 +1647,7 @@ export class RedlineEngine {
1621
1647
  anchor_run: Run | null,
1622
1648
  reuse_id: string,
1623
1649
  xmlDoc: Document,
1650
+ suppress_emphasis: boolean = false,
1624
1651
  ): Element | null {
1625
1652
  if (!line_text && line_text !== "") return null;
1626
1653
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
@@ -1630,25 +1657,26 @@ export class RedlineEngine {
1630
1657
  }
1631
1658
  for (const [segText, segProps] of segments) {
1632
1659
  const r = xmlDoc.createElement("w:r");
1633
- // Inherit run formatting (e.g. bold from a heading style) only when we
1634
- // have an anchor run AND we are not overriding via segment props.
1660
+ // Inherit run formatting from the anchor so partial replacements inside
1661
+ // a styled span keep the style (matching Word's type-into-selection
1662
+ // behavior and the Python engine — the old blanket strip made
1663
+ // "Important" -> "Critical" inside a bold span come out unstyled).
1635
1664
  if (anchor_run && anchor_run._element) {
1636
1665
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
1637
1666
  if (anchor_rPr) {
1638
1667
  const clone = anchor_rPr.cloneNode(true) as Element;
1639
- // Strip vanish / strike to avoid invisible inserts, and emphasis
1640
- // (bold/italic) so inserted replacement text does not silently
1641
- // inherit the anchor run's character formatting (BUG-23-2). Explicit
1642
- // markdown emphasis is re-applied per-segment via _apply_run_props.
1643
- for (const tag of [
1644
- "w:vanish",
1645
- "w:strike",
1646
- "w:dstrike",
1647
- "w:i",
1648
- "w:iCs",
1649
- "w:b",
1650
- "w:bCs",
1651
- ]) {
1668
+ // Always strip vanish / strike (invisible inserts) and italic
1669
+ // (BUG-23-2: an inserted replacement must not silently inherit the
1670
+ // surrounding italic styling). Bold is preserved it usually
1671
+ // carries structural meaning (headings, defined terms) — UNLESS
1672
+ // the edit's own markers are authoritative (QA 2026-07-19 F-02):
1673
+ // `**X**` -> `_X_` must yield italic-only, `**X**` -> `X` plain.
1674
+ // Mirrors the Python engine's _track_insert_inline exactly.
1675
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
1676
+ if (suppress_emphasis) {
1677
+ strip_tags.push("w:b", "w:bCs");
1678
+ }
1679
+ for (const tag of strip_tags) {
1652
1680
  const found = findChild(clone, tag);
1653
1681
  if (found) clone.removeChild(found);
1654
1682
  }
@@ -1695,6 +1723,28 @@ export class RedlineEngine {
1695
1723
  return [text, null];
1696
1724
  }
1697
1725
 
1726
+ /**
1727
+ * True when this edit's target or replacement text carries explicit
1728
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
1729
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
1730
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
1731
+ * the replaced span's run properties on top of (or instead of) the
1732
+ * requested markers silently produces the wrong document while the report
1733
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
1734
+ * either side) keep inheriting the context style so partial replacements
1735
+ * inside a styled span never lose formatting.
1736
+ */
1737
+ private _edit_declares_emphasis(edit: any): boolean {
1738
+ for (const text of [edit?.target_text, edit?.new_text]) {
1739
+ if (!text || (!text.includes("**") && !text.includes("_"))) continue;
1740
+ const segments = this._parse_inline_markdown(text);
1741
+ if (segments.some(([, props]: [string, any]) => props && Object.keys(props).length > 0)) {
1742
+ return true;
1743
+ }
1744
+ }
1745
+ return false;
1746
+ }
1747
+
1698
1748
  private _parse_inline_markdown(
1699
1749
  text: string,
1700
1750
  baseStyle: any = {},
@@ -2037,6 +2087,23 @@ export class RedlineEngine {
2037
2087
  const is_regex = (edit as any).regex || false;
2038
2088
  const match_mode = (edit as any).match_mode || "strict";
2039
2089
 
2090
+ if (is_regex) {
2091
+ // An unparsable pattern must be diagnosed as a regex problem. Without
2092
+ // this check it falls through the matcher's silent guard and surfaces
2093
+ // as "target text not found", sending the user hunting for a typo in
2094
+ // the document instead of in the pattern (QA 2026-07-19 F-13).
2095
+ try {
2096
+ new RegExp(edit.target_text);
2097
+ } catch (regex_err: any) {
2098
+ errors.push(
2099
+ `- Edit ${i + 1 + index_offset} Failed: target_text is not a valid regular expression ` +
2100
+ `(${regex_err?.message ?? regex_err}). Fix the pattern, or set "regex": false to ` +
2101
+ "match the text literally.",
2102
+ );
2103
+ continue;
2104
+ }
2105
+ }
2106
+
2040
2107
  let matches = this.mapper.find_all_match_indices(
2041
2108
  edit.target_text,
2042
2109
  is_regex,
@@ -2855,6 +2922,14 @@ export class RedlineEngine {
2855
2922
  actions_skipped: skipped_actions,
2856
2923
  edits_applied: applied_edits,
2857
2924
  edits_skipped: skipped_edits,
2925
+ // edits_applied counts change OBJECTS; this is the total number of
2926
+ // document occurrences they modified (match_mode="all" fan-out), so
2927
+ // automation never has to guess which of the two a count means
2928
+ // (QA 2026-07-19 F-21).
2929
+ occurrences_modified: edits_reports.reduce(
2930
+ (sum: number, r: any) => sum + (r.occurrences_modified || 0),
2931
+ 0,
2932
+ ),
2858
2933
  skipped_details: this.skipped_details,
2859
2934
  edits: edits_reports,
2860
2935
  engine: "node",
@@ -2882,6 +2957,7 @@ export class RedlineEngine {
2882
2957
  }
2883
2958
  edit._applied_status = false;
2884
2959
  edit._error_msg = null;
2960
+ edit._any_sub_failure = false;
2885
2961
  }
2886
2962
 
2887
2963
  for (const edit of edits) {
@@ -3020,18 +3096,25 @@ export class RedlineEngine {
3020
3096
  this.skipped_details.push(msg);
3021
3097
  edit._applied_status = false;
3022
3098
  edit._error_msg = msg;
3099
+ edit._any_sub_failure = true;
3023
3100
  const parent = edit._parent_edit_ref;
3024
3101
  if (parent) {
3025
3102
  parent._applied_status = false;
3026
3103
  parent._error_msg = msg;
3104
+ parent._any_sub_failure = true;
3027
3105
  }
3028
3106
  continue;
3029
3107
  }
3030
3108
 
3031
3109
  let success = false;
3032
3110
  if (edit.type === "modify") {
3033
- const rebuild = edit._split_group_id !== undefined && edit._split_group_id !== null;
3034
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
3111
+ // Never rebuild the map inside the sweep: sub-edits apply in strictly
3112
+ // descending offset order, and every DOM mutation (run splits, w:del
3113
+ // wraps, w:ins insertions, bottom-up paragraph merges) happens at or
3114
+ // above the current offset, so spans below it stay valid in the stale
3115
+ // map. Rebuilding here made regex + match_mode="all"
3116
+ // O(occurrences × document) (QA 2026-07-19 F-06).
3117
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
3035
3118
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
3036
3119
  success = this._apply_table_edit(edit, false);
3037
3120
  }
@@ -3089,8 +3172,10 @@ export class RedlineEngine {
3089
3172
  this.skipped_details.push(msg);
3090
3173
  edit._applied_status = false;
3091
3174
  edit._error_msg = msg;
3175
+ edit._any_sub_failure = true;
3092
3176
  const parent = edit._parent_edit_ref;
3093
3177
  if (parent) {
3178
+ parent._any_sub_failure = true;
3094
3179
  if (!parent._applied_status) {
3095
3180
  parent._applied_status = false;
3096
3181
  parent._error_msg = msg;
@@ -3099,7 +3184,25 @@ export class RedlineEngine {
3099
3184
  }
3100
3185
  }
3101
3186
 
3102
- return [applied, skipped];
3187
+ // Return LOGICAL edit counts over the caller's input list: one
3188
+ // match_mode="all" edit over N occurrences is one applied edit (its
3189
+ // occurrence count lives in _occurrences_modified / the report), never N
3190
+ // (QA 2026-07-19 F-21). An edit with any failed or skipped sub-edit
3191
+ // counts as skipped so the all-or-nothing batch contract is unchanged.
3192
+ let applied_logical = 0;
3193
+ let skipped_logical = 0;
3194
+ for (const input_edit of edits) {
3195
+ if (typeof input_edit !== "object" || input_edit === null) {
3196
+ skipped_logical++;
3197
+ continue;
3198
+ }
3199
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
3200
+ applied_logical++;
3201
+ } else {
3202
+ skipped_logical++;
3203
+ }
3204
+ }
3205
+ return [applied_logical, skipped_logical];
3103
3206
  }
3104
3207
 
3105
3208
  public apply_review_actions(actions: any[]): [number, number] {
@@ -3753,6 +3856,14 @@ export class RedlineEngine {
3753
3856
  }
3754
3857
  }
3755
3858
 
3859
+ // Explicit bold/italic markers in the edit make the markers
3860
+ // authoritative: inserted runs must not additionally inherit the replaced
3861
+ // span's emphasis (QA 2026-07-19 F-02). Keys on THIS resolved edit's
3862
+ // post-trim fields: identical markers on both sides were absorbed into
3863
+ // context (formatting unchanged — keep inheriting), and plain edits
3864
+ // fuzzy-matched onto styled text never receive marker hunks at all.
3865
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
3866
+
3756
3867
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
3757
3868
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3758
3869
  start_idx,
@@ -4013,6 +4124,7 @@ export class RedlineEngine {
4013
4124
  anchor_run,
4014
4125
  ins_id!,
4015
4126
  xmlDoc,
4127
+ suppress_emphasis,
4016
4128
  );
4017
4129
  if (content_ins) new_p.appendChild(content_ins);
4018
4130
  body.insertBefore(new_p, _bug233_target_para);
@@ -4047,6 +4159,8 @@ export class RedlineEngine {
4047
4159
  anchor_run,
4048
4160
  anchor_para,
4049
4161
  ins_id!,
4162
+ null,
4163
+ suppress_emphasis,
4050
4164
  );
4051
4165
 
4052
4166
  if (!result.first_node) return false;
@@ -4261,6 +4375,7 @@ export class RedlineEngine {
4261
4375
  // The insertion physically follows the deletion block; the style
4262
4376
  // run was detached when the deletion cloned it into <w:del>.
4263
4377
  last_del,
4378
+ suppress_emphasis,
4264
4379
  );
4265
4380
 
4266
4381
  if (result.first_node) {
@@ -4314,6 +4429,8 @@ export class RedlineEngine {
4314
4429
  anchor,
4315
4430
  first_span.paragraph,
4316
4431
  ins_id!,
4432
+ null,
4433
+ suppress_emphasis,
4317
4434
  );
4318
4435
  if (result.first_node) {
4319
4436
  p1_el.appendChild(result.first_node);
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ export function identifyEngine() {
5
5
  export { DocumentObject } from './docx/bridge.js';
6
6
  export { DocumentMapper, TextSpan } from './mapper.js';
7
7
  export { RedlineEngine, BatchValidationError, validate_edit_strings, describe_illegal_control_chars } from './engine.js';
8
- export { generate_edits_from_text, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, DiffEdit } from './diff.js';
8
+ export { generate_edits_from_text, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, collect_media_difference_warnings, DiffEdit } from './diff.js';
9
9
  export { apply_edits_to_markdown, MarkupEditReport } from './markup.js';
10
10
  export { paginate, split_structural_appendix, PaginationResult, PageInfo } from './pagination.js';
11
11
  export { extract_outline, OutlineNode } from './outline.js';
package/src/ingest.ts CHANGED
@@ -347,16 +347,25 @@ export function build_paragraph_text(
347
347
  new_wrappers[0] === current_wrappers[0] &&
348
348
  new_wrappers[1] === current_wrappers[1]
349
349
  ) {
350
+ // Hoisted leading whitespace may sit before the incoming segment's
351
+ // opening marker ("**A**" + " **B**" -> "**A B**"), mirroring the
352
+ // mapper's part-level elision exactly (QA 2026-07-19 F-03).
353
+ const escaped_prefix = new_style[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
354
+ const lead_match =
355
+ new_style[0] !== "" || new_style[1] !== ""
356
+ ? seg.match(new RegExp("^(\\s*)" + escaped_prefix))
357
+ : null;
350
358
  if (
351
359
  new_style[0] === current_style[0] &&
352
360
  new_style[1] === current_style[1] &&
353
361
  current_style[0] !== "" &&
354
362
  pending_text.endsWith(current_style[1]) &&
355
- seg.startsWith(new_style[0])
363
+ lead_match !== null
356
364
  ) {
357
365
  pending_text =
358
366
  pending_text.slice(0, -current_style[1].length) +
359
- seg.slice(new_style[0].length);
367
+ lead_match[1] +
368
+ seg.slice(lead_match[0].length);
360
369
  } else {
361
370
  pending_text += seg;
362
371
  }