@helping-ai-workflow/md2doc 3.0.0 → 3.0.2

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.
@@ -29,10 +29,38 @@
29
29
  // nothing to do with text alignment. Reading them as alignment would be a
30
30
  // bug; they are deliberately never consulted below.
31
31
  //
32
- // ── Emission form (spec §4 / task-5 brief) ─────────────────────────────
33
- // '| a | b |' rows (single-space padding); '|---|' separator row — no
34
- // padding around the dashes, ':' alignment variants (':---' / '---:' /
35
- // ':---:'), dashes never stretched to content width; literal '|' inside
32
+ // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1) ──────────
33
+ // There are TWO emission forms, and exactly one gate selects between them:
34
+ // whether serializeTable() was handed an `originalMd` it could turn into a
35
+ // usable width source (columnWidthsOf() parsed it, AND the greedy
36
+ // header-TEXT walk in serializeTable() matched EVERY original column, in
37
+ // order — the all-or-nothing gate). No second argument, an unparseable or
38
+ // ragged original, a renamed header, a dropped column: all of them land on
39
+ // form A.
40
+ //
41
+ // A. MINIMAL form (pre-v3.0.1, unchanged, and still what
42
+ // `serializeTable(tableEl)` with no second argument emits — byte for
43
+ // byte): '| a | b |' rows with single-space padding; a '|---|'
44
+ // separator row with no padding around the dashes, ':' alignment
45
+ // variants (':---' / '---:' / ':---:'), dashes never stretched to
46
+ // content width, always at least 3 of them.
47
+ //
48
+ // B. WIDTH-PRESERVING form (v3.0.1 欄寬保留): every row of a column,
49
+ // header and body alike, is padded out to that column's preserved
50
+ // width, and the separator's dash run IS stretched to the same width
51
+ // — with NO 3-dash floor, because a legal hand-aligned '|:-:|' must
52
+ // not be widened (see sepCellFor() for that floor's exact scope). A
53
+ // preserved width only exists for a column whose original fields were
54
+ // the same width on every row AND wider than 3; a matched column that
55
+ // fails that test still reuses its ORIGINAL separator field's width
56
+ // rather than collapsing to the minimal 3. On top of that, F1: when a
57
+ // matched column's original separator field already expresses the
58
+ // column's current alignment and is already exactly the target width,
59
+ // that field is re-emitted VERBATIM — padding and all — so the padded
60
+ // separators Prettier and VS Code emit ('| ----- | --- |') survive a
61
+ // no-op instead of being rebuilt flush ('|-------|-----|').
62
+ //
63
+ // Independent of the form: a literal '|' inside
36
64
  // a cell -> '&#124;'; a cell newline is the literal '<br>' inline-md.js's
37
65
  // serializeInline() already emits for both a real <br> node and a
38
66
  // contenteditable <div> line-split boundary — AND (final-review Finding 1
@@ -121,6 +149,116 @@
121
149
  return false;
122
150
  }
123
151
 
