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/preamble.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Preamble handling: split, compare, and choose which revision wins.
|
|
2
|
+
|
|
3
|
+
Policy (v1):
|
|
4
|
+
|
|
5
|
+
* the marked-up output uses the **new** revision's preamble. The diff
|
|
6
|
+
has to render with the current document styling; marking up
|
|
7
|
+
preamble macro definitions with ``\\DIFadd``/``\\DIFdel`` would
|
|
8
|
+
define/de-define packages mid-document and break compilation;
|
|
9
|
+
* preamble differences are counted (``DiffStats.preamble_changes``)
|
|
10
|
+
rather than marked up, so callers can report "preamble changed"
|
|
11
|
+
without risking a broken diff;
|
|
12
|
+
* fragments (no ``\\begin{document}``) have an empty preamble and are
|
|
13
|
+
never touched.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def split_preamble(source: str) -> tuple[str, str, str]:
|
|
23
|
+
"""Split a document into (preamble, body, postscript).
|
|
24
|
+
|
|
25
|
+
``preamble`` includes everything up to and including the
|
|
26
|
+
``\\begin{document}`` line; ``postscript`` starts at
|
|
27
|
+
``\\end{document}`` and covers the rest; ``body`` is the
|
|
28
|
+
in-between and parses standalone. A document without
|
|
29
|
+
``\\begin{document}`` is a fragment: empty preamble and post.
|
|
30
|
+
"""
|
|
31
|
+
idx = source.find("\\begin{document}")
|
|
32
|
+
if idx < 0:
|
|
33
|
+
return "", source, ""
|
|
34
|
+
end = idx + len("\\begin{document}")
|
|
35
|
+
# include trailing spaces and ONE newline only when nothing but
|
|
36
|
+
# whitespace follows on that line - glued documents
|
|
37
|
+
# (\begin{document}body...) must keep the body as body
|
|
38
|
+
rest_of_line = source[end : source.find("\n", end) if "\n" in source[end:] else len(source)]
|
|
39
|
+
stripped = rest_of_line.strip()
|
|
40
|
+
if not stripped or stripped.startswith("\\end{document}"):
|
|
41
|
+
nl = source.find("\n", end)
|
|
42
|
+
if nl >= 0:
|
|
43
|
+
end = nl + 1
|
|
44
|
+
pre = source[:end]
|
|
45
|
+
|
|
46
|
+
endidx = source.find("\\end{document}", end)
|
|
47
|
+
if endidx < 0:
|
|
48
|
+
return pre, source[end:], ""
|
|
49
|
+
return pre, source[end:endidx], source[endidx:]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def preamble_tuple(source: str) -> tuple[str, str]:
|
|
53
|
+
"""Two-tuple view for callers that ignore the body split."""
|
|
54
|
+
pre, _body, post = split_preamble(source)
|
|
55
|
+
return pre, post
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def new_preamble(source: str) -> str:
|
|
59
|
+
"""The preamble used for the marked-up output (the new revision's)."""
|
|
60
|
+
pre, _ = preamble_tuple(source)
|
|
61
|
+
return pre
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_preamble_changes(old_source: str, new_source: str) -> int:
|
|
65
|
+
"""Count changed preamble *lines* between the two revisions.
|
|
66
|
+
|
|
67
|
+
Uses line-level ratio so reordering or reformatting counts as a
|
|
68
|
+
change, but identical preambles count zero. Comments and blank
|
|
69
|
+
lines are ignored (they carry no rendering semantics).
|
|
70
|
+
"""
|
|
71
|
+
old_lines = _meaningful(old_source)
|
|
72
|
+
new_lines = _meaningful(new_source)
|
|
73
|
+
if not old_lines and not new_lines:
|
|
74
|
+
return 0
|
|
75
|
+
sm = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
|
76
|
+
changed = 0
|
|
77
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
78
|
+
if tag != "equal":
|
|
79
|
+
changed += max(i2 - i1, j2 - j1)
|
|
80
|
+
return changed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _meaningful(source: str) -> list[str]:
|
|
84
|
+
pre, _body, _post = split_preamble(source)
|
|
85
|
+
out = []
|
|
86
|
+
for line in pre.splitlines():
|
|
87
|
+
stripped = line.strip()
|
|
88
|
+
if not stripped or stripped.startswith("%"):
|
|
89
|
+
continue
|
|
90
|
+
out.append(stripped)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# \def\name{...} / \newcommand{\name}{...} definitions in the preamble
|
|
95
|
+
_DEF_RE = re.compile(r"\\(?:def|gdef|edef|xdef)\s*\\([a-zA-Z]+)\s*(?=\{)")
|
|
96
|
+
_NEWCOMMAND_RE = re.compile(r"\\(?:re)?newcommand\*?\s*\{\s*\\([a-zA-Z]+)\s*\}")
|
|
97
|
+
# a reference to the macro later in the document (used, not defined)
|
|
98
|
+
_USE_RE_CACHE: dict[str, re.Pattern[str]] = {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _invoked_preamble_macros(pre: str, body: str) -> set[str]:
|
|
102
|
+
"""Preamble-defined macros that are used (invoked) in the body.
|
|
103
|
+
|
|
104
|
+
Only definitions followed by a body invocation qualify: their
|
|
105
|
+
content is typeset at the use site, so colouring inside their
|
|
106
|
+
bodies is visible (and safe - the colour is scoped by whatever
|
|
107
|
+
group the body wraps it in, e.g. each table cell).
|
|
108
|
+
"""
|
|
109
|
+
defined = {m.group(1) for m in _DEF_RE.finditer(pre)}
|
|
110
|
+
defined |= {m.group(1) for m in _NEWCOMMAND_RE.finditer(pre)}
|
|
111
|
+
return {n for n in defined if re.search(rf"\\{n}\s*[^a-zA-Z]", body) or f"\\{n}" == body.strip()}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _norm_line(line: str) -> str:
|
|
115
|
+
"""Normalised content of a macro-body line for comparison.
|
|
116
|
+
|
|
117
|
+
Mirrors refine-diff.pl's ``norm``: latexdiff markers, control
|
|
118
|
+
sequences, special characters and whitespace are dropped, so a
|
|
119
|
+
line that merely absorbed a moved ``\\hline`` or closing brace
|
|
120
|
+
still compares equal to its old counterpart. Only the *words*
|
|
121
|
+
carry identity.
|
|
122
|
+
"""
|
|
123
|
+
s = re.sub(r"%DIF.*$", "", line)
|
|
124
|
+
s = re.sub(r"\\[a-zA-Z]+\s*", "", s)
|
|
125
|
+
s = re.sub(r"[%\\{}\[\]&]", "", s)
|
|
126
|
+
return re.sub(r"\s+", "", s)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _changed_macro_lines(old_pre: str, new_pre: str, names: set[str]) -> list[str]:
|
|
130
|
+
"""Content lines present only in the new preamble macro bodies.
|
|
131
|
+
|
|
132
|
+
A *changed macro line* is a line inside a ``\\name{...}``
|
|
133
|
+
definition whose normalised content has no counterpart in the
|
|
134
|
+
old revision's bodies of the same macros. Closures (``}`` alone)
|
|
135
|
+
and pure structure do not count: they merely move when rows are
|
|
136
|
+
appended.
|
|
137
|
+
"""
|
|
138
|
+
old_norms = {_norm_line(l) for l in _macro_body_lines(old_pre, names)}
|
|
139
|
+
changed: list[str] = []
|
|
140
|
+
for line in _macro_body_lines(new_pre, names):
|
|
141
|
+
if not line.strip() or _is_closing_line(line):
|
|
142
|
+
continue
|
|
143
|
+
if len(_norm_line(line)) < 4:
|
|
144
|
+
continue
|
|
145
|
+
if _norm_line(line) not in old_norms:
|
|
146
|
+
changed.append(line)
|
|
147
|
+
return changed
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _macro_body_lines(pre: str, names: set[str]) -> list[str]:
|
|
151
|
+
"""Lines belonging to the tracked macro definitions' bodies."""
|
|
152
|
+
out: list[str] = []
|
|
153
|
+
active = False
|
|
154
|
+
depth = 0
|
|
155
|
+
for line in pre.splitlines():
|
|
156
|
+
started = False
|
|
157
|
+
for name in names:
|
|
158
|
+
if re.search(rf"\\(?:def\s*|gdef\s*|edef\s*|xdef\s*)\\{name}\s*\{{", line) or re.search(
|
|
159
|
+
rf"\\(?:re)?newcommand\*?\s*\{{\s*\\{name}\s*\}}", line
|
|
160
|
+
):
|
|
161
|
+
active = True
|
|
162
|
+
started = True
|
|
163
|
+
if active:
|
|
164
|
+
out.append(line)
|
|
165
|
+
depth += line.count("{") - line.count("}")
|
|
166
|
+
if not started and depth <= 0:
|
|
167
|
+
active = False
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_closing_line(line: str) -> bool:
|
|
172
|
+
"""A line that only closes a group / carries no content (``}``)."""
|
|
173
|
+
return not re.sub(r"[{}\s%]", "", line)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def mark_preamble_macro_changes(
|
|
177
|
+
marked_up: str, old_source: str, new_source: str
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Colour added lines of body-invoked preamble macros (blue).
|
|
180
|
+
|
|
181
|
+
Preamble macro definitions whose content is *typeset* via a use
|
|
182
|
+
in the document body (a change-record table held in
|
|
183
|
+
``\\def\\name{...}`` and expanded with ``\\name``) would
|
|
184
|
+
otherwise swallow their additions silently: the preamble policy
|
|
185
|
+
emits the new preamble verbatim, so new rows of the change record
|
|
186
|
+
render indistinguishable from the kept ones.
|
|
187
|
+
|
|
188
|
+
For each such macro, lines of its body that are new (absent from
|
|
189
|
+
the old revision's bodies of the same macros) get the same
|
|
190
|
+
per-cell colour treatment the emitter uses for unsafe added runs
|
|
191
|
+
- ``\\color{blue}`` re-started after each ``&``. Deleted lines are
|
|
192
|
+
commented out (``%DIF <``, latexdiff preamble convention) so the
|
|
193
|
+
old rows stay visible in the source without typesetting.
|
|
194
|
+
|
|
195
|
+
The operation is textual and strictly contained in the preamble;
|
|
196
|
+
it cannot affect compilation because ``\\color`` inside a macro
|
|
197
|
+
body is scoped by the table cells of the typeset body.
|
|
198
|
+
"""
|
|
199
|
+
pre_old, _body_old, _post_old = split_preamble(old_source)
|
|
200
|
+
pre_new, body_new, _post_new = split_preamble(new_source)
|
|
201
|
+
if not pre_new:
|
|
202
|
+
return marked_up
|
|
203
|
+
names = _invoked_preamble_macros(pre_new, body_new)
|
|
204
|
+
# only macro bodies that are typeset as TABLES may carry the
|
|
205
|
+
# per-cell colour markup: a \color is only meaningful - and only
|
|
206
|
+
# safe - inside table cells. Plain macros (\newcommand{\issue})
|
|
207
|
+
# change value between revisions; they keep the new value
|
|
208
|
+
# silently, exactly like refine-diff.pl leaves them.
|
|
209
|
+
names = {n for n in names if _body_is_table(pre_new, n)}
|
|
210
|
+
if not names:
|
|
211
|
+
return marked_up
|
|
212
|
+
changed = _changed_macro_lines(pre_old, pre_new, names)
|
|
213
|
+
# deleted lines live only in the OLD preamble: re-insert them as
|
|
214
|
+
# comments at their old position next to the surviving lines
|
|
215
|
+
deleted = _changed_macro_lines(pre_new, pre_old, names)
|
|
216
|
+
if not changed and not deleted:
|
|
217
|
+
return marked_up
|
|
218
|
+
return (
|
|
219
|
+
_apply_macro_markup(pre_old, pre_new, changed, deleted, names)
|
|
220
|
+
+ marked_up[len(pre_new) :]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _apply_macro_markup(
|
|
225
|
+
old_pre: str,
|
|
226
|
+
new_pre: str,
|
|
227
|
+
changed: list[str],
|
|
228
|
+
deleted: list[str],
|
|
229
|
+
names: set[str],
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Merge the old and new tracked macro bodies, marked per line.
|
|
232
|
+
|
|
233
|
+
Emits the NEW preamble with, inside each tracked macro body:
|
|
234
|
+
|
|
235
|
+
* genuinely new lines prefixed ``\\color{blue}`` per cell;
|
|
236
|
+
* lines that existed only in the old body re-inserted at their
|
|
237
|
+
original position as ``%DIF <`` comments (latexdiff preamble
|
|
238
|
+
convention: invisible in the typeset output, reviewable in
|
|
239
|
+
source).
|
|
240
|
+
|
|
241
|
+
The merge aligns the old and new body lines on their normalized
|
|
242
|
+
content (SequenceMatcher over normalized lines): equal lines are
|
|
243
|
+
kept verbatim, old-only lines become comments, new-only lines
|
|
244
|
+
are coloured blue.
|
|
245
|
+
"""
|
|
246
|
+
def _apply_macro_markup(
|
|
247
|
+
old_pre: str,
|
|
248
|
+
new_pre: str,
|
|
249
|
+
changed: list[str],
|
|
250
|
+
deleted: list[str],
|
|
251
|
+
names: set[str],
|
|
252
|
+
) -> str:
|
|
253
|
+
"""Merge the old and new tracked macro bodies, marked per line.
|
|
254
|
+
|
|
255
|
+
Emits the NEW preamble with, inside EACH tracked macro body:
|
|
256
|
+
|
|
257
|
+
* genuinely new lines prefixed ``\\color{blue}`` per cell;
|
|
258
|
+
* lines that existed only in the old body re-inserted at their
|
|
259
|
+
original position as ``%DIF <`` comments (latexdiff preamble
|
|
260
|
+
convention: invisible in the typeset output, reviewable in
|
|
261
|
+
source).
|
|
262
|
+
|
|
263
|
+
Each macro's old and new body lines are aligned on their
|
|
264
|
+
normalized content (SequenceMatcher): equal lines kept verbatim,
|
|
265
|
+
old-only lines commented, new-only lines coloured blue. Bodies
|
|
266
|
+
are spliced back in reverse document order so earlier line
|
|
267
|
+
indices stay valid.
|
|
268
|
+
"""
|
|
269
|
+
pre_out = new_pre.splitlines()
|
|
270
|
+
spliced = False
|
|
271
|
+
for name, rng in _body_ranges(new_pre, names):
|
|
272
|
+
new_body = _macro_body(new_pre, name)
|
|
273
|
+
old_body = _macro_body(old_pre, name)
|
|
274
|
+
sm = difflib.SequenceMatcher(
|
|
275
|
+
a=[_norm_line(l) for l in old_body],
|
|
276
|
+
b=[_norm_line(l) for l in new_body],
|
|
277
|
+
autojunk=False,
|
|
278
|
+
)
|
|
279
|
+
merged: list[str] = []
|
|
280
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
281
|
+
if tag == "equal":
|
|
282
|
+
# keep the NEW revision's bytes: brace/structure
|
|
283
|
+
# placement (e.g. the macro's closing "}") belongs to
|
|
284
|
+
# the new document - old bytes would close the body
|
|
285
|
+
# prematurely and leak later lines outside the macro
|
|
286
|
+
merged.extend(new_body[i1:i2])
|
|
287
|
+
elif tag == "delete":
|
|
288
|
+
merged.extend(f"%DIF < {l}" for l in old_body[i1:i2])
|
|
289
|
+
elif tag == "insert":
|
|
290
|
+
merged.extend(_color_line(l) for l in new_body[j1:j2])
|
|
291
|
+
else: # replace
|
|
292
|
+
merged.extend(f"%DIF < {l}" for l in old_body[i1:i2])
|
|
293
|
+
merged.extend(_color_line(l) for l in new_body[j1:j2])
|
|
294
|
+
if merged != new_body or len(merged) != len(new_body):
|
|
295
|
+
pre_out[rng[0] : rng[1]] = merged
|
|
296
|
+
spliced = True
|
|
297
|
+
elif merged == new_body and _body_changed(old_body, new_body):
|
|
298
|
+
# identical render but changes exist (colours dropped):
|
|
299
|
+
# still splice to carry them
|
|
300
|
+
pre_out[rng[0] : rng[1]] = merged
|
|
301
|
+
spliced = True
|
|
302
|
+
if not spliced:
|
|
303
|
+
return new_pre
|
|
304
|
+
result = "\n".join(pre_out)
|
|
305
|
+
return result + ("\n" if new_pre.endswith("\n") else "")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _body_changed(old_body: list[str], new_body: list[str]) -> bool:
|
|
309
|
+
"""True when the bodies differ on normalized content."""
|
|
310
|
+
return [_norm_line(l) for l in old_body] != [_norm_line(l) for l in new_body]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _body_is_table(pre: str, name: str) -> bool:
|
|
314
|
+
"""True when a macro's body contains table rows (cells + row ends).
|
|
315
|
+
|
|
316
|
+
The criterion is structural: at least one unescaped ``&`` cell
|
|
317
|
+
separator AND at least one ``\\\\`` row terminator anywhere in
|
|
318
|
+
the body. Change-record tables (``v3 & date & id & text \\\\``)
|
|
319
|
+
satisfy it; scalar macros like ``\\newcommand{\\issue}{v2}`` do
|
|
320
|
+
not.
|
|
321
|
+
"""
|
|
322
|
+
body = "\n".join(_macro_body(pre, name))
|
|
323
|
+
stripped = _strip_comment_line_wise(body)
|
|
324
|
+
has_cells = bool(re.search(r"(?<!\\)&", stripped))
|
|
325
|
+
has_rows = "\\\\" in stripped
|
|
326
|
+
return has_cells and has_rows
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _strip_comment_line_wise(text: str) -> str:
|
|
330
|
+
"""Remove ``%`` comments from each line (but keep escaped ``\\%``)."""
|
|
331
|
+
return "\n".join(_strip_comment(l) for l in text.splitlines())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _body_ranges(pre: str, names: set[str]) -> list[tuple[str, tuple[int, int]]]:
|
|
335
|
+
"""(name, (start, end)) line ranges of tracked macro bodies.
|
|
336
|
+
|
|
337
|
+
Returned in document order. Bodies are brace-delimited and thus
|
|
338
|
+
disjoint; a definition not found yields no range.
|
|
339
|
+
"""
|
|
340
|
+
lines = pre.splitlines()
|
|
341
|
+
out: list[tuple[str, tuple[int, int]]] = []
|
|
342
|
+
start = None
|
|
343
|
+
depth = 0
|
|
344
|
+
for idx, line in enumerate(lines):
|
|
345
|
+
if start is None:
|
|
346
|
+
for name in names:
|
|
347
|
+
if re.search(
|
|
348
|
+
rf"\\(?:def\s*|gdef\s*|edef\s*|xdef\s*)\\{name}\s*\{{", line
|
|
349
|
+
) or re.search(
|
|
350
|
+
rf"\\(?:re)?newcommand\*?\s*\{{\s*\\{name}\s*\}}", line
|
|
351
|
+
):
|
|
352
|
+
start = (name, idx)
|
|
353
|
+
depth = _brace_delta(_strip_comment(line))
|
|
354
|
+
if depth <= 0:
|
|
355
|
+
out.append((name, (idx, idx + 1)))
|
|
356
|
+
start = None
|
|
357
|
+
break
|
|
358
|
+
else:
|
|
359
|
+
depth += _brace_delta(_strip_comment(line))
|
|
360
|
+
if depth <= 0:
|
|
361
|
+
out.append((start[0], (start[1], idx + 1)))
|
|
362
|
+
start = None
|
|
363
|
+
if start is not None:
|
|
364
|
+
out.append((start[0], (start[1], len(lines))))
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _macro_body(pre: str, name: str) -> list[str]:
|
|
369
|
+
"""Lines of ONE tracked macro's definition body."""
|
|
370
|
+
for _name, rng in _body_ranges(pre, {name}):
|
|
371
|
+
if _name == name:
|
|
372
|
+
return pre.splitlines()[rng[0] : rng[1]]
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
def _brace_delta(line: str) -> int:
|
|
376
|
+
"""Net brace depth change of a line (comments removed)."""
|
|
377
|
+
return line.count("{") - line.count("}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _strip_comment(line: str) -> str:
|
|
381
|
+
"""Drop a trailing LaTeX comment (``%`` to end of line)."""
|
|
382
|
+
return re.sub(r"(?<!\\)%.*$", "", line)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _color_line(line: str) -> str:
|
|
386
|
+
"""Prepend \\color{blue} per table cell (after every &)."""
|
|
387
|
+
line = f"\\color{{blue}} {line}"
|
|
388
|
+
return re.sub(r"(?<!\\)&", r"& \\color{blue} ", line)
|