calkit-python 0.49.4__py3-none-any.whl → 0.50.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.
- calkit/agent_skills/resolve-merge-conflicts/SKILL.md +72 -0
- calkit/cli/check.py +3 -1
- calkit/cli/fix.py +196 -0
- calkit/cli/latex.py +133 -7
- calkit/cli/main/core.py +168 -1
- calkit/cli/overleaf.py +114 -43
- calkit/cli/update.py +4 -1
- calkit/config.py +62 -1
- calkit/docx.py +288 -58
- calkit/latex.py +830 -29
- calkit/merge.py +636 -0
- calkit/models/pipeline.py +166 -6
- calkit/overleaf.py +183 -14
- calkit/pipeline.py +27 -3
- calkit/resources/README.md +1 -0
- calkit/resources/latex/calkit.sty +208 -0
- calkit/tests/cli/main/test_core.py +78 -0
- calkit/tests/cli/test_fix.py +174 -0
- calkit/tests/cli/test_latex.py +212 -0
- calkit/tests/cli/test_overleaf.py +85 -2
- calkit/tests/test_config.py +66 -0
- calkit/tests/test_docx.py +170 -0
- calkit/tests/test_latex.py +493 -0
- calkit/tests/test_merge.py +182 -0
- calkit/tests/test_overleaf.py +143 -4
- calkit/tests/test_pipeline.py +248 -0
- {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/METADATA +2 -1
- {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/RECORD +44 -38
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: resolve-merge-conflicts
|
|
3
|
+
description: Resolve Git merge conflicts in a Calkit project, including
|
|
4
|
+
dvc.lock, stage run records, and pipeline outputs. Use when the user
|
|
5
|
+
invokes `/calkit:resolve-merge-conflicts`, asks to merge a branch, or a
|
|
6
|
+
merge, pull, or rebase stopped with conflicts.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Resolve merge conflicts
|
|
10
|
+
|
|
11
|
+
Some conflicted files record what the pipeline ran rather than what anyone
|
|
12
|
+
wrote, so they're resolved per stage by Calkit, not by editing text.
|
|
13
|
+
|
|
14
|
+
## Division of labor
|
|
15
|
+
|
|
16
|
+
Done by Calkit, never by hand:
|
|
17
|
+
|
|
18
|
+
- `dvc.lock` and `.calkit/stage-runs.json`, in the project and any
|
|
19
|
+
subproject. Don't edit them or take one side whole with
|
|
20
|
+
`git checkout --ours/--theirs`: that mixes stages, or drops the records of
|
|
21
|
+
stages that only ran on the other side.
|
|
22
|
+
- Git-tracked pipeline outputs, which are checked out from the side each
|
|
23
|
+
stage's `dvc.lock` entry came from.
|
|
24
|
+
- `dvc.yaml`, which is generated from `calkit.yaml`. Once `calkit.yaml` is
|
|
25
|
+
resolved, take either side (`git checkout --ours dvc.yaml`) and run
|
|
26
|
+
`calkit status`, which rewrites it.
|
|
27
|
+
|
|
28
|
+
Done by you, with judgment: everything else, e.g., source code,
|
|
29
|
+
`calkit.yaml`, and manuscripts. When the two sides disagree about intent,
|
|
30
|
+
not just text, ask the user.
|
|
31
|
+
|
|
32
|
+
## Starting a merge
|
|
33
|
+
|
|
34
|
+
Prefer `calkit merge BRANCH` to `git merge`. It sets up the merge driver,
|
|
35
|
+
resolves what it can, and checks out DVC-tracked outputs afterwards. Most
|
|
36
|
+
stages merge without a conflict: one changed on one side only takes that
|
|
37
|
+
side.
|
|
38
|
+
|
|
39
|
+
## Stages changed on both sides
|
|
40
|
+
|
|
41
|
+
Each needs one choice, which decides its `dvc.lock` entry, run record, and
|
|
42
|
+
Git-tracked outputs together:
|
|
43
|
+
|
|
44
|
+
- `ours` or `theirs`: keep that side's result.
|
|
45
|
+
- `rerun`: keep neither, so `calkit run` reruns it.
|
|
46
|
+
|
|
47
|
+
You have no terminal for the prompt, so pass choices as options:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
calkit fix merge-conflicts --theirs fit --rerun figures
|
|
51
|
+
calkit fix merge-conflicts --prefer rerun
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Name subproject stages as `calkit check stage-runs` does, e.g.,
|
|
55
|
+
`sub/dvc.yaml:stage`. Run it without options first to list the stages that
|
|
56
|
+
need a choice.
|
|
57
|
+
|
|
58
|
+
To choose, compare each side's inputs with the merged ones. If the merged
|
|
59
|
+
inputs match one side, keep that side. If they match neither, e.g., both
|
|
60
|
+
sides edited the code, the stage will rerun either way, so choose `rerun`.
|
|
61
|
+
If a stage is expensive to run, or its result is shared with others, ask the
|
|
62
|
+
user. During a rebase, "ours" is the branch being rebased onto, and
|
|
63
|
+
"theirs" is the commit being replayed.
|
|
64
|
+
|
|
65
|
+
## Finishing
|
|
66
|
+
|
|
67
|
+
1. Resolve and `git add` the remaining files.
|
|
68
|
+
2. For a merge, run `calkit merge --continue`, with any stage choices still
|
|
69
|
+
needed. For a rebase or cherry-pick, run `calkit fix merge-conflicts`,
|
|
70
|
+
then `git rebase --continue` or `git cherry-pick --continue`.
|
|
71
|
+
3. Run `calkit status`, then `calkit run` if stages are stale.
|
|
72
|
+
4. Run `calkit check stage-runs` to confirm every stage matches its run.
|
calkit/cli/check.py
CHANGED
|
@@ -2466,7 +2466,9 @@ def check_storage(
|
|
|
2466
2466
|
stale: set[str] | None = set()
|
|
2467
2467
|
if any(p["stage"] for p in to_record):
|
|
2468
2468
|
status = calkit.pipeline.get_status(
|
|
2469
|
-
check_environments=False,
|
|
2469
|
+
check_environments=False,
|
|
2470
|
+
clean_notebooks=False,
|
|
2471
|
+
preprocess_stages=False,
|
|
2470
2472
|
)
|
|
2471
2473
|
if not status.errors:
|
|
2472
2474
|
stale = {n.split("@")[0] for n in status.stale_stage_names}
|
calkit/cli/fix.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""CLI for fixing things."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import typer
|
|
10
|
+
from typing_extensions import Annotated
|
|
11
|
+
|
|
12
|
+
from calkit.cli import AliasGroup, raise_error, warn
|
|
13
|
+
|
|
14
|
+
fix_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
OursOpt = Annotated[
|
|
17
|
+
list[str],
|
|
18
|
+
typer.Option(
|
|
19
|
+
"--ours",
|
|
20
|
+
"-o",
|
|
21
|
+
help="Keep our side of this stage (repeatable).",
|
|
22
|
+
show_default=False,
|
|
23
|
+
),
|
|
24
|
+
]
|
|
25
|
+
TheirsOpt = Annotated[
|
|
26
|
+
list[str],
|
|
27
|
+
typer.Option(
|
|
28
|
+
"--theirs",
|
|
29
|
+
"-t",
|
|
30
|
+
help="Keep their side of this stage (repeatable).",
|
|
31
|
+
show_default=False,
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
RerunOpt = Annotated[
|
|
35
|
+
list[str],
|
|
36
|
+
typer.Option(
|
|
37
|
+
"--rerun",
|
|
38
|
+
"-r",
|
|
39
|
+
help="Keep neither side of this stage, so it reruns (repeatable).",
|
|
40
|
+
show_default=False,
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
PreferOpt = Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--prefer",
|
|
47
|
+
help="Choice for every other stage changed on both sides "
|
|
48
|
+
"(ours, theirs, or rerun).",
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_merge_conflicts(
|
|
54
|
+
ours: list[str] | None = None,
|
|
55
|
+
theirs: list[str] | None = None,
|
|
56
|
+
rerun: list[str] | None = None,
|
|
57
|
+
prefer: str | None = None,
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Resolve conflicts in the files Calkit merges, asking for choices.
|
|
60
|
+
|
|
61
|
+
Returns what ``calkit.merge.resolve_conflicts`` does.
|
|
62
|
+
"""
|
|
63
|
+
import calkit.git
|
|
64
|
+
import calkit.merge
|
|
65
|
+
|
|
66
|
+
if prefer is not None and prefer not in calkit.merge.CHOICES:
|
|
67
|
+
raise_error(
|
|
68
|
+
f"--prefer must be one of {', '.join(calkit.merge.CHOICES)}"
|
|
69
|
+
)
|
|
70
|
+
choices: dict[str, str] = {}
|
|
71
|
+
for choice, names in [
|
|
72
|
+
(calkit.merge.OURS, ours),
|
|
73
|
+
(calkit.merge.THEIRS, theirs),
|
|
74
|
+
(calkit.merge.RERUN, rerun),
|
|
75
|
+
]:
|
|
76
|
+
for name in names or []:
|
|
77
|
+
if name in choices and choices[name] != choice:
|
|
78
|
+
raise_error(f"Stage '{name}' was given more than one choice")
|
|
79
|
+
choices[name] = choice
|
|
80
|
+
repo = calkit.git.get_repo()
|
|
81
|
+
operation = calkit.merge.get_operation(repo)
|
|
82
|
+
if operation is None:
|
|
83
|
+
raise_error("No merge, rebase, or cherry-pick is in progress")
|
|
84
|
+
assert operation is not None
|
|
85
|
+
labels = {}
|
|
86
|
+
for side, ref in [("ours", "HEAD"), ("theirs", operation[1])]:
|
|
87
|
+
labels[side] = repo.git.log("-1", "--format=%h %s", ref)
|
|
88
|
+
|
|
89
|
+
def choose(name: str, info: dict) -> str:
|
|
90
|
+
if not sys.stdin.isatty():
|
|
91
|
+
raise_error(
|
|
92
|
+
f"Stage '{name}' changed on both sides; choose with --ours, "
|
|
93
|
+
"--theirs, --rerun, or --prefer"
|
|
94
|
+
)
|
|
95
|
+
typer.echo(f"\nStage '{name}' changed on both sides:")
|
|
96
|
+
for side in ("ours", "theirs"):
|
|
97
|
+
typer.echo(f" {side} ({labels[side]}): {info[side]}")
|
|
98
|
+
if info["changed_deps"]:
|
|
99
|
+
typer.echo(
|
|
100
|
+
" Inputs that differ: " + ", ".join(info["changed_deps"])
|
|
101
|
+
)
|
|
102
|
+
answer = typer.prompt(
|
|
103
|
+
"Keep [o]urs, [t]heirs, or [r]erun it",
|
|
104
|
+
type=click.Choice(["o", "t", "r"]),
|
|
105
|
+
show_choices=False,
|
|
106
|
+
)
|
|
107
|
+
return {"o": "ours", "t": "theirs", "r": "rerun"}[answer]
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = calkit.merge.resolve_conflicts(
|
|
111
|
+
repo, choices=choices, prefer=prefer, choose=choose
|
|
112
|
+
)
|
|
113
|
+
except calkit.merge.MergeError as e:
|
|
114
|
+
raise_error(str(e))
|
|
115
|
+
unknown = sorted(set(choices) - set(result["choices"]))
|
|
116
|
+
for name in unknown:
|
|
117
|
+
warn(
|
|
118
|
+
f"Stage '{name}' wasn't changed on both sides; ignoring its choice"
|
|
119
|
+
)
|
|
120
|
+
for path in result["resolved"]:
|
|
121
|
+
typer.echo(f"Resolved {path}")
|
|
122
|
+
rerun_names = [
|
|
123
|
+
n for n, c in result["choices"].items() if c == calkit.merge.RERUN
|
|
124
|
+
]
|
|
125
|
+
if rerun_names:
|
|
126
|
+
typer.echo(
|
|
127
|
+
"Run 'calkit run' to rerun " + ", ".join(sorted(rerun_names))
|
|
128
|
+
)
|
|
129
|
+
if result["unmerged"]:
|
|
130
|
+
typer.echo("Still in conflict:")
|
|
131
|
+
for path in result["unmerged"]:
|
|
132
|
+
typer.echo(f" {path}")
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def checkout_dvc_outputs() -> None:
|
|
137
|
+
"""Check out DVC-tracked outputs to match dvc.lock."""
|
|
138
|
+
import calkit.dvc
|
|
139
|
+
import calkit.dvc.zip
|
|
140
|
+
|
|
141
|
+
if calkit.dvc.run_dvc_command(["checkout"]) != 0:
|
|
142
|
+
warn(
|
|
143
|
+
"Some DVC-tracked outputs couldn't be checked out; run "
|
|
144
|
+
"'calkit dvc pull' to fetch them"
|
|
145
|
+
)
|
|
146
|
+
calkit.dvc.zip.sync_all(direction="to-workspace")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@fix_app.command(name="merge-conflicts")
|
|
150
|
+
def fix_merge_conflicts(
|
|
151
|
+
ours: OursOpt = [],
|
|
152
|
+
theirs: TheirsOpt = [],
|
|
153
|
+
rerun: RerunOpt = [],
|
|
154
|
+
prefer: PreferOpt = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Resolve merge conflicts in dvc.lock and stage run records.
|
|
157
|
+
|
|
158
|
+
Each stage is merged whole. A stage changed on one side takes that side,
|
|
159
|
+
and one changed on both needs a choice, which also decides its run record
|
|
160
|
+
and Git-tracked outputs. Stages are named as in 'calkit check
|
|
161
|
+
stage-runs', e.g., 'sub/dvc.yaml:stage' for a subproject's.
|
|
162
|
+
"""
|
|
163
|
+
result = resolve_merge_conflicts(
|
|
164
|
+
ours=ours, theirs=theirs, rerun=rerun, prefer=prefer
|
|
165
|
+
)
|
|
166
|
+
operation = result["operation"]
|
|
167
|
+
unmerged = result["unmerged"]
|
|
168
|
+
finish = {
|
|
169
|
+
"merge": "calkit merge --continue",
|
|
170
|
+
"squash": "calkit merge --continue",
|
|
171
|
+
"rebase": "git rebase --continue",
|
|
172
|
+
"cherry-pick": "git cherry-pick --continue",
|
|
173
|
+
}[operation]
|
|
174
|
+
# DVC can't read the pipeline while a dvc.yaml is in conflict, and
|
|
175
|
+
# 'calkit merge --continue' checks outputs out after committing anyway
|
|
176
|
+
steps = [finish]
|
|
177
|
+
if any(posixpath.basename(p) == "dvc.yaml" for p in unmerged):
|
|
178
|
+
if operation not in ("merge", "squash"):
|
|
179
|
+
steps.insert(0, "calkit dvc checkout")
|
|
180
|
+
else:
|
|
181
|
+
checkout_dvc_outputs()
|
|
182
|
+
then = " and ".join(f"'{step}'" for step in steps)
|
|
183
|
+
if unmerged:
|
|
184
|
+
raise_error(f"Resolve the rest and 'git add' them, then run {then}")
|
|
185
|
+
typer.echo(f"Run {then} to finish")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@fix_app.command(name="storage")
|
|
189
|
+
def fix_storage() -> None:
|
|
190
|
+
"""Move paths to where they're declared to be stored (Git or DVC).
|
|
191
|
+
|
|
192
|
+
Same as 'calkit check storage --fix'.
|
|
193
|
+
"""
|
|
194
|
+
from calkit.cli.check import check_storage
|
|
195
|
+
|
|
196
|
+
check_storage(fix=True, as_json=False)
|
calkit/cli/latex.py
CHANGED
|
@@ -561,6 +561,17 @@ def _run_latexmk(
|
|
|
561
561
|
os.remove(fdb_path)
|
|
562
562
|
|
|
563
563
|
|
|
564
|
+
@latex_app.command(name="map-components")
|
|
565
|
+
def map_components(
|
|
566
|
+
tex_file: Annotated[
|
|
567
|
+
str, typer.Argument(help="The .tex file whose components to map.")
|
|
568
|
+
],
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Map in components a LaTeX document includes with `\\ck*` commands."""
|
|
571
|
+
for problem in calkit.latex.map_ck_components(tex_file):
|
|
572
|
+
warn(f"{problem}; it will print as ??")
|
|
573
|
+
|
|
574
|
+
|
|
564
575
|
@latex_app.command(name="build")
|
|
565
576
|
def build(
|
|
566
577
|
tex_file: Annotated[str, typer.Argument(help="The .tex file to compile.")],
|
|
@@ -657,6 +668,10 @@ def build(
|
|
|
657
668
|
tex_dir = os.path.dirname(tex_file) or "."
|
|
658
669
|
stem = Path(tex_file).stem
|
|
659
670
|
pdf_dir = output_dir if output_dir is not None else tex_dir
|
|
671
|
+
# Components the document includes with calkit.sty's commands, e.g.,
|
|
672
|
+
# \ckfigure, have to be beside it before TeX can read them
|
|
673
|
+
for problem in calkit.latex.map_ck_components(tex_file):
|
|
674
|
+
warn(f"{problem}; it will print as ??")
|
|
660
675
|
if not os.path.isfile(os.path.join(pdf_dir, stem + ".pdf")):
|
|
661
676
|
fdb_dir = aux_dir if aux_dir is not None else tex_dir
|
|
662
677
|
fdb_fpath = os.path.join(fdb_dir, stem + ".fdb_latexmk")
|
|
@@ -766,8 +781,9 @@ _IFDEFINED_MARKUP_RE = re.compile(
|
|
|
766
781
|
)
|
|
767
782
|
# A one-argument macro's definition, up to its body's opening brace
|
|
768
783
|
_ONE_ARG_MACRO_DEF_RE = re.compile(
|
|
769
|
-
r"\\(?:(?:re
|
|
770
|
-
r"
|
|
784
|
+
r"\\(?:(?:(?:re)?newcommand|providecommand)\*?\s*"
|
|
785
|
+
r"(?:\{\s*\\([A-Za-z@]+)\s*\}|\\([A-Za-z@]+))\s*\[1\]"
|
|
786
|
+
r"|def\s*\\([A-Za-z@]+)#1)\s*(?=\{)"
|
|
771
787
|
)
|
|
772
788
|
get_diff_path = calkit.latex.get_diff_path
|
|
773
789
|
_default_base_ref = calkit.latex.default_base_ref
|
|
@@ -969,6 +985,7 @@ def diff(
|
|
|
969
985
|
generates, are fetched as they were at that revision, so each side of
|
|
970
986
|
the comparison shows its own.
|
|
971
987
|
"""
|
|
988
|
+
import calkit.resources
|
|
972
989
|
|
|
973
990
|
def stage_path(stage: dict, path: str) -> str:
|
|
974
991
|
"""A stage's path in the project's frame rather than its wdir's."""
|
|
@@ -1335,7 +1352,7 @@ def diff(
|
|
|
1335
1352
|
which can't say what's inside, so those directories beside the
|
|
1336
1353
|
document are included whole. Neither finds another stage's output
|
|
1337
1354
|
DVC doesn't cache, so the pipeline's inputs for the document are
|
|
1338
|
-
added too.
|
|
1355
|
+
added too, as are the components the document includes.
|
|
1339
1356
|
"""
|
|
1340
1357
|
doc_dir = Path(root, os.path.dirname(tex_file))
|
|
1341
1358
|
pointed = [
|
|
@@ -1344,7 +1361,17 @@ def diff(
|
|
|
1344
1361
|
if p.is_file()
|
|
1345
1362
|
]
|
|
1346
1363
|
detected = calkit.latex.detect_inputs(tex_file, wdir=root)
|
|
1347
|
-
|
|
1364
|
+
# What the revision's own calkit.sty commands include, which the
|
|
1365
|
+
# stage may not depend on now, at each path TeX would try
|
|
1366
|
+
_, components = calkit.latex.read_ck_usage(
|
|
1367
|
+
os.path.join(root, tex_file)
|
|
1368
|
+
)
|
|
1369
|
+
included = [
|
|
1370
|
+
path
|
|
1371
|
+
for c in components
|
|
1372
|
+
for path in calkit.latex.component_candidates(c)[0]
|
|
1373
|
+
]
|
|
1374
|
+
return detected + pointed + included + pipeline_inputs
|
|
1348
1375
|
|
|
1349
1376
|
def point_changed_figures_at_base(base_root: str, head_root: str) -> None:
|
|
1350
1377
|
"""Make the older side's changed figures refer to its own copies.
|
|
@@ -1531,6 +1558,22 @@ def diff(
|
|
|
1531
1558
|
for root in prepared:
|
|
1532
1559
|
break_verbatim_params(root)
|
|
1533
1560
|
expand_wrapper_macros(root)
|
|
1561
|
+
# Each side's own values, figures, and tables, read from its
|
|
1562
|
+
# revision, or the working tree for its copy of the sources
|
|
1563
|
+
calkit.latex.expand_ck_components(
|
|
1564
|
+
root, "." if root == working_copy else root, tex_file
|
|
1565
|
+
)
|
|
1566
|
+
# \cknum in the marked-up document needs calkit.sty beside it
|
|
1567
|
+
build_style = os.path.join(
|
|
1568
|
+
head_root, os.path.dirname(tex_file), calkit.latex.STYLE_FNAME
|
|
1569
|
+
)
|
|
1570
|
+
if not os.path.isfile(build_style):
|
|
1571
|
+
with open(build_style, "w", encoding="utf-8") as f:
|
|
1572
|
+
f.write(
|
|
1573
|
+
calkit.resources.read_text(
|
|
1574
|
+
"latex", calkit.latex.STYLE_FNAME
|
|
1575
|
+
)
|
|
1576
|
+
)
|
|
1534
1577
|
point_changed_figures_at_base(checkouts["base"], head_root)
|
|
1535
1578
|
_build_diff(
|
|
1536
1579
|
base_tex_fpath=sides["base"],
|
|
@@ -1711,6 +1754,9 @@ def _build_diff(
|
|
|
1711
1754
|
# chose otherwise
|
|
1712
1755
|
if not any(a.startswith("--graphics-markup") for a in latexdiff_args):
|
|
1713
1756
|
latexdiff_cmd.append("--graphics-markup=both")
|
|
1757
|
+
# A value from calkit.sty is marked like text, so a changed one shows
|
|
1758
|
+
# struck out beside its new value, rather than only as it is now
|
|
1759
|
+
latexdiff_cmd.append("--append-safecmd=cknum")
|
|
1714
1760
|
# User pass-through args come last so they can override Calkit's
|
|
1715
1761
|
# defaults
|
|
1716
1762
|
latexdiff_cmd += latexdiff_args
|
|
@@ -2013,7 +2059,10 @@ def to_docx(
|
|
|
2013
2059
|
try:
|
|
2014
2060
|
calkit.docx.pdf_to_docx(pdf_path, output)
|
|
2015
2061
|
except RuntimeError as e:
|
|
2016
|
-
raise_error(
|
|
2062
|
+
raise_error(
|
|
2063
|
+
f"{e}\nTo convert from the source instead, use "
|
|
2064
|
+
"--engine libreoffice"
|
|
2065
|
+
)
|
|
2017
2066
|
elif engine == "libreoffice":
|
|
2018
2067
|
import shlex
|
|
2019
2068
|
|
|
@@ -2032,6 +2081,8 @@ def to_docx(
|
|
|
2032
2081
|
job = Path(source).stem + "-calkit-docx"
|
|
2033
2082
|
build = os.path.join(calkit.latex.DOCX_BUILD_DIR, Path(source).stem)
|
|
2034
2083
|
shutil.rmtree(build, ignore_errors=True)
|
|
2084
|
+
# Under .calkit/local, which keeps itself out of Git
|
|
2085
|
+
calkit.ensure_local_dir()
|
|
2035
2086
|
os.makedirs(build)
|
|
2036
2087
|
# The PDF build's bibliography, since TeX4ht doesn't run BibTeX
|
|
2037
2088
|
bbl = Path(source).with_suffix(".bbl")
|
|
@@ -2169,6 +2220,32 @@ def to_docx(
|
|
|
2169
2220
|
blks = calkit.latex.blocks(lines)
|
|
2170
2221
|
paras = doc.paragraphs
|
|
2171
2222
|
matched = calkit.latex.align([p.text for p in paras], blks)
|
|
2223
|
+
# Word's PDF import drops a hyphen ending a line even when the word has
|
|
2224
|
+
# it, e.g., "two-dimensional" as "twodimensional", so it goes back where
|
|
2225
|
+
# the paragraph's source spells the word with it and never without
|
|
2226
|
+
if engine == "word":
|
|
2227
|
+
for para, blk in zip(paras, matched):
|
|
2228
|
+
if blk is None or para.element is None:
|
|
2229
|
+
continue
|
|
2230
|
+
words = re.findall(
|
|
2231
|
+
r"(?<![\w-])[^\W\d_]\w*(?:-\w+)+(?![\w-])", blk.text
|
|
2232
|
+
)
|
|
2233
|
+
for word in dict.fromkeys(words):
|
|
2234
|
+
pattern = re.compile(
|
|
2235
|
+
r"(?<![\w-])"
|
|
2236
|
+
+ "(-?)".join(re.escape(w) for w in word.split("-"))
|
|
2237
|
+
+ r"(?![\w-])",
|
|
2238
|
+
re.IGNORECASE,
|
|
2239
|
+
)
|
|
2240
|
+
if any("" in m.groups() for m in pattern.finditer(blk.text)):
|
|
2241
|
+
continue
|
|
2242
|
+
text = doc.linear_text(para.element)
|
|
2243
|
+
# From the end, so earlier offsets still hold
|
|
2244
|
+
for m in reversed(list(pattern.finditer(text))):
|
|
2245
|
+
for k in reversed(range(1, len(m.groups()) + 1)):
|
|
2246
|
+
if not m.group(k):
|
|
2247
|
+
doc.insert_text(para.element, m.start(k), "-")
|
|
2248
|
+
paras = doc.paragraphs
|
|
2172
2249
|
# Bookmark each anchored paragraph; a block rendering as several
|
|
2173
2250
|
# paragraphs gets numbered suffixes
|
|
2174
2251
|
original: dict[str, str] = {}
|
|
@@ -2251,6 +2328,26 @@ def to_docx(
|
|
|
2251
2328
|
# Word lays some displays out in tables of its own, which go too
|
|
2252
2329
|
# if all else they hold is the prose on either side, moved out.
|
|
2253
2330
|
emu_limit = int(1.5 * 914400)
|
|
2331
|
+
# A figure without a caption, in a float or not, could be any tall
|
|
2332
|
+
# picture, so then none go
|
|
2333
|
+
captionless, depth, captioned = False, 0, False
|
|
2334
|
+
for ln in lines:
|
|
2335
|
+
code = calkit.latex._strip_comments(ln.text)
|
|
2336
|
+
for m in re.finditer(
|
|
2337
|
+
r"\\(begin|end)\{(figure|wrapfigure|sidewaysfigure)\*?\}"
|
|
2338
|
+
r"|\\(caption|includegraphics)\b",
|
|
2339
|
+
code,
|
|
2340
|
+
):
|
|
2341
|
+
if m.group(1) == "begin":
|
|
2342
|
+
depth, captioned = depth + 1, captioned and depth > 0
|
|
2343
|
+
elif m.group(1) == "end":
|
|
2344
|
+
depth = max(0, depth - 1)
|
|
2345
|
+
if depth == 0 and not captioned:
|
|
2346
|
+
captionless = True
|
|
2347
|
+
elif m.group(3) == "caption":
|
|
2348
|
+
captioned = True
|
|
2349
|
+
elif depth == 0:
|
|
2350
|
+
captionless = True
|
|
2254
2351
|
w_body = doc.doc.find(calkit.docx._tag(calkit.docx.W, "body"))
|
|
2255
2352
|
assert w_body is not None
|
|
2256
2353
|
w_p = calkit.docx._tag(calkit.docx.W, "p")
|
|
@@ -2263,6 +2360,11 @@ def to_docx(
|
|
|
2263
2360
|
)
|
|
2264
2361
|
|
|
2265
2362
|
anchored = {p.element for p, b in zip(paras, matched) if b is not None}
|
|
2363
|
+
captions = {
|
|
2364
|
+
p.element
|
|
2365
|
+
for p, b in zip(paras, matched)
|
|
2366
|
+
if b is not None and b.floating
|
|
2367
|
+
}
|
|
2266
2368
|
|
|
2267
2369
|
def fragment(el: Any, context: str) -> bool:
|
|
2268
2370
|
# Never prose: anything anchored to the source, e.g., a caption
|
|
@@ -2276,7 +2378,7 @@ def to_docx(
|
|
|
2276
2378
|
prose_like = (
|
|
2277
2379
|
len(words) >= 2
|
|
2278
2380
|
and calkit.latex.similarity(text, context) >= 0.5
|
|
2279
|
-
and not
|
|
2381
|
+
and not calkit.latex.equation_fragment(text)
|
|
2280
2382
|
)
|
|
2281
2383
|
if el in anchored or len(words) > 3 or prose_like:
|
|
2282
2384
|
return False
|
|
@@ -2287,7 +2389,29 @@ def to_docx(
|
|
|
2287
2389
|
"wordprocessingDrawing}extent"
|
|
2288
2390
|
)
|
|
2289
2391
|
)
|
|
2290
|
-
|
|
2392
|
+
if not tall:
|
|
2393
|
+
return True
|
|
2394
|
+
if captionless:
|
|
2395
|
+
return False
|
|
2396
|
+
# A tall picture is a figure where there's a caption beside it,
|
|
2397
|
+
# past any other pictures, else a display drawn as one
|
|
2398
|
+
children = list(w_body)
|
|
2399
|
+
i = children.index(unit_of(el))
|
|
2400
|
+
for step in (-1, 1):
|
|
2401
|
+
k = i + step
|
|
2402
|
+
while (
|
|
2403
|
+
0 <= k < len(children) and not text_of(children[k]).strip()
|
|
2404
|
+
):
|
|
2405
|
+
k += step
|
|
2406
|
+
if not 0 <= k < len(children):
|
|
2407
|
+
continue
|
|
2408
|
+
near = children[k]
|
|
2409
|
+
if any(q in captions for q in near.iter(w_p)) or re.match(
|
|
2410
|
+
r"\s*(Figure|Fig\.|Table|Scheme|Chart)\s+[A-Z]?\.?\d",
|
|
2411
|
+
text_of(near),
|
|
2412
|
+
):
|
|
2413
|
+
return False
|
|
2414
|
+
return True
|
|
2291
2415
|
|
|
2292
2416
|
def unit_of(el: Any) -> Any:
|
|
2293
2417
|
while doc._parents.get(el) is not w_body:
|
|
@@ -2690,6 +2814,7 @@ def to_docx(
|
|
|
2690
2814
|
.isoformat(timespec="seconds")
|
|
2691
2815
|
.replace(":", "-")
|
|
2692
2816
|
)
|
|
2817
|
+
calkit.ensure_local_dir()
|
|
2693
2818
|
dirs = [calkit.latex.LOCAL_DOCX_EXPORTS_DIR]
|
|
2694
2819
|
if log:
|
|
2695
2820
|
dirs.append(calkit.latex.DOCX_EXPORTS_DIR)
|
|
@@ -3084,6 +3209,7 @@ def merge_docx(
|
|
|
3084
3209
|
.isoformat(timespec="seconds")
|
|
3085
3210
|
.replace(":", "-")
|
|
3086
3211
|
)
|
|
3212
|
+
calkit.ensure_local_dir()
|
|
3087
3213
|
dirs = [calkit.latex.LOCAL_DOCX_MERGES_DIR]
|
|
3088
3214
|
if log:
|
|
3089
3215
|
dirs.append(calkit.latex.DOCX_MERGES_DIR)
|