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/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)