texdiff 0.2.0__py3-none-any.whl

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.
texdiff/emit.py ADDED
@@ -0,0 +1,1193 @@
1
+ """Markup emitter: edit script → LaTeX source with \\DIFadd/\\DIFdel.
2
+
3
+ Walks the edit script produced by :mod:`texdiff.align` (after optional
4
+ recursion) and renders both old and new content with markup compatible
5
+ with latexdiff's UNDERLINE type, so existing review conventions and
6
+ preambles (``\\RequirePackage{ulem}\\providecommand{\\DIFadd}...``) work
7
+ unchanged.
8
+
9
+ Emitters are markup-agnostic through the :class:`Markup` protocol -
10
+ easy to add CFONT-style, color-only, or XML output later.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import Iterator
18
+
19
+ from .align import Delete, Edit, Insert, Match, Modify
20
+ from .nodes import Node, _SECTIONING_RE
21
+ from .parse import VERBATIM_ENVIRONMENTS
22
+ from . import oldlines
23
+ from . import tables
24
+ from .tables import RETIRE_MIN_ROWS
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LatexdiffMarkup:
29
+ """latexdiff-underline-style markup (blue wavy underline / red strike).
30
+
31
+ Two markup forms exist, mirroring latexdiff's own distinction:
32
+
33
+ * *inline* (``\\DIFadd{...}``) for text runs - wavy underline /
34
+ strike-through; LR-mode only, must not contain block structure;
35
+ * *block* (``\\DIFaddbegin ... \\DIFaddend``) around whole
36
+ nodes whose content cannot live inside a macro argument
37
+ (environments, tables, multi-line runs). Block markers switch
38
+ the active colour declaration-style (no group!): a *colour
39
+ group* between ``\\\\`` and ``\\hline`` provokes
40
+ ``Misplaced \\noalign`` in tables, but a plain declaration
41
+ (``\\color{blue}``) is legal there - LaTeX keeps it in force
42
+ until the next ``\\DIFdelbegin``/group boundary.
43
+ """
44
+
45
+ add_open: str = "\\DIFadd{"
46
+ add_close: str = "}"
47
+ del_open: str = "\\DIFdel{"
48
+ del_close: str = "}"
49
+ # declarations, not groups: visible outside tables
50
+ block_add_open: str = "\\DIFaddbegin\n"
51
+ block_add_close: str = "\\DIFaddend\n"
52
+ block_del_open: str = "\\DIFdelbegin\n"
53
+ block_del_close: str = "\\DIFdelend\n"
54
+ # row-region block markers: no-op, like latexdiff's *FL variants.
55
+ # Anything expandable after \\ starts the next table cell and
56
+ # provokes "Misplaced \noalign" before a following \hline (a
57
+ # colour declaration included); rows stay visible through the
58
+ # per-cell inline markup instead.
59
+ row_block_add_open: str = "\\DIFaddbeginFL\n"
60
+ row_block_add_close: str = "\\DIFaddendFL\n"
61
+ row_block_del_open: str = "\\DIFdelbeginFL\n"
62
+ row_block_del_close: str = "\\DIFdelendFL\n"
63
+
64
+
65
+ def render(edits: list[Edit], markup: LatexdiffMarkup = LatexdiffMarkup()) -> str:
66
+ """Render an edit script to LaTeX source.
67
+
68
+ Guarantees (v0 contract):
69
+
70
+ * unchanged (``Match``) node text is emitted byte-identically;
71
+ * markup never appears inside an atomic node's span;
72
+ * the output compiles whenever both inputs compile.
73
+
74
+ Heavily restructured tables (>= 80 % data-row churn between the
75
+ paired old/new table texts) bypass the row markup: they render
76
+ wholesale - old table struck through in red, new table in blue -
77
+ because per-row markup of that density is unreadable noise and
78
+ the struck old table carries the "what was there" information
79
+ the way the reference build does.
80
+ """
81
+ out: list[str] = []
82
+ for edit in _hoist_retired_tables(_coalesce(edits)):
83
+ if isinstance(edit, Match):
84
+ out.append(edit.node.text)
85
+ elif isinstance(edit, Insert):
86
+ out.append(_wrap_node(edit.new, markup, added=True))
87
+ elif isinstance(edit, Delete):
88
+ span = _table_span(edit.old.text)
89
+ rendered = None
90
+ if span is not None and tables.data_rows(span[2]) >= RETIRE_MIN_ROWS:
91
+ # retired table: struck-through old version, visible
92
+ rendered = _emit_deleted_table(edit.old)
93
+ else:
94
+ rendered = _wrap_node(edit.old, markup, added=False)
95
+ out.append(rendered)
96
+ elif isinstance(edit, Modify):
97
+ if edit.old.kind == "env" and edit.old.name in VERBATIM_ENVIRONMENTS:
98
+ # changed verbatim-like environment: latexdiff's
99
+ # COLORLISTINGS line-by-line treatment (%DIF markers
100
+ # hidden by the DIFcode listings language)
101
+ merged = _render_verbatim_modify(edit)
102
+ if merged is not None:
103
+ out.append(merged)
104
+ continue
105
+ if _is_table_replacement(edit):
106
+ old_start, old_end, old_text = _table_span(edit.old.text)
107
+ new_start, new_end, new_text = _table_span(edit.new.text)
108
+ if tables.is_restructured(old_text, new_text):
109
+ out.append(edit.old.text[:old_start])
110
+ out.append(
111
+ tables.render_restructured(old_text, new_text)
112
+ )
113
+ out.append(edit.new.text[new_end:])
114
+ continue
115
+ if tables.is_pathological(old_text, new_text):
116
+ # poorly matched table pair: try one row-merged
117
+ # table, fall back to the wholesale old+new pair
118
+ out.append(edit.old.text[:old_start])
119
+ out.append(
120
+ tables.merge_tables(old_text, new_text)
121
+ or tables.render_restructured(old_text, new_text)
122
+ )
123
+ out.append(edit.new.text[new_end:])
124
+ continue
125
+ if edit.inner is not None:
126
+ # recurse into the changed environment/group, keeping
127
+ # its \begin{...}/\end{...} (or brace) wrapper intact
128
+ out.append(_render_recursed(edit, markup))
129
+ elif _align_state(edit):
130
+ # alignment glue: a Modify that pairs one side's
131
+ # paragraph separator (whitespace-only text) with the
132
+ # other side's real text is a pure insertion/deletion
133
+ # in disguise. Rendering the whitespace side through
134
+ # the block markup emits nothing visible but its
135
+ # blank-lines content - a paragraph break in the
136
+ # middle of a sentence ("...message";} ** here ** . The
137
+ # stream..."). It is emitted as the plain separator it
138
+ # is and only the content side takes markup.
139
+ if edit.new.text.strip():
140
+ out.append(_wrap_node(edit.new, markup, added=True))
141
+ else:
142
+ out.append(_wrap_node(edit.old, markup, added=False))
143
+ out.append(edit.new.text)
144
+ else:
145
+ out.append(_wrap_node(edit.old, markup, added=False))
146
+ out.append(_wrap_node(edit.new, markup, added=True))
147
+ return "".join(out)
148
+
149
+
150
+ def _hoist_retired_tables(edits: list[Edit]) -> list[Edit]:
151
+ """Reorder ``Insert(new table) ... Delete(old table)`` pairs.
152
+
153
+ The alignment can pair a restructured-table replacement as
154
+ Insert(new)-before-Delete(old) (it anchors on which ever side the
155
+ neighbouring matches sit). A replaced table reads old-then-new -
156
+ the retired red table first, the blue replacement right after -
157
+ so this pre-pass swaps the two, keeping any whitespace-only
158
+ matches between them attached after the hoisted deletion.
159
+
160
+ The same insert-first order appears when the new revision holds a
161
+ *duplicated* table: the alignment emits Insert(copy of the new
162
+ table) before the Modify(old table -> new table) that carries the
163
+ replacement. The Modify already renders red-then-blue, but its
164
+ retired half still lands after the inserted copy; the swap moves
165
+ the Modify ahead of the insert so all retired tables precede
166
+ their blue counterparts and numbering reads 33 (blue pair
167
+ replacement) then 34 (the additional new copy).
168
+
169
+ The swap is narrow on purpose: the insert must carry a longtable,
170
+ the retired node must hold a retireable longtable (data-row
171
+ count) - either a Delete or a table-replacement Modify - and only
172
+ whitespace may sit between the two. Anything else - several
173
+ edits in between, prose deletions - is untouched.
174
+ """
175
+ out: list[Edit] = []
176
+ glue: list[Edit] = []
177
+ pending: Insert | None = None
178
+ for edit in edits:
179
+ if isinstance(edit, Insert) and "\\begin{longtable" in edit.new.text:
180
+ if pending is not None:
181
+ out.append(pending)
182
+ pending = edit
183
+ continue
184
+ elif pending is not None and (
185
+ (isinstance(edit, Delete) and _is_retirable_table(edit.old.text))
186
+ or (
187
+ isinstance(edit, Modify)
188
+ and _is_table_replacement(edit)
189
+ and _is_retirable_table(edit.old.text)
190
+ )
191
+ ):
192
+ # either a retired Delete or a replacement Modify whose
193
+ # old side retires: its red half belongs before the
194
+ # pending inserted table, so the two swap; glue flushed
195
+ # between them
196
+ out.append(edit)
197
+ out.extend(glue)
198
+ glue.clear()
199
+ out.append(pending)
200
+ pending = None
201
+ continue
202
+ if (
203
+ isinstance(edit, Match)
204
+ and not edit.node.text.strip()
205
+ ) or (
206
+ isinstance(edit, Modify)
207
+ and _align_state(edit)
208
+ ):
209
+ # whitespace-only glue between the inserted table and
210
+ # the retiring edit does not break the adjacency the
211
+ # swap keys on; it queues after the pending insert so a
212
+ # following retiring edit still swaps with it
213
+ if pending is not None:
214
+ glue.append(edit)
215
+ else:
216
+ out.append(edit)
217
+ continue
218
+ if pending is not None:
219
+ out.append(pending)
220
+ pending = None
221
+ out.extend(glue)
222
+ glue.clear()
223
+ out.append(edit)
224
+ if pending is not None:
225
+ out.append(pending)
226
+ out.extend(glue)
227
+ return out
228
+
229
+
230
+ def _is_retirable_table(text: str) -> bool:
231
+ """True when a deleted node's text is a retireable longtable region."""
232
+ span = _table_span(text)
233
+ if span is None:
234
+ return False
235
+ return tables.data_rows(span[2]) >= RETIRE_MIN_ROWS
236
+
237
+
238
+ def _align_state(edit: Modify) -> bool:
239
+ """True when one side of a text-text Modify is whitespace-only."""
240
+ return (edit.old.kind == "text" and edit.new.kind == "text") and (
241
+ not edit.old.text.strip() or not edit.new.text.strip()
242
+ )
243
+
244
+
245
+ _TABLE_SPAN_RE = re.compile(r"\\begin\{longtable\*?\}.*?\\end\{longtable\*?\}", re.S)
246
+
247
+
248
+ def _table_span(text: str) -> tuple[int, int, str] | None:
249
+ """Locate the first longtable inside a node's text as (start, end, text)."""
250
+ m = _TABLE_SPAN_RE.search(text)
251
+ if m is None:
252
+ return None
253
+ return m.start(), m.end(), m.group(0)
254
+
255
+
256
+ def _is_table_replacement(edit: Modify) -> bool:
257
+ """True when a Modify pair is table-bearing on both sides."""
258
+ return bool(_table_span(edit.old.text) and _table_span(edit.new.text))
259
+
260
+
261
+ def _emit_deleted_table(node: Node) -> str:
262
+ """Emit a retired table: struck-through old version, nothing new.
263
+
264
+ The whole longtable span renders via the tables module (red
265
+ struck-through); text outside the span (glue, group openers like
266
+ ``{\\scriptsize``) passes through verbatim so the brace balance
267
+ survives.
268
+ """
269
+ span = _table_span(node.text)
270
+ if span is None:
271
+ return node.text
272
+ start, end, table_text = span
273
+ return (
274
+ node.text[:start]
275
+ + "{\\color{red}\n"
276
+ + tables.strike_through(table_text)
277
+ + "}\n"
278
+ + node.text[end:]
279
+ )
280
+
281
+
282
+ def _coalesce(edits: list[Edit]) -> Iterator[Edit]:
283
+ """Merge neighbouring edits of the same kind into one region.
284
+
285
+ A single logical insertion is often split across node boundaries
286
+ (``\\subsubsubsection`` macro + ``{arg}`` group + newline): marking
287
+ each fragment separately both looks noisy and, for inline markup,
288
+ breaks macro arity (``\\DIFadd{\\subsubsubsection}`` steals the
289
+ argument braces). Coalescing glued fragments into one marked
290
+ region fixes both problems at once.
291
+ """
292
+ run: list[Insert] = []
293
+ run_del: list[Delete] = []
294
+ for edit in edits:
295
+ if isinstance(edit, Match):
296
+ if run_del:
297
+ yield Delete(old=_merge_nodes([e.old for e in run_del]))
298
+ run_del.clear()
299
+ if run:
300
+ yield Insert(new=_merge_nodes([e.new for e in run]))
301
+ run.clear()
302
+ yield edit
303
+ elif isinstance(edit, Insert):
304
+ if run_del:
305
+ yield Delete(old=_merge_nodes([e.old for e in run_del]))
306
+ run_del.clear()
307
+ run.append(edit)
308
+ elif isinstance(edit, Delete):
309
+ if run:
310
+ yield Insert(new=_merge_nodes([e.new for e in run]))
311
+ run.clear()
312
+ run_del.append(edit)
313
+ else: # Modify
314
+ if run_del:
315
+ yield Delete(old=_merge_nodes([e.old for e in run_del]))
316
+ run_del.clear()
317
+ if run:
318
+ yield Insert(new=_merge_nodes([e.new for e in run]))
319
+ run.clear()
320
+ yield edit
321
+ if run_del:
322
+ yield Delete(old=_merge_nodes([e.old for e in run_del]))
323
+ if run:
324
+ yield Insert(new=_merge_nodes([e.new for e in run]))
325
+
326
+
327
+ def _merge_nodes(nodes: list[Node]) -> Node:
328
+ """Concatenate nodes into one synthetic node.
329
+
330
+ A run made only of row nodes (plus pure-whitespace glue between
331
+ them) stays ``kind="row"``: it is then marked up by
332
+ :func:`_wrap_row`, which keeps ``&``/``\\\\``/``\\hline`` outside
333
+ the inline markup and uses the table-safe no-op block markers.
334
+ Losing that identity (as in earlier versions, which produced a
335
+ plain text node) sent whole-row insertions through the generic
336
+ block path with expandable markers glued after ``\\\\`` — a
337
+ recipe for ``Misplaced \\noalign``.
338
+ """
339
+ all_rows_or_space = all(
340
+ n.kind == "row" or (n.kind == "text" and not n.text.strip()) for n in nodes
341
+ )
342
+ kind = "row" if (all_rows_or_space and any(n.kind == "row" for n in nodes)) else "text"
343
+ # a single-node run keeps its identity: coalescing must not
344
+ # degrade e.g. a deleted verbatim environment (env + name) into
345
+ # an anonymous text run - the verbatim deletion policy keys off it
346
+ if len(nodes) == 1:
347
+ return nodes[0]
348
+ return Node(kind=kind, text="".join(n.text for n in nodes), atom=True)
349
+
350
+
351
+ def _mark_heading_arg(text: str) -> str:
352
+ """Reinforce a sectioning heading: ``\\DIFadd`` inside the title.
353
+
354
+ Block markers colour the typeset heading text, but the table of
355
+ contents entry is written at ``\\subsection`` expansion time from
356
+ the *argument* - a colour declaration outside the braces never
357
+ reaches the .toc file. Wrapping the argument content in
358
+ ``\\DIFadd{...}`` (as the reference build does) carries the markup
359
+ into the TOC line as well.
360
+
361
+ Applied only when the title is inline-safe (no macro-with-arg,
362
+ no nested environments): anything fancier falls back to the plain
363
+ block colouring, which still colours the body text.
364
+ """
365
+ m = _SECTIONING_RE.match(text)
366
+ if not m:
367
+ return text
368
+ body = text[m.end() - 1 :] # from the opening brace
369
+ depth = 0
370
+ end = -1
371
+ for i, ch in enumerate(body):
372
+ if ch == "{" and (i == 0 or body[i - 1] != "\\"):
373
+ depth += 1
374
+ elif ch == "}" and (i == 0 or body[i - 1] != "\\"):
375
+ depth -= 1
376
+ if depth == 0:
377
+ end = i
378
+ break
379
+ if end < 1:
380
+ return text
381
+ title = body[1:end]
382
+ if not _is_safe_inline(title) or not title.strip():
383
+ return text
384
+ return (
385
+ text[: m.end() - 1]
386
+ + "{\\DIFadd{"
387
+ + title
388
+ + "}}"
389
+ + body[end + 1 :]
390
+ )
391
+
392
+
393
+ def _mark_heading_args_in_run(text: str) -> str:
394
+ """Mark sectioning headings inside a coalesced insert run.
395
+
396
+ Long insert runs are merged into one synthetic text node, so the
397
+ per-macro heading marking never sees them. Large added regions are
398
+ still block-coloured overall; the only visible gap is the TOC,
399
+ which takes its content from the heading argument. This helper
400
+ walks the run line-wise and applies :func:`_mark_heading_arg` to
401
+ every line that is exactly one sectioning macro call.
402
+ """
403
+ if _SECTIONING_RE.search(text) is None:
404
+ return text
405
+ out = []
406
+ for line in text.split("\n"):
407
+ stripped = line.strip()
408
+ head_call = _find_heading_span(stripped) if _SECTIONING_RE.match(stripped) else None
409
+ if head_call:
410
+ pos = line.find(head_call)
411
+ out.append(
412
+ line[:pos]
413
+ + _mark_heading_arg(head_call)
414
+ + line[pos + len(head_call) :]
415
+ )
416
+ else:
417
+ out.append(line)
418
+ return "\n".join(out)
419
+
420
+
421
+ def _find_heading_span(text: str) -> str:
422
+ """Return the first sectioning macro call substring of `text`."""
423
+ m = _SECTIONING_RE.search(text)
424
+ start = m.start()
425
+ depth = 0
426
+ i = m.end() - 1 # at the opening brace
427
+ while i < len(text):
428
+ if text[i] == "{":
429
+ depth += 1
430
+ elif text[i] == "}":
431
+ depth -= 1
432
+ if depth == 0:
433
+ return text[start : i + 1]
434
+ i += 1
435
+ return text[start:]
436
+
437
+
438
+ def _wrap_node(node: Node, markup: LatexdiffMarkup, added: bool) -> str:
439
+ """Wrap one node with inline or block markup as appropriate.
440
+
441
+ Inline ``\\DIFadd{...}`` is LR-mode-only: it must never contain a
442
+ macro-with-argument (the braces would steal the argument), an
443
+ environment, or a line break. Those all take the block form, whose
444
+ markers are no-op macros - they cannot break anything that
445
+ compiled before.
446
+
447
+ A whole deleted verbatim-like environment is commented out
448
+ (``%DIFDELCMD``) instead of wrapped: rendering it visibly would
449
+ double the document, and latexdiff's own COLORLISTINGS mode keeps
450
+ deletions to the line-wise ``%DIF <`` markers inside *modified*
451
+ listings only.
452
+
453
+ A large added block colouring does not reach into verbatim-like
454
+ environments: ``listings`` resets the current text colour at
455
+ ``\\begin{lstlisting}`` and applies its own ``basicstyle`` (and
456
+ ``commentstyle``/``morecomment`` on top), so an added YAML sample
457
+ inside a blue chapter comes out black with wildly-coloured
458
+ ``#``-comments. The reference build marks every line of such a
459
+ listing ``%DIF >`` inside a DIFcode ``alsolanguage`` environment -
460
+ the markers themselves typeset the whole line blue, including
461
+ comment lines.
462
+ """
463
+ if node.kind == "env" and node.name in VERBATIM_ENVIRONMENTS and not added:
464
+ return _comment_out_env(node)
465
+ if node.kind == "row":
466
+ # table rows: wrap each cell's text but keep & and \\
467
+ # outside markup - \DIFdel{a & b} is illegal in alignment
468
+ return _wrap_row(node, markup, added)
469
+ if _needs_block(node):
470
+ if added:
471
+ body = _mark_added_listings(node.text)
472
+ body = _mark_heading_args_in_run(body)
473
+ return f"{markup.block_add_open}{body}{markup.block_add_close}"
474
+ return f"{markup.block_del_open}{node.text}{markup.block_del_close}"
475
+ if added:
476
+ body = _mark_heading_args_in_run(node.text)
477
+ return _wrap(body, markup.add_open, markup.add_close)
478
+ return _wrap(node.text, markup.del_open, markup.del_close)
479
+
480
+
481
+ # matches a whole verbatim-like environment span (begin..end) inside
482
+ # a coalesced block run; non-greedy so consecutive environments match
483
+ # separately. Only lstlisting-like envs with an optional [...] arg.
484
+ _VERBATIM_SPAN_RE = re.compile(
485
+ r"\\begin\{(lstlisting|minted|alltt)\}"
486
+ r"(\[[^\]]*\])?"
487
+ r"\n(.*?)\\end\{\1\}",
488
+ re.S,
489
+ )
490
+
491
+
492
+ def _mark_added_listings(text: str) -> str:
493
+ """Line-mark verbatim environments inside an added block.
494
+
495
+ Every non-blank body line gets the ``%DIF >`` prefix and the
496
+ begin marker gains ``alsolanguage=DIFcode`` (when it does not
497
+ already), matching what a *modified* listing renders like - an
498
+ inserted chapter then typesets its listings blue line by line
499
+ instead of falling back to the listings language styles
500
+ (magenta ``#``-comments and friends stick out against a blue
501
+ chapter otherwise). Environments already carrying a ``%DIF``
502
+ marker line (a nested modify render that leaked into the run)
503
+ stay untouched.
504
+ """
505
+ if "\\begin{lstlisting" not in text and "minted" not in text and "alltt" not in text:
506
+ return text
507
+
508
+ def _mark(m: re.Match) -> str:
509
+ env, opts, body = m.group(1), m.group(2), m.group(3)
510
+ begin = f"\\begin{{{env}}}{opts or ''}"
511
+ begin = _add_alsolanguage(begin)
512
+ if _DIF_ADD_MARK in body or _DIF_DEL_MARK in body:
513
+ # already line-marked (nested modify markup): keep as-is
514
+ return m.group(0)
515
+ lines = [(_DIF_ADD_MARK + l if l.strip() else l) for l in body.split("\n")]
516
+ # a trailing marker-only line (empty last line) is dropped:
517
+ # listings would typeset a blank marker line at the env end
518
+ while lines and not lines[-1].strip():
519
+ lines.pop()
520
+ return begin + "\n" + "\n".join(lines) + f"\\end{{{env}}}"
521
+
522
+ return _VERBATIM_SPAN_RE.sub(_mark, text)
523
+
524
+
525
+ # split a row region into structural tokens and text runs. `(?<!\\)&`
526
+ # so an escaped literal ampersand (``\&`` in cell text) is NOT a
527
+ # cell separator; ``\begin{...}`` is structural alongside ``\end`` so
528
+ # nested environments in cells are never inline-wrapped.
529
+ _ROW_SPLIT_RE = re.compile(
530
+ r"((?<!\\)&|\\\\|\\hline|\\begin\{[a-zA-Z*]+\}|\\end(?:firsthead|head|foot|lastfoot)|\\end\{[a-zA-Z*]+\})"
531
+ )
532
+
533
+
534
+ def _wrap_row(node: Node, markup: LatexdiffMarkup, added: bool) -> str:
535
+ """Mark up a table row region: block markers + per-cell inline.
536
+
537
+ Row regions may contain several physical rows (a delete run);
538
+ tokens that carry table structure (``&``, ``\\\\``, ``\\hline``,
539
+ boundaries) are kept outside the inline markup; surrounding text
540
+ runs get the inline wrap. Block markers (no-op) delimit the
541
+ region as a whole.
542
+
543
+ A deleted row region containing nested environment *bodies*
544
+ (``\\begin{itemize}\\item ... \\end{itemize}`` inside a cell)
545
+ cannot survive the split: the begin/end tokens are structural
546
+ and stay verbatim while the ``\\item``s between them would be
547
+ commented out, leaving ``\\begin{itemize}`` without any item -
548
+ "Something's wrong--perhaps a missing \\item" for every such
549
+ row. Such a region is commented out wholesale instead, which is
550
+ also latexdiff's treatment of deleted rows.
551
+ """
552
+ b_open = markup.row_block_add_open if added else markup.row_block_del_open
553
+ b_close = markup.row_block_add_close if added else markup.row_block_del_close
554
+ open_, close = (
555
+ (markup.add_open, markup.add_close)
556
+ if added
557
+ else (markup.del_open, markup.del_close)
558
+ )
559
+
560
+ if not added and _has_env_body(node.text):
561
+ return b_open + _comment_out_rows(node.text) + b_close
562
+
563
+ parts = _ROW_SPLIT_RE.split(node.text)
564
+ marked: list[str] = []
565
+ for part in parts:
566
+ if part and _ROW_SPLIT_RE.fullmatch(part):
567
+ marked.append(part) # structural token: verbatim
568
+ elif not part.strip():
569
+ marked.append(part) # whitespace: verbatim
570
+ elif _is_safe_inline(part):
571
+ marked.append(_wrap(part, open_, close))
572
+ elif added:
573
+ # unsafe added run: cannot take the LR-mode inline wrap;
574
+ # colour each line blue instead (cell groups contain the
575
+ # declaration, structurally safe like latexdiff's
576
+ # degrade-to-block behaviour for multi-line cells)
577
+ marked.append(_color_lines(part))
578
+ else:
579
+ # unsafe deleted run: comment the lines out (DIFDELCMD),
580
+ # keeping structure tokens (already captured by the split)
581
+ # outside; \sout cannot span \\ line breaks
582
+ marked.append(_comment_out(part))
583
+ body = "".join(marked)
584
+ return f"{b_open}{body}{b_close}"
585
+
586
+
587
+ # list/paragraph primitives that cannot appear inside \uwave/\sout
588
+ _LIST_ITEM_RE = re.compile(r"\\(?:item|par|newline|linebreak|cr)\b")
589
+
590
+
591
+ def _comment_out(text: str) -> str:
592
+ """Comment out each line, latexdiff ``%DIFDELCMD <`` convention.
593
+
594
+ The result always ends in a newline: a structural token
595
+ (``&``, ``\\\\``) captured by the row split is appended
596
+ directly after the commented run, and a comment character at the
597
+ start of the line would otherwise swallow it.
598
+ """
599
+ if not text.strip():
600
+ return text
601
+ lines = text.split("\n")
602
+ # a trailing empty line from the split is dropped: the final newline
603
+ # added below re-creates it
604
+ while lines and not lines[-1].strip():
605
+ lines.pop()
606
+ out = [
607
+ f"%DIFDELCMD < {line} \\%%" if line.strip() else line for line in lines
608
+ ]
609
+ return "\n".join(out) + "\n"
610
+
611
+
612
+ # nested environment whose *body* carries content a row split would
613
+ # destroy (itemize/enumerate \item lists inside a table cell): begin
614
+ # and end tokens are structural for the split, whatever sits between
615
+ # them is not - a commented-out body leaves the env empty
616
+ _ENV_BODY_RE = re.compile(r"\\(?:begin|end)\{(?:itemize|enumerate|description)\}")
617
+
618
+
619
+ def _has_env_body(text: str) -> bool:
620
+ """True when a row region contains a list-environment body."""
621
+ return bool(_ENV_BODY_RE.search(text))
622
+
623
+
624
+ def _comment_out_rows(text: str) -> str:
625
+ """Comment out a whole row region, keeping ``\\hline`` visible.
626
+
627
+ Table-only structure tokens (``\\hline`` and friends) share lines
628
+ with nothing else in generated tables and must stay: dropping
629
+ them would change the visual grid of the *remaining* rows. Any
630
+ other line - row content, environment bodies inside cells - is
631
+ neutralised with latexdiff's ``%DIFDELCMD <`` convention; the
632
+ ``\\%%`` tail keeps a following structural token on the same
633
+ line from being swallowed by the comment.
634
+ """
635
+ out: list[str] = []
636
+ for line in text.split("\n"):
637
+ code = re.sub(r"(?<!\\)%.*$", "", line)
638
+ if _STRUCT_TOKEN_RE.search(code):
639
+ out.append(line)
640
+ elif not line.strip():
641
+ out.append(line)
642
+ else:
643
+ out.append(f"%DIFDELCMD < {line} \\%%")
644
+ return "\n".join(out) + ("\n" if not text.endswith("\n") else "")
645
+
646
+
647
+ # table-structure lines that must never receive a colour declaration
648
+ # (bare structure commands are legal only outside a cell's group)
649
+ _STRUCT_LINE_RE = re.compile(r"^\s*\\(?:hline|endhead|endfoot|rowcolor|caption)\b")
650
+
651
+
652
+ def _color_lines(text: str) -> str:
653
+ """Colour added lines blue, existing ones black (refine-diff).
654
+
655
+ A colour declaration is legal at the start of a table cell but
656
+ dies at the cell boundary, so it must be re-started after each
657
+ ``&`` on the same line (refine-diff convention); structure-only
658
+ lines (``\\hline`` ...) stay untouched.
659
+
660
+ A line whose normalised content already existed in the OLD
661
+ revision is re-emitted content, not a genuine addition: it takes
662
+ ``\\color{black}`` so unstated-content stays visually unchanged
663
+ (refine-diff.pl semantics).
664
+ """
665
+ from . import oldlines
666
+
667
+ out: list[str] = []
668
+ for line in text.split("\n"):
669
+ if line.strip() and not _STRUCT_LINE_RE.match(line):
670
+ color = "blue" if not oldlines.in_old(line) else "black"
671
+ line = f"\\color{{{color}}} {line}"
672
+ if color == "blue":
673
+ line = re.sub(r"(?<!\\)&", r"& \\color{blue} ", line)
674
+ out.append(line)
675
+ return "\n".join(out)
676
+
677
+
678
+ # row-structure tokens that may share a line with row content but
679
+ # must survive a wholesale deletion of that row (they belong to the
680
+ # table grid, not to the deleted row): bare \hline and friends
681
+ _STRUCT_TOKEN_RE = re.compile(
682
+ r"^\s*\\(?:hline|hdashline|toprule|midrule|bottomrule"
683
+ r"|endfirsthead|endhead|endfoot|endlastfoot)\b"
684
+ )
685
+
686
+
687
+ # ---------------------------------------------------------------- verbatim --
688
+ # latexdiff COLORLISTINGS treatment of changed verbatim-like
689
+ # environments: both revisions merge into ONE environment whose body
690
+ # carries per-line markers. Inside lstlisting the markers themselves
691
+ # must stay source-visible (line comments would kill the diff), so the
692
+ # preamble defines a DIFcode ``listings`` language whose delimiters
693
+ # ``%DIF < `` / ``%DIF > `` typeset as hidden red-strike / blue runs -
694
+ # exactly latexdiff's ``moredelim=[il]`` trick.
695
+ _DIF_DEL_MARK = "%DIF < "
696
+ _DIF_ADD_MARK = "%DIF > "
697
+
698
+
699
+ def _split_verbatim(node: Node) -> tuple[str, list[str], str] | None:
700
+ """Split a verbatim env node into (begin, body lines, end).
701
+
702
+ Returns ``None`` when the begin/end delimiters cannot be located
703
+ (tolerant parsing artifacts) so the caller can fall back.
704
+ """
705
+ text = node.text
706
+ name = node.name
707
+ if not name:
708
+ return None
709
+ m = re.match(
710
+ r"^(?P<begin>\\begin\{" + re.escape(name) + r"\}(?:\[[^\]]*\])?)\n",
711
+ text,
712
+ )
713
+ idx = text.rfind("\\end{" + name + "}")
714
+ if not m or idx < 0 or idx <= m.end():
715
+ return None
716
+ begin = m.group("begin")
717
+ body = text[m.end() : idx]
718
+ end = text[idx:]
719
+ return begin, body.split("\n"), end
720
+
721
+
722
+ def _render_verbatim_modify(edit: Modify) -> str | None:
723
+ """Render a modified verbatim env as one line-marked environment.
724
+
725
+ Lines only in the old revision get the ``%DIF <`` prefix (the
726
+ DIFcode language typesets them hidden in red strikeout), lines
727
+ only in the new revision ``%DIF >`` (blue); equal lines stay as
728
+ the new revision's bytes. The merged body keeps the NEW begin/end
729
+ delimiters, extended with ``alsolanguage=DIFcode`` so the markers
730
+ are interpreted (a plain verbatim env cannot host them: returned
731
+ unchanged from ``_split_verbatim`` handling in the caller).
732
+
733
+ Lines compare on *stripped* text: re-indentation (a code block
734
+ moved one nesting level deeper) must not retire and re-add every
735
+ line of the block.
736
+
737
+ Moved blocks: a line group that exists in both revisions but at
738
+ different positions falls out of the primary alignment as a
739
+ delete run plus an insert run. A second match over the unmatched
740
+ lines of both sides finds those pairs and cancels them - the
741
+ block emits once, unmarked, at its new position, instead of
742
+ being retired and re-added wholesale (the "retired and
743
+ reintroduced" look generic text diffs avoid by word matching).
744
+
745
+ Cancellation is only for old lines from *delete* regions: an old
746
+ line inside a replace region already has new-side partners there,
747
+ and when such lines are duplicated into a new structural context
748
+ (a member moved from one struct into a new band-level struct),
749
+ cancelling hides both the removal and the addition. The reviewer
750
+ must see the delete where it happened  and  the insert where the
751
+ copy now lives (latexdiff does the same there, while genuinely
752
+ re-sited blocks - old side deleted outright - still move quietly)
753
+ """
754
+ from difflib import SequenceMatcher
755
+
756
+ old = _split_verbatim(edit.old)
757
+ new = _split_verbatim(edit.new)
758
+ if old is None or new is None:
759
+ return None
760
+ _, old_lines, _ = old
761
+ begin, new_lines, end = new
762
+
763
+ def key(line: str) -> str:
764
+ return line.strip()
765
+
766
+ sm = SequenceMatcher(
767
+ a=[key(l) for l in old_lines], b=[key(l) for l in new_lines], autojunk=False
768
+ )
769
+ ops = sm.get_opcodes()
770
+
771
+ # second pass: match unmatched old lines against unmatched new
772
+ # lines to catch moved blocks (see docstring)
773
+ un_a = [i for op in ops if op[0] in ("delete", "replace") for i in range(op[1], op[2])]
774
+ un_b = [j for op in ops if op[0] in ("insert", "replace") for j in range(op[3], op[4])]
775
+ old_region: dict[int, str] = {
776
+ i: op[0] for op in ops for i in range(op[1], op[2])
777
+ }
778
+ cancels_a: set[int] = set()
779
+ cancels_b: set[int] = set()
780
+ if un_a and un_b:
781
+ sm2 = SequenceMatcher(
782
+ a=[key(old_lines[i]) for i in un_a],
783
+ b=[key(new_lines[j]) for j in un_b],
784
+ autojunk=False,
785
+ )
786
+ for blk in sm2.get_matching_blocks():
787
+ for k in range(blk.size):
788
+ i = un_a[blk.a + k]
789
+ # only old lines with no primary new partner may be
790
+ # cancelled as moved (delete-region lines); replace-
791
+ # region old lines keep their visible strikeout
792
+ if old_region.get(i) == "delete":
793
+ cancels_a.add(i)
794
+ cancels_b.add(un_b[blk.b + k])
795
+
796
+ out: list[str] = []
797
+ changed = False
798
+ for tag, i1, i2, j1, j2 in ops:
799
+ if tag == "equal":
800
+ out.extend(new_lines[j1:j2])
801
+ continue
802
+ changed = True
803
+ # old-only lines of this region, minus the moved (cancelled)
804
+ for i in range(i1, i2):
805
+ if i in cancels_a or not old_lines[i].strip():
806
+ continue
807
+ out.append(_DIF_DEL_MARK + old_lines[i])
808
+ # new lines stay in new revision order; cancelled ones are
809
+ # moved context and emit unmarked at their new position
810
+ for j in range(j1, j2):
811
+ if not new_lines[j].strip():
812
+ continue
813
+ out.append(
814
+ new_lines[j] if j in cancels_b else _DIF_ADD_MARK + new_lines[j]
815
+ )
816
+ if not changed:
817
+ return edit.new.text
818
+
819
+ # extend the optional argument with alsolanguage=DIFcode
820
+ begin = _add_alsolanguage(begin)
821
+ return f"\\DIFmodbegin\n{begin}\n" + "\n".join(out) + f"\n{end}\\DIFmodend"
822
+
823
+
824
+ def _add_alsolanguage(begin: str) -> str:
825
+ """Insert ``alsolanguage=DIFcode`` into a lstlisting begin marker.
826
+
827
+ ``\\begin{lstlisting}[opts,alsolanguage=DIFcode]`` - the option is
828
+ appended to an existing optional argument or one is created; a
829
+ begin without options keeps ``\\end`` counterpart untouched. For
830
+ non-option-taking plain ``verbatim`` the marker stays as-is: its
831
+ ``%DIF`` markers then degrade to ordinary (visible) comments,
832
+ which is latexdiff's own fallback.
833
+ """
834
+ m = re.match(r"^(\\begin\{[^}]+\})(\[.*\])?$", begin, re.S)
835
+ if not m:
836
+ return begin
837
+ opener, opts = m.group(1), m.group(2)
838
+ if opts is None:
839
+ return f"{opener}[alsolanguage=DIFcode]"
840
+ return f"{opener}{opts[:-1]},alsolanguage=DIFcode]"
841
+
842
+
843
+ def _comment_out_env(node: Node) -> str:
844
+ """Comment out a whole deleted verbatim environment line-wise.
845
+
846
+ Each line becomes ``%DIFDELCMD < line`` so nothing renders - the
847
+ reference build's behaviour for deleted listings (they would
848
+ otherwise inflate the document by their full body).
849
+ """
850
+ lines = node.text.split("\n")
851
+ # trailing blank lines carry no information and would render as
852
+ # bare marker lines; every interior blank line keeps one so the
853
+ # environment's structure stays recognisable in the source
854
+ while lines and not lines[-1].strip():
855
+ lines.pop()
856
+ return "\n".join(
857
+ f"%DIFDELCMD < {line}".rstrip() for line in lines
858
+ ) + "\n"
859
+
860
+
861
+ def _is_safe_inline(text: str) -> bool:
862
+ """True when a text run can live inside \\DIFadd{...}/\\DIFdel{...}.
863
+
864
+ Inline markup expands to ``\\uwave``/``\\sout`` (LR mode): the run
865
+ must be single-line, contain no environment boundaries and no
866
+ macro-with-argument - **including macros with optional
867
+ ``[..]`` arguments** (``\\makecell[tl]{..}``, ``\\cite[x]{..}``):
868
+ wrapping those inline would let the markup's closing brace
869
+ terminate the macro argument instead. Used by the regular node
870
+ wrap and the per-cell wraps inside table row regions - a
871
+ multi-line run keeps the region colour (added) or gets commented
872
+ out (deleted), which is latexdiff's degraded-mode behaviour for
873
+ such cells.
874
+ """
875
+ if "\\begin" in text or "\\end" in text:
876
+ return False
877
+ if "\n" in text:
878
+ return False
879
+ if _ARG_MACRO_RE.search(text) or _ARG_MACRO_BR_RE.search(text):
880
+ return False
881
+ if _LIST_ITEM_RE.search(text):
882
+ # \item / \par inside a strikeout/wave is LR-mode illegal
883
+ # ("Lonely \item") - cells with nested lists keep the region
884
+ # colour instead of inline markup
885
+ return False
886
+ return True
887
+
888
+
889
+ def _needs_block(node: Node) -> bool:
890
+ """True when the node's text cannot live inside an LR-mode macro.
891
+
892
+ * macro nodes: an argument-following macro wrapped inline would
893
+ have its argument stolen by the markup braces
894
+ (``\\DIFadd{\\subsubsubsection}`` breaks the arity);
895
+ * environments and multi-line runs: ``\\uwave``/``\\sout`` cannot
896
+ span paragraph or table structure.
897
+ Content check covers macro text hidden in merged (coalesced)
898
+ runs and synthetic text nodes.
899
+ """
900
+ if node.kind in {"macro", "env"}:
901
+ return True
902
+ return not _is_safe_inline(node.text)
903
+
904
+
905
+ # a control sequence directly followed by an argument brace (possibly
906
+ # after optional ``[…]`` arguments): wrapping it inline would let the
907
+ # markup's closing brace terminate the macro argument instead
908
+ # (arity breakage)
909
+ _ARG_MACRO_RE = re.compile(r"\\[a-zA-Z]+\*?\s*\{")
910
+ _ARG_MACRO_BR_RE = re.compile(r"\\[a-zA-Z]+\*?(?:\[[^\[\]]*\])+\s*\{")
911
+
912
+
913
+ def _render_recursed(edit: Modify, markup: LatexdiffMarkup) -> str:
914
+ """Render a recursed Modify: wrapper + inner script + wrapper."""
915
+ wrapped = _wrappers(edit.old)
916
+ if wrapped is None:
917
+ # body not locatable in the source span: safest fallback is
918
+ # the whole-block del/add replacement
919
+ return _wrap(edit.old.text, markup.del_open, markup.del_close) + _wrap(
920
+ edit.new.text, markup.add_open, markup.add_close
921
+ )
922
+ prefix, suffix = wrapped
923
+ return prefix + _render_row_region(edit.inner or [], markup) + suffix
924
+
925
+
926
+ def _render_row_region(edits: list[Edit], markup: LatexdiffMarkup) -> str:
927
+ """Render an inner edit list, merging Delete/Insert row pairs.
928
+
929
+ A changed longtable row normally renders as a deleted row region
930
+ followed by a re-added one. For rows made of multi-line makecell
931
+ cells that layout visibly breaks the table: the deleted region's
932
+ commented-out lines leave the row-end ``\\`` dangling, and the
933
+ re-added row duplicates the row (and its struck cells jam a ghost
934
+ row with double the column count into the grid).
935
+
936
+ The reference build instead emits ONE row: common cell lines kept
937
+ (``\\color{black}``), genuinely new lines blue, old-only lines
938
+ ``%DIFDELCMD`` inside a ``\\DIFdelbegin...\\DIFdelend`` span just
939
+ before the new span. :func:`_merge_row_text` produces that
940
+ layout; anything but a qualifying row pair takes the normal
941
+ :func:`render` path.
942
+ """
943
+ out: list[str] = []
944
+ i = 0
945
+ while i < len(edits):
946
+ e = edits[i]
947
+ if (
948
+ i + 1 < len(edits)
949
+ and isinstance(e, Delete)
950
+ and isinstance(edits[i + 1], Insert)
951
+ and e.old.kind == "row"
952
+ and edits[i + 1].new.kind == "row"
953
+ and _row_pair_matches(e.old.text, edits[i + 1].new.text)
954
+ ):
955
+ merged, consumed = _merge_row_pair(
956
+ edits[i], edits[i + 1], i, edits
957
+ )
958
+ out.append(merged)
959
+ i += consumed
960
+ continue
961
+ # single edit: normal render path, but recurse for inner lists
962
+ if isinstance(e, Modify) and e.inner is not None:
963
+ out.append(_render_recursed(e, markup))
964
+ elif isinstance(e, Match):
965
+ out.append(e.node.text)
966
+ elif isinstance(e, Insert):
967
+ out.append(_wrap_node(e.new, markup, added=True))
968
+ elif isinstance(e, Delete):
969
+ out.append(_wrap_node(e.old, markup, added=False))
970
+ i += 1
971
+ return "".join(out)
972
+
973
+
974
+ def _row_pair_matches(old_row: str, new_row: str) -> bool:
975
+ """Do a deleted/inserted row pair look like the same logical row?"""
976
+ old_lines = {l for l in (oldlines.norm_line(x) for x in old_row.split("\n")) if l}
977
+ new_lines = {l for l in (oldlines.norm_line(x) for x in new_row.split("\n")) if l}
978
+ if not old_lines or not new_lines:
979
+ return False
980
+ shared = old_lines & new_lines
981
+ return len(shared) >= max(len(old_lines), len(new_lines)) * _ROW_MERGE_SIMILARITY
982
+
983
+
984
+ _ROW_MERGE_SIMILARITY = 0.35 # shared-line fraction below which pairs stay separate
985
+
986
+
987
+ def _merge_row_pair(
988
+ delete: Delete, insert: Insert, idx: int, edits: list[Edit]
989
+ ) -> tuple[str, int]:
990
+ """Render a deleted+inserted row pair as one merged row (reference form)."""
991
+ from .textdiff import word_diff
992
+ from difflib import SequenceMatcher
993
+
994
+ old_row, new_row = delete.old.text, insert.new.text
995
+ old_lines = old_row.split("\n")
996
+ new_lines = new_row.split("\n")
997
+ new_norms = {oldlines.norm_line(x) for x in new_lines}
998
+
999
+ out: list[str] = []
1000
+ # leading structure lines of the old row (\hline) stay visible
1001
+ first_content = next(
1002
+ (l for l in old_lines if l.strip() and not _STRUCT_LINE_RE.match(l)),
1003
+ None,
1004
+ )
1005
+ lead = old_row[: old_row.find(first_content)] if first_content else ""
1006
+ out.append(lead)
1007
+
1008
+ # old-only content lines: commented out inside \DIFdelbegin...\DIFdelend;
1009
+ # lines also present in the new row are re-emitted (colour-marked) below.
1010
+ # A similar old/new line *pair* (one-char fix, small rewording) is
1011
+ # instead emitted once with word-level DIFdel/DIFadd marks - the
1012
+ # reference treatment of the "Inpu[p]t product files" typo fix.
1013
+ del_only = [
1014
+ l
1015
+ for l in old_lines
1016
+ if l.strip()
1017
+ and not _STRUCT_LINE_RE.match(l)
1018
+ and oldlines.norm_line(l) not in new_norms
1019
+ ]
1020
+ add_only = [
1021
+ l
1022
+ for l in new_lines
1023
+ if l.strip()
1024
+ and not _STRUCT_LINE_RE.match(l)
1025
+ and oldlines.norm_line(l) not in {oldlines.norm_line(x) for x in old_lines}
1026
+ ]
1027
+ pair_map: dict[int, str] = {} # index in del_only -> rendered replacement
1028
+ new_pair_map: dict[int, str] = {} # index in add_only -> rendered replacement
1029
+ used_new: set[int] = set()
1030
+ for oi, ol in enumerate(del_only):
1031
+ best_j, best_ratio = -1, 0.0
1032
+ for aj, al in enumerate(add_only):
1033
+ if aj in used_new:
1034
+ continue
1035
+ ratio = SequenceMatcher(
1036
+ None, oldlines.norm_line(ol), oldlines.norm_line(al)
1037
+ ).ratio()
1038
+ if ratio > best_ratio:
1039
+ best_ratio, best_j = ratio, aj
1040
+ if best_j >= 0 and best_ratio >= 0.80:
1041
+ rendering = _word_marked_line(ol, add_only[best_j])
1042
+ if rendering:
1043
+ pair_map[oi] = rendering
1044
+ new_pair_map[best_j] = rendering
1045
+ used_new.add(best_j)
1046
+ replaced = set(pair_map)
1047
+ rendered_added = set(new_pair_map)
1048
+
1049
+ if any(oi not in replaced for oi in range(len(del_only))):
1050
+ out.append("\\DIFdelbeginFL\n")
1051
+ for oi, l in enumerate(del_only):
1052
+ if oi not in replaced:
1053
+ out.append(f"%DIFDELCMD < {l} \\%%\n")
1054
+ out.append("\\DIFdelendFL\n")
1055
+
1056
+ out.append("\\DIFaddbeginFL\n")
1057
+ ai = 0
1058
+ for line in new_lines:
1059
+ s = line.strip()
1060
+ if not s:
1061
+ out.append(line)
1062
+ continue
1063
+ # position of this line within add_only (if it is one)
1064
+ is_add_only = ai if (ai < len(add_only) and line == add_only[ai]) else None
1065
+ if is_add_only is not None:
1066
+ if is_add_only in rendered_added:
1067
+ # word-level pair: emit the marked pair line instead
1068
+ out.append(new_pair_map[is_add_only])
1069
+ elif oldlines.in_old(line):
1070
+ out.append(re.sub(r"^(\s*)(\S.*)$", r"\1\\color{black} \2", line))
1071
+ else:
1072
+ out.append(re.sub(r"^(\s*)(\S.*)$", r"\1\\color{blue} \2", line))
1073
+ ai += 1
1074
+ elif _STRUCT_LINE_RE.match(line):
1075
+ out.append(" " + s if not line[:1].isspace() else line)
1076
+ elif oldlines.in_old(line):
1077
+ out.append(re.sub(r"^(\s*)(\S.*)$", r"\1\\color{black} \2", line))
1078
+ else:
1079
+ out.append(re.sub(r"^(\s*)(\S.*)$", r"\1\\color{blue} \2", line))
1080
+ out.append("\\DIFaddendFL\n")
1081
+ return "".join(out), 2
1082
+
1083
+
1084
+ def _norm_core(s: str) -> str:
1085
+ """Minimal normalisation for contained-in checks of marked lines."""
1086
+ return re.sub(r"\\DIF(?:add|del)?(?:begin|end)?(?:FL)?|\\color\{(?:black|blue)\}|\s+", "", s)
1087
+
1088
+
1089
+ def _word_marked_line(old_line: str, new_line: str) -> str:
1090
+ """One line carrying word-level DIFdel/DIFadd marks.
1091
+
1092
+ Only for lines whose content is inline-safe on both sides (no
1093
+ ``&`` cell separators with unsafe runs, no macros-with-args in the
1094
+ changed region) - the wrap would otherwise break the row.
1095
+ """
1096
+ from .textdiff import (DELETE, INSERT, Chunk, word_diff)
1097
+
1098
+ chunks = word_diff(old_line, new_line)
1099
+ parts: list[str] = []
1100
+ for c in chunks:
1101
+ if c.op == "equal":
1102
+ parts.append(c.text)
1103
+ elif c.op == "delete":
1104
+ if not _is_safe_inline(c.text):
1105
+ return ""
1106
+ parts.append(f"\\DIFdelbegin \\DIFdel{{{c.text}}}\\DIFdelend ")
1107
+ else:
1108
+ if not _is_safe_inline(c.text):
1109
+ return ""
1110
+ parts.append(f"\\DIFadd{{{c.text}}}")
1111
+ return "".join(parts)
1112
+
1113
+
1114
+ def _wrappers(node: Node) -> tuple[str, str] | None:
1115
+ """Locate a recursable node's delimiters around its children.
1116
+
1117
+ Children span the node body contiguously; the wrapper is whatever
1118
+ precedes/follows that body span in ``node.text``. Returns ``None``
1119
+ when the body cannot be located (paranoia: fall back to block
1120
+ replace rather than emit wrong bytes).
1121
+ """
1122
+ if not node.children:
1123
+ return None
1124
+ body = "".join(c.text for c in node.children)
1125
+ text = node.text
1126
+ if node.kind == "group":
1127
+ prefix, suffix = "{", "}"
1128
+ elif node.kind == "env" and node.name:
1129
+ idx = text.rfind(f"\\end{{{node.name}}}")
1130
+ if idx < 0:
1131
+ return None
1132
+ suffix = text[idx:]
1133
+ prefix = text[: idx - len(body)]
1134
+ else:
1135
+ return None
1136
+ if prefix + body + suffix != text:
1137
+ return None
1138
+ return prefix, suffix
1139
+
1140
+
1141
+ def _wrap(text: str, open_: str, close: str) -> str:
1142
+ """Wrap text, keeping leading/trailing whitespace outside the macro.
1143
+
1144
+ ``\\DIFdel{ word }`` would render the underlined span with the
1145
+ surrounding spaces, blowing up line lengths; hoisting them out
1146
+ keeps the marked region tight and the source readable.
1147
+ """
1148
+ stripped = text.strip()
1149
+ if not stripped:
1150
+ return text
1151
+ lead = text[: len(text) - len(text.lstrip())]
1152
+ trail = text[len(text.rstrip()) :]
1153
+ return f"{lead}{open_}{stripped}{close}{trail}"
1154
+
1155
+
1156
+ PREAMBLE_TEMPLATE = """\
1157
+ %DIF PREAMBLE EXTENSION ADDED BY texdiff
1158
+ \\RequirePackage[normalem]{ulem} %DIF PREAMBLE
1159
+ \\RequirePackage{color} %DIF PREAMBLE
1160
+ %DIF single braces around the marked text: double braces make the
1161
+ %DIF \\uwave/\\sout argument one unbreakable box and long retired
1162
+ %DIF sentences run past the right page border
1163
+ \\providecommand{\\DIFadd}[1]{{\\protect\\color{blue}\\uwave{#1}}} %DIF PREAMBLE
1164
+ \\providecommand{\\DIFdel}[1]{{\\protect\\color{red}\\sout{#1}}} %DIF PREAMBLE
1165
+ %DIF block markers: colour declarations (visible outside tables)
1166
+ \\providecommand{\\DIFaddbegin}{\\color{blue}} %DIF PREAMBLE
1167
+ \\providecommand{\\DIFaddend}{\\color{black}} %DIF PREAMBLE
1168
+ \\providecommand{\\DIFdelbegin}{\\color{red}} %DIF PREAMBLE
1169
+ \\providecommand{\\DIFdelend}{\\color{black}} %DIF PREAMBLE
1170
+ %DIF row-region markers: no-op block delimiters (latexdiff FL
1171
+ %DIF style). Visibility inside rows comes from the per-cell inline
1172
+ %DIF markup and from line-wise colouring of runs that cannot take
1173
+ %DIF the inline wrap; a colour-switching marker after a row end and
1174
+ %DIF before a rule would break the table parser
1175
+ \\providecommand{\\DIFaddbeginFL}{} %DIF PREAMBLE
1176
+ \\providecommand{\\DIFaddendFL}{} %DIF PREAMBLE
1177
+ \\providecommand{\\DIFdelbeginFL}{} %DIF PREAMBLE
1178
+ \\providecommand{\\DIFdelendFL}{} %DIF PREAMBLE
1179
+ %DIF modified-block markers: no-op delimiters around line-marked
1180
+ %DIF regions (verbatim environments diffed line by line)
1181
+ \\providecommand{\\DIFmodbegin}{} %DIF PREAMBLE
1182
+ \\providecommand{\\DIFmodend}{} %DIF PREAMBLE
1183
+ %DIF COLORLISTINGS: language whose line delimiters typeset the
1184
+ %DIF markers themselves as hidden colour runs, so %DIF < / %DIF >
1185
+ %DIF prefixes inside listings render as strikeout / wavy text
1186
+ %DIF instead of literal source noise
1187
+ \\RequirePackage{listings} %DIF PREAMBLE
1188
+ \\lstdefinelanguage{DIFcode}{ %DIF PREAMBLE
1189
+ moredelim=[il][\\color{red}\\sout]{\\%DIF\\ <\\ }, %DIF PREAMBLE
1190
+ moredelim=[il][\\color{blue}]{\\%DIF\\ >\\ } %DIF PREAMBLE
1191
+ } %DIF PREAMBLE
1192
+ %DIF END PREAMBLE EXTENSION ADDED BY texdiff
1193
+ """