152
+ // ── v3.0.1 欄寬保留 ────────────────────────────────────────────────────
153
+ // A cell FIELD is the text between two '|' delimiters, PADDING INCLUDED:
154
+ // in '| a | b |' the fields are ' a ' and ' b '. A column only counts
155
+ // as a deliberately hand-aligned width source when its field is the SAME
156
+ // width on EVERY row of the original (header, separator, every body row)
157
+ // AND that width is greater than 3 — see columnWidthsOf() below for why
158
+ // both conditions are required (C2, 2026-09-02 review round 1: a uniform
159
+ // width of 3 is indistinguishable from this serializer's own minimal
160
+ // form, which is exactly 3 on every row by construction).
161
+ //
162
+ // A raw '|' never appears inside a cell in source THIS serializer produced
163
+ // (it emits '&#124;'), but a hand-written table may use the '\|' escape, so
164
+ // the split must not break on an escaped delimiter.
165
+ function splitFields(line) {
166
+ const t = line.trim();
167
+ if (t.charAt(0) !== '|' || t.charAt(t.length - 1) !== '|' || t.length < 2) return null;
168
+ const inner = t.slice(1, -1);
169
+ const out = [];
170
+ let cur = '';
171
+ for (let i = 0; i < inner.length; i++) {
172
+ const c = inner.charAt(i);
173
+ if (c === '\\' && inner.charAt(i + 1) === '|') { cur += '\\|'; i += 1; continue; }
174
+ if (c === '|') { out.push(cur); cur = ''; continue; }
175
+ cur += c;
176
+ }
177
+ out.push(cur);
178
+ return out;
179
+ }
180
+
181
+ // `{ widths, headerFields, sepFields }` describing the ORIGINAL table, or
182
+ // null when `originalMd` is absent or is not a table this can read. null
183
+ // means "no width source" and every caller below then emits minimal form
184
+ // — the pre-v3.0.1 behaviour, unchanged. `headerFields` (each original
185
+ // header field, trimmed) is what serializeTable() below uses to line a
186
+ // CURRENT column back up with the original column it corresponds to — see
187
+ // the comment at its call site for why index alone is not enough.
188
+ // `sepFields` (each original SEPARATOR field VERBATIM — padding, colons and
189
+ // all; always captured, independent of the `uniform`/`> 3` gate below)
190
+ // serves the separator emission site twice: its LENGTH is the fallback
191
+ // width for a matched column whose header/body rows aren't uniform (I1,
192
+ // 2026-09-02 review round 1), and the field ITSELF is re-emitted unchanged
193
+ // when nothing about that column's separator actually changed (F1,
194
+ // 2026-09-02 final review) — see sepFieldAlign() and the emission site.
195
+ //
196
+ // A column only counts as a width source when its field is the SAME width
197
+ // on EVERY row (header, separator, every body row) — that is what a
198
+ // deliberately hand-aligned column looks like — AND that width is > 3. A
199
+ // column whose original fields are NOT all equal was never hand-aligned to
200
+ // begin with: it's v3.0.0's un-padded minimal form, where each row's field
201
+ // is only as wide as that row's own content happens to be (e.g. header
202
+ // field 6, a body field 7, another body field 5 — three different
203
+ // numbers, no alignment to preserve). The `> 3` half of the gate catches
204
+ // the case that slips past the uniform check alone: a minimal-form table
205
+ // (single-char cells, say) reports the SAME width — exactly 3 — on every
206
+ // row, because ' a ' and '---' are both 3 characters; that is what THIS
207
+ // serializer's own minimal form always looks like, not evidence of a
208
+ // deliberate alignment (C2, review round 1). Without the `> 3` half, an
209
+ // ordinary minimal-form table would misread itself as "already aligned"
210
+ // and start re-widening on every edit — the header, the separator, AND
211
+ // every untouched row — even though v3.0.0 left all three byte-identical.
212
+ // Reporting `undefined` for such a column — rather than the width seen —
213
+ // means every caller's `widths[i] || 0` / `row[i] === undefined` checks
214
+ // fall through to independent per-row minimal form for it, exactly as if
215
+ // the column had no original at all.
216
+ function columnWidthsOf(originalMd) {
217
+ if (typeof originalMd !== 'string' || originalMd === '') return null;
218
+ const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
219
+ if (rows.length < 2) return null;
220
+ const first = splitFields(rows[0]);
221
+ if (!first) return null;
222
+ const widths = first.map((f) => f.length);
223
+ const uniform = widths.map(() => true);
224
+ let sepFields = null;
225
+ for (let r = 1; r < rows.length; r++) {
226
+ const fields = splitFields(rows[r]);
227
+ // A ragged original is not a width source — bail rather than guess.
228
+ if (!fields || fields.length !== widths.length) return null;
229
+ if (r === 1) sepFields = fields.slice();
230
+ for (let c = 0; c < fields.length; c++) {
231
+ if (fields[c].length !== widths[c]) uniform[c] = false;
232
+ }
233
+ }
234
+ for (let c = 0; c < widths.length; c++) {
235
+ if (!uniform[c] || widths[c] <= 3) widths[c] = undefined;
236
+ }
237
+ return { widths, headerFields: first.map((f) => f.trim()), sepFields };
238
+ }
239
+
240
+ // ' content ' right-padded out to `width`. NEVER truncates: a cell whose
241
+ // content grew past the original column simply widens that column, and a
242
+ // deliberately blank hand-aligned cell (e.g. `| |` kept wide on
243
+ // purpose) is reproduced at its full original width, never shrunk down to
244
+ // its own natural 2 chars — padding is a MINIMUM, not a maximum;
245
+ // truncating here would delete the user's alignment (or their text).
246
+ //
247
+ // (2026-09-02 review round 1, C2: an earlier version special-cased an
248
+ // EMPTY cell to always emit the natural 2-char field, to stop a freshly
249
+ // hover-inserted row/column from being padded out to its column's width.
250
+ // That was a misdiagnosis — the actual cause was columnWidthsOf() treating
251
+ // a uniform width of 3 (== this serializer's own minimal form) as a
252
+ // hand-aligned width source, so an ordinary just-inserted minimal-form
253
+ // column looked "aligned" and got synced. Fixing that at the source (the
254
+ // `> 3` gate above) made the empty-cell special case both unnecessary AND
255
+ // actively wrong — it silently discarded a genuinely blank, intentionally
256
+ // padded cell's width in an aligned table. Removed.)
257
+ function padField(content, width) {
258
+ const field = ' ' + content + ' ';
259
+ return field.length >= width ? field : field + ' '.repeat(width - field.length);
260
+ }
261
+
124
262
  function cellAlign(cell) {
125
263
  const style = cell.getAttribute('style');
126
264
  if (!style) return null;
@@ -128,24 +266,65 @@
128
266
  return m ? m[1] : null;
129
267
  }
