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.
Files changed (44) hide show
  1. calkit/agent_skills/resolve-merge-conflicts/SKILL.md +72 -0
  2. calkit/cli/check.py +3 -1
  3. calkit/cli/fix.py +196 -0
  4. calkit/cli/latex.py +133 -7
  5. calkit/cli/main/core.py +168 -1
  6. calkit/cli/overleaf.py +114 -43
  7. calkit/cli/update.py +4 -1
  8. calkit/config.py +62 -1
  9. calkit/docx.py +288 -58
  10. calkit/latex.py +830 -29
  11. calkit/merge.py +636 -0
  12. calkit/models/pipeline.py +166 -6
  13. calkit/overleaf.py +183 -14
  14. calkit/pipeline.py +27 -3
  15. calkit/resources/README.md +1 -0
  16. calkit/resources/latex/calkit.sty +208 -0
  17. calkit/tests/cli/main/test_core.py +78 -0
  18. calkit/tests/cli/test_fix.py +174 -0
  19. calkit/tests/cli/test_latex.py +212 -0
  20. calkit/tests/cli/test_overleaf.py +85 -2
  21. calkit/tests/test_config.py +66 -0
  22. calkit/tests/test_docx.py +170 -0
  23. calkit/tests/test_latex.py +493 -0
  24. calkit/tests/test_merge.py +182 -0
  25. calkit/tests/test_overleaf.py +143 -4
  26. calkit/tests/test_pipeline.py +248 -0
  27. {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/METADATA +2 -1
  28. {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/RECORD +44 -38
  29. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  30. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  31. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  32. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  33. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  34. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  35. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  36. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  37. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  38. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  39. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  40. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  41. {calkit_python-0.49.4.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  42. {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/WHEEL +0 -0
  43. {calkit_python-0.49.4.dist-info → calkit_python-0.50.0.dist-info}/entry_points.txt +0 -0
  44. {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, clean_notebooks=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|provide)?newcommand\*?\s*(?:\{\s*\\([A-Za-z@]+)\s*\}"
770
- r"|\\([A-Za-z@]+))\s*\[1\]|def\s*\\([A-Za-z@]+)#1)\s*(?=\{)"
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
- return detected + pointed + pipeline_inputs
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(str(e))
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 re.search(eq_num + r"\s*$", text)
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
- return not tall
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)