diff --git a/extradoc/src/extradoc/mock/table_ops.py b/extradoc/src/extradoc/mock/table_ops.py index 6433d92..4cac0d7 100644 --- a/extradoc/src/extradoc/mock/table_ops.py +++ b/extradoc/src/extradoc/mock/table_ops.py @@ -98,17 +98,29 @@ def handle_insert_table( # Step 5: Insert table into content array inject_content.insert(inject_idx, table_elem) + # A table is never the last element of a body segment, so the API emits a + # trailing carrier paragraph only when nothing (or another table) follows. + # When the step-1 split already left a following paragraph (the common mid- + # document insert), that paragraph IS the carrier; emitting another would + # over-count the footprint by one newline. See + # ``reconcile_v3.lower._batch_insert_size_from_reqs``. if segment_id is None: - inject_content.insert( - inject_idx + 1, - _build_table_carrier_paragraph( - paragraph_style=_resolve_preceding_paragraph_style( - inject_idx, - inject_content, - ), - text_style=inherited_text_style, - ), + following = ( + inject_content[inject_idx + 1] + if inject_idx + 1 < len(inject_content) + else None ) + if following is None or following.get("table") is not None: + inject_content.insert( + inject_idx + 1, + _build_table_carrier_paragraph( + paragraph_style=_resolve_preceding_paragraph_style( + inject_idx, + inject_content, + ), + text_style=inherited_text_style, + ), + ) # No index shifting — reindex handles it return {} diff --git a/extradoc/src/extradoc/reconcile_v3/lower.py b/extradoc/src/extradoc/reconcile_v3/lower.py index d7c0b88..e8a6453 100644 --- a/extradoc/src/extradoc/reconcile_v3/lower.py +++ b/extradoc/src/extradoc/reconcile_v3/lower.py @@ -1686,15 +1686,25 @@ def _final_doc_size_from_reqs(reqs: list[Request]) -> int: def _batch_insert_size_from_reqs(reqs: list[Request]) -> int: """Compute the net UTF-16 size added to the story segment by this element's - requests, measured as the sum of each insert request's own contribution. - - Differs from ``_final_doc_size_from_reqs`` in how ``insertTable`` is - counted: here we count only the table skeleton as the API delta - (``1 + rows * (1 + cols * 2)``), matching the request-level accounting - used by the matched-element post_insert_shift. We do NOT add the - pre-paragraph \\n or trailing carrier paragraph here — those are either - absorbed into the existing paragraph structure or emitted as separate - ``insertText`` requests already counted above. + requests — i.e. how far each insert shifts the content that follows it. + + This value feeds ``post_insert_shift`` for matched-element updates, so it + MUST equal the true API footprint of the requests. An ``insertTable`` shifts + subsequent content by the table span PLUS exactly one ``\\n`` — never two: + + * table span → ``2 + rows * (1 + cols * 2)`` + (table opener 1 + terminal 1 + rows*(row opener 1 + cols*cell 2)) + * one ``\\n`` → 1 + giving ``3 + rows*(1 + cols*2)``, which matches the per-request accounting + in ``_final_doc_size_from_reqs`` (``2 + rows*(1+cols*2) + 1``). + + The single ``\\n`` is the pre-split newline when the table is inserted mid- + document (the following existing paragraph acts as the table's carrier), or + the trailing carrier newline when it is inserted at end-of-segment — never + both. The earlier ``4 + rows*(1+cols*2)`` over-counted the mid-document case + by 1, sliding every later matched-update op one char off. The offline mock + hid this because ``mock/table_ops`` also emitted both newlines; only a real + SUGGEST push + accepted-text verify exposes the disagreement. """ total = 0 for req in reqs: @@ -1704,7 +1714,8 @@ def _batch_insert_size_from_reqs(reqs: list[Request]) -> int: it = req.insert_table rows = it.rows or 0 cols = it.columns or 0 - total += 1 + rows * (1 + cols * 2) + # table span (2 + rows*(1+cols*2)) + exactly one \n + total += 3 + rows * (1 + cols * 2) elif req.insert_page_break is not None: total += 2 elif req.insert_section_break is not None: diff --git a/extradoc/tests/reconcile_v3/test_story_content_update_bug.py b/extradoc/tests/reconcile_v3/test_story_content_update_bug.py index f129e97..fa46ef3 100644 --- a/extradoc/tests/reconcile_v3/test_story_content_update_bug.py +++ b/extradoc/tests/reconcile_v3/test_story_content_update_bug.py @@ -164,8 +164,14 @@ def _simulate_requests_and_find_stale_deletes( idx = ins.location.index rows = ins.rows or 0 cols = ins.columns or 0 - # Table skeleton: 1 + rows * (1 + cols * 2) UTF-16 units. - delta = 1 + rows * (1 + cols * 2) + # Full body-segment insertTable footprint — how far it shifts + # subsequent content — matching the real API (controlled probe) + # and ``_batch_insert_size_from_reqs``: table span + # (2 + rows*(1+cols*2)) + exactly ONE \n. The API adds a single + # newline (the pre-split \n mid-doc, or the trailing carrier at + # end-of-segment) — never both, so the shift is span+1, not + # span+2. + delta = 3 + rows * (1 + cols * 2) if idx > len(origins): violations.append( f"req[{req_idx}] insertTable: index={idx} > "