130
268
 
131
- function sepCellFor(align) {
132
- if (align === 'left') return ':---';
133
- if (align === 'right') return '---:';
134
- if (align === 'center') return ':---:';
135
- return '---';
269
+ // The alignment an ORIGINAL separator field already expresses, in the same
270
+ // vocabulary cellAlign() above returns ('left' / 'right' / 'center' /
271
+ // null), so the two can be compared directly at the emission site.
272
+ // Returns `undefined` — which cellAlign() can never return — for anything
273
+ // that is not a bare separator field, so a field this can't fully account
274
+ // for can never be re-emitted verbatim. F1 (2026-09-02 final review).
275
+ function sepFieldAlign(field) {
276
+ const t = field.trim();
277
+ if (!/^:?-+:?$/.test(t)) return undefined;
278
+ const l = t.charAt(0) === ':';
279
+ const r = t.charAt(t.length - 1) === ':';
280
+ if (l && r) return 'center';
281
+ if (l) return 'left';
282
+ if (r) return 'right';
283
+ return null;
284
+ }
285
+
286
+ // The dash run is stretched to `width`, minus whatever the colons consume.
287
+ // The 3-dash floor belongs ONLY to the no-width path (`w === 0`): that is
288
+ // GFM's own minimum, and it's also exactly the minimal form ('---' /
289
+ // ':---' / '---:' / ':---:') a width of 0 produces — what makes "no
290
+ // original" and "minimal-form original" the same answer. A KNOWN width
291
+ // (`w > 0`) must NOT be floored up to 3: a narrow hand-aligned separator
292
+ // like '|:--:|' (width 4) or '|:-:|' (width 3) is legal GFM (marked lexes
293
+ // it as one table token with `align === 'center'`), and flooring it to
294
+ // '|:---:|' rewrites a line the user never touched — C1 (2026-09-02
295
+ // review round 1): every center-aligned column narrower than 5, and every
296
+ // left/right-aligned column narrower than 4, silently widened on an
297
+ // otherwise-unchanged table, which made commitEdit() write the file and
298
+ // push an undo entry on a plain click-in/click-away. Floor of 1 instead:
299
+ // a dash run can't go negative once the colon(s) are subtracted, but it's
300
+ // otherwise free to reproduce whatever width the original actually had.
301
+ function sepCellFor(align, width) {
302
+ const w = typeof width === 'number' ? width : 0;
303
+ const floor = w > 0 ? 1 : 3;
304
+ if (align === 'left') return ':' + '-'.repeat(Math.max(floor, w - 1));
305
+ if (align === 'right') return '-'.repeat(Math.max(floor, w - 1)) + ':';
306
+ if (align === 'center') return ':' + '-'.repeat(Math.max(floor, w - 2)) + ':';
307
+ return '-'.repeat(Math.max(floor, w));
308
+ }
309
+
310
+ // The cell's markdown content, WITHOUT padding. Rendered exactly once per
311
+ // cell (never twice — inlineMd.serializeInline() would double-report
312
+ // `unsupported` for the same cell), so width measurement and emission
313
+ // below both read from these same strings.
314
+ function cellContentOf(cell, unsupported) {
315
+ if (cellHasCodePipe(cell)) unsupported.push('CODE');
316
+ const res = inlineMd.serializeInline(cell);
317
+ res.unsupported.forEach((u) => unsupported.push(u));
318
+ return escapeNewlines(escapePipes(res.md));
136
319
  }
137
320
 
138
- function serializeRow(cells, unsupported) {
139
- const parts = cells.map((cell) => {
140
- if (cellHasCodePipe(cell)) unsupported.push('CODE');
141
- const res = inlineMd.serializeInline(cell);
142
- res.unsupported.forEach((u) => unsupported.push(u));
143
- return escapeNewlines(escapePipes(res.md));
144
- });
145
- return '| ' + parts.join(' | ') + ' |';
321
+ function serializeRow(contents, widths) {
322
+ const parts = contents.map((content, i) =>
323
+ padField(content, widths ? (widths[i] || 0) : 0));
324
+ return '|' + parts.join('|') + '|';
146
325
  }
147
326
 
148
- function serializeTable(tableEl) {
327
+ function serializeTable(tableEl, originalMd) {
149
328
  const unsupported = [];
150
329
  const thead = firstChildNamed(tableEl, 'THEAD');
151
330
  const tbody = firstChildNamed(tableEl, 'TBODY');
@@ -165,14 +344,222 @@
165
344
  return { md: '', unsupported: ['TABLE_RAGGED'] };
166
345
  }
167
346
 
347
+ // Render every cell's content up front (same order the pre-v3.0.1 code
348
+ // walked: header first, then each body row left-to-right), so the width
349
+ // pass below and the emission pass after it share one rendering.
350
+ const headerContents = headerCells.map((c) => cellContentOf(c, unsupported));
351
+ const bodyContentRows = bodyRows.map((tr) =>
352
+ elementChildren(tr).filter(isCell).map((c) => cellContentOf(c, unsupported)));
353
+
354
+ // Read the ORIGINAL widths first, then line each CURRENT header column
355
+ // up with the original column it corresponds to BY TEXT, not by raw
356
+ // index. A column inserted in the MIDDLE (insertColumn() in client.js)
357
+ // shifts every later column's index by one without changing what that
358
+ // column actually is — matching by index alone would hand the shifted
359
+ // column a stale neighbour's width, and the freshly-inserted empty
360
+ // column would inherit whatever width used to sit at its new index (a
361
+ // plain-index version of this walk is byte-identical to this one for a
362
+ // trailing/mid-table INSERT — padField()'s natural fallback already
363
+ // covers the inserted column either way — but it is NOT for a column
364
+ // MOVE: test/editor-client-runtime.test.js:7876-7927 drags "Detail"
365
+ // from the last position to the first and requires ALL THREE widths
366
+ // dropped even though "Name" and "Note" still sit at index-shifted but
367
+ // TEXT-unchanged positions; only text-matching, combined with the
368
+ // all-or-nothing gate below, gets that right).
369
+ //
370
+ // v3.0.2 superseded the 2026-09-02 review round-1 ruling that a renamed
371
+ // header or a deleted column must discard the whole table's width memory:
372
+ // the forward scan below skips an original column that no longer appears,
373
+ // and only a genuine column REORDER still takes the whole-table refusal.
374
+ // What is still NOT handled — deliberately — is a table with DUPLICATE
375
+ // header names: the scan pairs by TEXT, so after a delete a survivor can
376
+ // inherit the same-named other column's width. Telling the instances
377
+ // apart needs identity matching, which is a larger change than this
378
+ // patch release carries. See test/table-width.test.js cases 14, 15, 20,
379
+ // 21, 23 and 24, which pin every branch of the above.
380
+ const original = columnWidthsOf(originalMd);
381
+ let widths = null;
382
+ // Set for a column that falls off the END of the original header list
383
+ // (every original column has already been matched by the time this one
384
+ // is reached) — a genuinely NEW, appended column, e.g. test case 6's
385
+ // 'City'. Its separator still tracks its OWN header's natural width
386
+ // below. This exists to reconcile the brief's OWN case 6: the brief's
387
+ // Step 5 code unconditionally used `widths[i] || 0` for the separator
388
+ // (which is 0, i.e. the bare 3-dash minimal, for any unmatched column),
389
+ // but the brief's own expected string for case 6 has 'City' getting a
390
+ // 6-dash separator — 'City''s own rendered width, not 3 dashes. No
391
+ // pre-existing test forces this; it exists purely to make the brief's
392
+ // Step 5 code and the brief's own case 6 expectation agree with each
393
+ // other. Any OTHER mismatch (a renamed header, or a row-drag promotion)
394
+ // gets no special separator treatment: it falls all the way back to the
395
+ // 3-dash minimal, same as `widths[i]` itself already falls back to
396
+ // natural per-row padding for it — moot in practice, since the
397
+ // all-or-nothing gate below discards `widths` entirely for those cases
398
+ // anyway, so this array is never even consulted for them.
399
+ const sepFallbackNatural = new Array(headerContents.length).fill(false);
400
+ // The ORIGINAL separator row's own field, VERBATIM, for each MATCHED
401
+ // column. Used two ways at the emission site below:
402
+ //
403
+ // • its LENGTH is the fallback width for a matched column whose
404
+ // header/body rows are not themselves uniform (`original.widths[j]`
405
+ // is `undefined` there — see columnWidthsOf()). I1 (review round 1):
406
+ // without this, such a column's header/body rows fell back to their
407
+ // own natural content width (which happens to reproduce the original
408
+ // bytes when nothing changed), but the separator fell back to the
409
+ // bare 3-dash minimal — rewriting a separator line the user never
410
+ // touched the moment ANY other cell in the table was edited.
411
+ // • the FIELD ITSELF is re-emitted unchanged when the column's
412
+ // separator did not actually change (F1, final review) — see the
413
+ // verbatim gate at the emission site.
414
+ const matchedSepFields = new Array(headerContents.length);
415
+ if (original) {
416
+ const matched = new Array(headerContents.length);
417
+ // v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
418
+ // lockstep version left `j` parked on the first original column that
419
+ // did not match, so it could never recover from one mismatch — and
420
+ // "some original column was never consumed" is the signature of a
421
+ // DELETE and a RENAME just as much as of the column REORDER the gate
422
+ // below actually exists to refuse. Scanning forward lets a delete or
423
+ // a rename skip the original it no longer has, and moves the refusal
424
+ // into an explicit test (`moved`) instead of a side effect of `j`.
425
+ const unmatchedNames = [];
426
+ let j = 0;
427
+ for (let i = 0; i < headerContents.length; i++) {
428
+ const name = headerContents[i].trim();
429
+ let k = -1;
430
+ for (let q = j; q < original.headerFields.length; q++) {
431
+ if (original.headerFields[q].trim() === name) { k = q; break; }
432
+ }
433
+ if (k >= 0) {
434
+ matched[i] = original.widths[k];
435
+ matchedSepFields[i] = original.sepFields[k];
436
+ j = k + 1;
437
+ } else {
438
+ matched[i] = undefined;
439
+ // The EMPTY name is exempt from the reorder test below, and the
440
+ // exemption is load-bearing, not defensive: insertColumn()
441
+ // (lib/editor/client.js:6448-6465) creates a th (or td) with no
442
+ // content at all — the element itself at line 6451 — so every
443
+ // inserted column is named ''. A table that
444
+ // already holds one blank header would therefore see the new
445
+ // column's '' "found elsewhere in the original" and take the
446
+ // whole-table refusal — a straight regression against v3.0.1 on
447
+ // the ordinary insert-a-column-twice flow.
448
+ if (name !== '') unmatchedNames.push(name);
449
+ if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
450
+ }
451
+ }
452
+ // A column that could not find itself in what remains of the original,
453
+ // yet whose name IS somewhere in the original, has MOVED relative to
454
+ // the others. That is the one shape a per-column width guess gets
455
+ // silently wrong ("a column move must carry its alignment with it"),
456
+ // so it still discards the width source for the WHOLE table. A name
457
+ // that is absent from the original entirely is an insert or a rename:
458
+ // nothing stale to carry, so its neighbours keep their widths.
459
+ const moved = unmatchedNames.some((n) =>
460
+ original.headerFields.some((f) => f.trim() === n));
461
+ if (!moved) {
462
+ widths = matched;
463
+ // A matched column is then widened, for every row of that column
464
+ // at once (header + separator + every body row), to whatever the
465
+ // grown content now needs — the padding floor rises but never
466
+ // falls, and never truncates (test case 5): "a cell whose content
467
+ // grew past the original column widens that COLUMN", not just its
468
+ // own line. An unmatched column's width stays `undefined` here —
469
+ // comparing a number against `undefined` is always false, so this
470
+ // loop leaves it alone and padField()'s `|| 0` fallback below
471
+ // gives it independent per-row minimal form instead.
472
+ const allRows = [headerContents].concat(bodyContentRows);
473
+ for (let i = 0; i < widths.length; i++) {
474
+ let needed = widths[i];
475
+ allRows.forEach((row) => {
476
+ if (row[i] === undefined) return;
477
+ const natural = row[i].length + 2; // 1-space padding, both sides
478
+ if (natural > needed) needed = natural;
479
+ });
480
+ widths[i] = needed;
481
+ }
482
+ }
483
+ }
484
+
168
485
  const lines = [];
169
- lines.push(serializeRow(headerCells, unsupported));
170
- lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
486
+ lines.push(serializeRow(headerContents, widths));
487
+ lines.push('|' + headerCells.map((c, i) => {
488
+ // Preference order for a column's separator width:
489
+ // 1. widths[i] — this column is a full hand-aligned width source
490
+ // (matched, uniform, and > 3). Same width the header/body rows
491
+ // already use.
492
+ // 2. matchedSepFields[i].length — this column WAS matched to an
493
+ // original column, just not a uniform-width one (I1: reproduces
494
+ // the ORIGINAL separator's own field width instead of collapsing
495
+ // to the bare 3-dash minimal on an edit elsewhere in the table).
496
+ // 3. headerContents[i].length + 2, but ONLY for a column that fell
497
+ // off the END of the original header list (sepFallbackNatural —
498
+ // see above): a genuinely new, appended column still gets a
499
+ // separator matching its own header's natural width (case 6:
500
+ // 'City' has no original width, but its separator is 6 dashes),
501
+ // unlike the header/body rows above, whose padField() `|| 0`
502
+ // deliberately leaves such a column unsynced.
503
+ // 4. 0 (the plain 3-dash minimal) — no original table at all
504
+ // (`widths` itself is null: cases 1 and 7 stay byte-identical to
505
+ // the pre-v3.0.1 minimal form), or a mismatch that is neither a
506
+ // matched-but-non-uniform column nor a trailing new one (a
507
+ // renamed header, or a row-drag promotion). Since v3.0.2 the
508
+ // gate above no longer discards `widths` for a rename, so this
509
+ // branch is now the live path for a renamed column: its header
510
+ // and body rows take padField()'s `|| 0` natural width and its
511
+ // separator takes the bare 3-dash minimal. A row-drag promotion
512
+ // ONLY leaves every column unmatched — and so only produces the
513
+ // same all-undefined `widths`, byte-for-byte the `null` output,
514
+ // because every preference above falls through to this 0 — when
515
+ // none of the promoted row's cells collides with an ORIGINAL
516
+ // header name. A collision re-enters the by-text match above (the
517
+ // `k >= 0` branch), so that column inherits the OLD column's
518
+ // width and the row emits half-aligned rather than fully minimal
519
+ // (measured: promoting a 'Note' / 'zzz' row past an original
520
+ // 'Name | Note' header keeps column 0 at the original Note
521
+ // column's width while column 1 falls to 3-dash minimal). The two
522
+ // promotion scenarios this suite covers are both collision-free.
523
+ const origField = matchedSepFields[i];
524
+ const w = widths
525
+ ? (widths[i] !== undefined ? widths[i]
526
+ : origField !== undefined ? origField.length
527
+ : (sepFallbackNatural[i] ? headerContents[i].length + 2 : 0))
528
+ : 0;
529
+ const align = cellAlign(c);
530
+ // F1 (2026-09-02 final review, Critical): once the width is settled,
531
+ // prefer the ORIGINAL field's own BYTES over rebuilding them. Every
532
+ // preference above only ever agreed on the WIDTH of the separator
533
+ // field; sepCellFor() then rebuilt its contents as a dash run flush
534
+ // against both delimiters. For the padded separator row Prettier,
535
+ // prettier-plugin-markdown and VS Code's table formatter all emit —
536
+ // '| ----- | --- |', the most common machine-aligned markdown table
537
+ // in the wild — that is the same width but different bytes, so a pure
538
+ // no-op (click into the table, click away) came back as
539
+ // '|-------|-----|'. commitRangeEdit() short-circuits only on a
540
+ // byte-identical result, so that was a REAL op: an undo entry pushed,
541
+ // `lines` replaced, and the next save writing a line the user never
542
+ // touched.
543
+ //
544
+ // Re-emit the original field verbatim only when BOTH hold, so this can
545
+ // never smuggle a stale separator past a genuine change:
546
+ // (a) the field already expresses the alignment this column now has
547
+ // (sepFieldAlign() returns `undefined` for anything that isn't a
548
+ // plain separator field, which cellAlign() can never equal, so an
549
+ // unparseable field always falls through); and
550
+ // (b) the target width `w` is exactly that field's own length — any
551
+ // width GROWTH (a cell that outgrew its column, cases 5 and 12)
552
+ // lands here with w > origField.length and falls through.
553
+ // Everything that falls through takes today's sepCellFor() path,
554
+ // unchanged.
555
+ if (widths && origField !== undefined &&
556
+ origField.length === w && sepFieldAlign(origField) === align) {
557
+ return origField;
558
+ }
559
+ return sepCellFor(align, w);
560
+ }).join('|') + '|');
171
561
 
172
- bodyRows.forEach((tr) => {
173
- const cells = elementChildren(tr).filter(isCell);
174
- lines.push(serializeRow(cells, unsupported));
175
- });
562
+ bodyContentRows.forEach((contents) => lines.push(serializeRow(contents, widths)));
176
563
 
177
564
  return { md: lines.join('\n'), unsupported };
178
565
  }