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/__init__.py +45 -0
- texdiff/align.py +202 -0
- texdiff/api.py +326 -0
- texdiff/check.py +61 -0
- texdiff/cli.py +87 -0
- texdiff/emit.py +1193 -0
- texdiff/flatten.py +185 -0
- texdiff/nodes.py +147 -0
- texdiff/oldlines.py +49 -0
- texdiff/parse.py +524 -0
- texdiff/preamble.py +388 -0
- texdiff/tables.py +540 -0
- texdiff/textdiff.py +157 -0
- texdiff-0.2.0.dist-info/METADATA +112 -0
- texdiff-0.2.0.dist-info/RECORD +19 -0
- texdiff-0.2.0.dist-info/WHEEL +5 -0
- texdiff-0.2.0.dist-info/entry_points.txt +2 -0
- texdiff-0.2.0.dist-info/licenses/LICENSE +21 -0
- texdiff-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
"""
|