calkit-python 0.50.0__py3-none-any.whl → 0.50.1__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/cli/latex.py +400 -58
- calkit/docx.py +203 -28
- calkit/latex.py +435 -57
- calkit/tests/test_docx.py +425 -1
- calkit/tests/test_latex.py +54 -2
- {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/METADATA +1 -1
- {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/RECORD +23 -23
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/WHEEL +0 -0
- {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/licenses/LICENSE +0 -0
calkit/cli/latex.py
CHANGED
|
@@ -2220,6 +2220,157 @@ def to_docx(
|
|
|
2220
2220
|
blks = calkit.latex.blocks(lines)
|
|
2221
2221
|
paras = doc.paragraphs
|
|
2222
2222
|
matched = calkit.latex.align([p.text for p in paras], blks)
|
|
2223
|
+
# Pieces of a paragraph Word's import split, e.g., at a page break,
|
|
2224
|
+
# join again, with anything floated between them after
|
|
2225
|
+
if engine == "word":
|
|
2226
|
+
from collections import Counter
|
|
2227
|
+
from xml.etree import ElementTree as ET
|
|
2228
|
+
|
|
2229
|
+
body = doc.doc.find(calkit.docx._tag(calkit.docx.W, "body"))
|
|
2230
|
+
assert body is not None
|
|
2231
|
+
w_ppr = calkit.docx._tag(calkit.docx.W, "pPr")
|
|
2232
|
+
w_sect = f"{w_ppr}/{calkit.docx._tag(calkit.docx.W, 'sectPr')}"
|
|
2233
|
+
block_of = {p.element: b for p, b in zip(paras, matched)}
|
|
2234
|
+
floats = {
|
|
2235
|
+
p.element for p, b in zip(paras, matched) if b and b.floating
|
|
2236
|
+
}
|
|
2237
|
+
w_jc_path = f"{w_ppr}/{calkit.docx._tag(calkit.docx.W, 'jc')}"
|
|
2238
|
+
w_val = calkit.docx._tag(calkit.docx.W, "val")
|
|
2239
|
+
after_jc = {
|
|
2240
|
+
calkit.docx._tag(calkit.docx.W, t)
|
|
2241
|
+
for t in (
|
|
2242
|
+
"textDirection",
|
|
2243
|
+
"textAlignment",
|
|
2244
|
+
"textboxTightWrap",
|
|
2245
|
+
"outlineLvl",
|
|
2246
|
+
"divId",
|
|
2247
|
+
"cnfStyle",
|
|
2248
|
+
"rPr",
|
|
2249
|
+
"sectPr",
|
|
2250
|
+
"pPrChange",
|
|
2251
|
+
)
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
def get_jc(el: Any) -> str | None:
|
|
2255
|
+
jc = el.find(w_jc_path)
|
|
2256
|
+
return None if jc is None else jc.get(w_val)
|
|
2257
|
+
|
|
2258
|
+
# How the body text is aligned: as most long paragraphs are
|
|
2259
|
+
counts = Counter(
|
|
2260
|
+
get_jc(p.element)
|
|
2261
|
+
for p in paras
|
|
2262
|
+
if p.element is not None and len(p.text) > 200
|
|
2263
|
+
)
|
|
2264
|
+
usual_jc = counts.most_common(1)[0][0] if counts else None
|
|
2265
|
+
|
|
2266
|
+
def is_usual(jc: str | None) -> bool:
|
|
2267
|
+
return bool(counts) and jc == usual_jc
|
|
2268
|
+
|
|
2269
|
+
for i in range(len(paras) - 1, 0, -1):
|
|
2270
|
+
part1, part2, blk = paras[i - 1], paras[i], matched[i]
|
|
2271
|
+
# Not table rows, broken lines, the title and authors, or a
|
|
2272
|
+
# heading like "Abstract"
|
|
2273
|
+
if (
|
|
2274
|
+
blk is None
|
|
2275
|
+
or matched[i - 1] is not blk
|
|
2276
|
+
or blk.preamble
|
|
2277
|
+
or (
|
|
2278
|
+
re.match(r"\s*\\begin\{", blk.lines[0].text) is not None
|
|
2279
|
+
and len(part1.text.split()) <= 2
|
|
2280
|
+
)
|
|
2281
|
+
or (
|
|
2282
|
+
blk.floating
|
|
2283
|
+
and not re.match(r"\s*\\caption\b", blk.lines[0].text)
|
|
2284
|
+
)
|
|
2285
|
+
or any(
|
|
2286
|
+
"\\\\" in calkit.latex._strip_comments(ln.text)
|
|
2287
|
+
for ln in blk.lines
|
|
2288
|
+
)
|
|
2289
|
+
or part1.element is None
|
|
2290
|
+
or part2.element is None
|
|
2291
|
+
or doc._parents.get(part1.element) is not body
|
|
2292
|
+
or doc._parents.get(part2.element) is not body
|
|
2293
|
+
or part1.element.find(w_sect) is not None
|
|
2294
|
+
or part2.element.find(w_sect) is not None
|
|
2295
|
+
):
|
|
2296
|
+
continue
|
|
2297
|
+
children = list(body)
|
|
2298
|
+
between = children[
|
|
2299
|
+
children.index(part1.element) + 1 : children.index(
|
|
2300
|
+
part2.element
|
|
2301
|
+
)
|
|
2302
|
+
]
|
|
2303
|
+
if not all(
|
|
2304
|
+
all(
|
|
2305
|
+
q in floats or not doc.linear_text(q).strip()
|
|
2306
|
+
for q in u.iter(calkit.docx._tag(calkit.docx.W, "p"))
|
|
2307
|
+
)
|
|
2308
|
+
for u in between
|
|
2309
|
+
):
|
|
2310
|
+
continue
|
|
2311
|
+
# No space inside a word broken across the pieces, which a
|
|
2312
|
+
# hyphen goes back into below if the word has one
|
|
2313
|
+
word_end = re.search(r"(\w+)$", part1.text)
|
|
2314
|
+
word_start = re.match(r"(\w+)", part2.text)
|
|
2315
|
+
sep = " "
|
|
2316
|
+
if part1.text.endswith("-") or (
|
|
2317
|
+
word_end is not None
|
|
2318
|
+
and word_start is not None
|
|
2319
|
+
and re.search(
|
|
2320
|
+
rf"(?<!\w){word_end[1]}-?{word_start[1]}(?!\w)",
|
|
2321
|
+
blk.text,
|
|
2322
|
+
re.I,
|
|
2323
|
+
)
|
|
2324
|
+
and not re.search(
|
|
2325
|
+
rf"(?<!\w){word_end[1]}\s+{word_start[1]}(?!\w)",
|
|
2326
|
+
blk.text,
|
|
2327
|
+
re.I,
|
|
2328
|
+
)
|
|
2329
|
+
):
|
|
2330
|
+
sep = ""
|
|
2331
|
+
if doc.linear_text(part1.element)[-1:].isspace():
|
|
2332
|
+
sep = ""
|
|
2333
|
+
if sep:
|
|
2334
|
+
sep_run = ET.SubElement(
|
|
2335
|
+
part1.element, calkit.docx._tag(calkit.docx.W, "r")
|
|
2336
|
+
)
|
|
2337
|
+
sep_t = ET.SubElement(
|
|
2338
|
+
sep_run, calkit.docx._tag(calkit.docx.W, "t")
|
|
2339
|
+
)
|
|
2340
|
+
sep_t.text = sep
|
|
2341
|
+
sep_t.set(calkit.docx.XML_SPACE, "preserve")
|
|
2342
|
+
# Aligned as body text usually is, else as the longer piece,
|
|
2343
|
+
# since Word's import can align a one-line piece right
|
|
2344
|
+
jc1 = part1.element.find(w_jc_path)
|
|
2345
|
+
jc2 = part2.element.find(w_jc_path)
|
|
2346
|
+
v1, v2 = get_jc(part1.element), get_jc(part2.element)
|
|
2347
|
+
longer = len(doc.linear_text(part2.element)) > len(
|
|
2348
|
+
doc.linear_text(part1.element)
|
|
2349
|
+
)
|
|
2350
|
+
if v1 != v2 and (is_usual(v2) or (longer and not is_usual(v1))):
|
|
2351
|
+
ppr1 = part1.element.find(w_ppr)
|
|
2352
|
+
if ppr1 is None:
|
|
2353
|
+
ppr1 = ET.Element(w_ppr)
|
|
2354
|
+
part1.element.insert(0, ppr1)
|
|
2355
|
+
if jc1 is not None:
|
|
2356
|
+
at = list(ppr1).index(jc1)
|
|
2357
|
+
ppr1.remove(jc1)
|
|
2358
|
+
else:
|
|
2359
|
+
# In schema order, before what follows alignment
|
|
2360
|
+
at = next(
|
|
2361
|
+
(k for k, c in enumerate(ppr1) if c.tag in after_jc),
|
|
2362
|
+
len(ppr1),
|
|
2363
|
+
)
|
|
2364
|
+
if jc2 is not None:
|
|
2365
|
+
ppr1.insert(at, deepcopy(jc2))
|
|
2366
|
+
for child in list(part2.element):
|
|
2367
|
+
if child.tag != w_ppr:
|
|
2368
|
+
part2.element.remove(child)
|
|
2369
|
+
part1.element.append(child)
|
|
2370
|
+
body.remove(part2.element)
|
|
2371
|
+
doc._parents = {c: p for p in doc.doc.iter() for c in p}
|
|
2372
|
+
paras = doc.paragraphs
|
|
2373
|
+
matched = [block_of.get(p.element) for p in paras]
|
|
2223
2374
|
# Word's PDF import drops a hyphen ending a line even when the word has
|
|
2224
2375
|
# it, e.g., "two-dimensional" as "twodimensional", so it goes back where
|
|
2225
2376
|
# the paragraph's source spells the word with it and never without
|
|
@@ -2228,7 +2379,7 @@ def to_docx(
|
|
|
2228
2379
|
if blk is None or para.element is None:
|
|
2229
2380
|
continue
|
|
2230
2381
|
words = re.findall(
|
|
2231
|
-
r"(?<![\w-])[^\W\d_]\w*(?:-\w+)+(?![\w-])", blk.text
|
|
2382
|
+
r"(?<![\w-])\w*[^\W\d_]\w*(?:-\w+)+(?![\w-])", blk.text
|
|
2232
2383
|
)
|
|
2233
2384
|
for word in dict.fromkeys(words):
|
|
2234
2385
|
pattern = re.compile(
|
|
@@ -2285,6 +2436,24 @@ def to_docx(
|
|
|
2285
2436
|
elif has_display or has_inline:
|
|
2286
2437
|
from xml.etree import ElementTree as ET
|
|
2287
2438
|
|
|
2439
|
+
version = calkit.docx.get_pandoc_version()
|
|
2440
|
+
aligned = any(
|
|
2441
|
+
b.display
|
|
2442
|
+
and (
|
|
2443
|
+
b.display.rstrip("*") in calkit.latex._MULTIROW_ENVS
|
|
2444
|
+
or re.search(
|
|
2445
|
+
r"\\begin\{(aligned|gathered|split)",
|
|
2446
|
+
"\n".join(ln.text for ln in b.lines),
|
|
2447
|
+
)
|
|
2448
|
+
)
|
|
2449
|
+
for b in blks
|
|
2450
|
+
)
|
|
2451
|
+
if aligned and version is not None and version < (3, 8):
|
|
2452
|
+
warn(
|
|
2453
|
+
f"Pandoc {'.'.join(map(str, version))} lays out some "
|
|
2454
|
+
"equations, e.g., aligned ones, unlike newer versions; "
|
|
2455
|
+
"update it to 3.8 or newer for them to match"
|
|
2456
|
+
)
|
|
2288
2457
|
eq_num = r"\(([A-Z]?\.?\d+(?:\.\d+)*[a-z]?)\)"
|
|
2289
2458
|
first: dict[int, int] = {}
|
|
2290
2459
|
last: dict[int, int] = {}
|
|
@@ -2567,54 +2736,135 @@ def to_docx(
|
|
|
2567
2736
|
# of prose on either side of it in the source, which may be text,
|
|
2568
2737
|
# pictures or both, goes in as an equation
|
|
2569
2738
|
|
|
2739
|
+
marks = (
|
|
2740
|
+
calkit.latex.REF_MARK
|
|
2741
|
+
+ calkit.latex.CITE_MARK
|
|
2742
|
+
+ calkit.latex.TEXT_CITE_MARK
|
|
2743
|
+
)
|
|
2744
|
+
# What a reference renders as, e.g., "2", "A.4", or "Appendix A",
|
|
2745
|
+
# and a parenthetical citation, e.g., "(Lee, 2020)" or "[3]"
|
|
2746
|
+
ref_re = (
|
|
2747
|
+
r"(?:(?:Appendix|Section|Sec\.|Chapter|Figure|Fig\.|Table|"
|
|
2748
|
+
r"Equation|Eq\.)\s*)?[\w.]*\w"
|
|
2749
|
+
)
|
|
2750
|
+
cite = r"\([^()]*\)|\[[^\]]*\]"
|
|
2751
|
+
# What each mark renders as; a textual citation with its authors
|
|
2752
|
+
# outside, e.g., "Lee and Kim (2020)"
|
|
2753
|
+
mark_res = {
|
|
2754
|
+
calkit.latex.REF_MARK: ref_re,
|
|
2755
|
+
calkit.latex.CITE_MARK: cite,
|
|
2756
|
+
calkit.latex.TEXT_CITE_MARK: (
|
|
2757
|
+
r"[A-Z][^\s()\[\]]*"
|
|
2758
|
+
r"(?:\s+(?:et\s+al\.?|and|&|[A-Z][^\s()\[\]]*))*\s*"
|
|
2759
|
+
rf"(?:{cite})"
|
|
2760
|
+
),
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2570
2763
|
def tokens(prose: str) -> list[str]:
|
|
2571
2764
|
# Numbers count, e.g., a table cell "0.05" after the math
|
|
2572
2765
|
return re.findall(r"[A-Za-z0-9]+", prose)
|
|
2573
2766
|
|
|
2574
|
-
def
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
)
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2767
|
+
def make_words_re(
|
|
2768
|
+
ws: list[str], head: bool = True, tail: bool = True
|
|
2769
|
+
) -> re.Pattern[str]:
|
|
2770
|
+
# Bounded by what the ends aren't, since Word's import can drop
|
|
2771
|
+
# a space between a number and a unit, e.g., "20K", where the
|
|
2772
|
+
# source doesn't run the math into them
|
|
2773
|
+
head_cls = "0-9" if ws[0][0].isdigit() else "A-Za-z"
|
|
2774
|
+
tail_cls = "0-9" if ws[-1][-1].isdigit() else "A-Za-z"
|
|
2775
|
+
return re.compile(
|
|
2776
|
+
(rf"(?<![{head_cls}])" if head else "")
|
|
2777
|
+
+ r"[^A-Za-z0-9]*".join(map(re.escape, ws))
|
|
2778
|
+
+ (rf"(?![{tail_cls}])" if tail else "")
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
def find_starts(text: str, cursor: int, before: str) -> list[int]:
|
|
2782
|
+
# Where the math could start: after each place the words before it
|
|
2783
|
+
# are, those the punctuation after them matches first
|
|
2784
|
+
near = tokens(re.split(f"[{marks}]", before)[-1])
|
|
2785
|
+
near = near or tokens(before)
|
|
2786
|
+
out: list[tuple[bool, int]] = []
|
|
2787
|
+
for n in (3, 2) if near else (0,):
|
|
2788
|
+
bw = near[-n:] if n else []
|
|
2789
|
+
tail = before
|
|
2790
|
+
spots = [cursor]
|
|
2791
|
+
if bw:
|
|
2792
|
+
tail = before[before.rfind(bw[-1]) + len(bw[-1]) :]
|
|
2793
|
+
spots = [
|
|
2794
|
+
m.end()
|
|
2795
|
+
for m in make_words_re(bw, tail=bool(tail)).finditer(
|
|
2796
|
+
text, cursor
|
|
2797
|
+
)
|
|
2798
|
+
]
|
|
2799
|
+
for start in spots:
|
|
2800
|
+
# Past the prose's own punctuation, e.g., "(" before the
|
|
2801
|
+
# math
|
|
2802
|
+
whole = True
|
|
2803
|
+
for ch in tail.replace(" ", ""):
|
|
2804
|
+
while start < len(text) and text[start].isspace():
|
|
2805
|
+
start += 1
|
|
2806
|
+
m = None
|
|
2807
|
+
if ch in marks:
|
|
2808
|
+
m = re.compile(mark_res[ch]).match(text, start)
|
|
2809
|
+
if m is not None:
|
|
2810
|
+
start = m.end()
|
|
2811
|
+
elif text[start : start + 1] == ch:
|
|
2812
|
+
start += 1
|
|
2813
|
+
else:
|
|
2814
|
+
whole = False
|
|
2815
|
+
break
|
|
2816
|
+
while start < len(text) and text[start].isspace():
|
|
2817
|
+
start += 1
|
|
2818
|
+
out.append((not whole, start))
|
|
2819
|
+
return [
|
|
2820
|
+
s for _, s in sorted(dict.fromkeys(out), key=lambda c: c[0])
|
|
2821
|
+
]
|
|
2598
2822
|
|
|
2599
|
-
def find_end(
|
|
2600
|
-
|
|
2823
|
+
def find_end(
|
|
2824
|
+
text: str, start: int, after: str, tex: str, is_last: bool
|
|
2825
|
+
) -> int | None:
|
|
2826
|
+
aw = tokens(re.split(f"[{marks}]", after)[0])[:2]
|
|
2827
|
+
aw = aw or tokens(after)[:2]
|
|
2601
2828
|
head = (after[: after.find(aw[0])] if aw else after).replace(
|
|
2602
2829
|
" ", ""
|
|
2603
2830
|
)
|
|
2604
2831
|
end = len(text)
|
|
2605
2832
|
if aw:
|
|
2606
|
-
m =
|
|
2607
|
-
|
|
2608
|
-
+ r"[^A-Za-z0-9]+".join(map(re.escape, aw))
|
|
2609
|
-
+ r"(?![A-Za-z0-9])"
|
|
2833
|
+
m = make_words_re(
|
|
2834
|
+
aw, head=bool(head) or after[:1].isspace()
|
|
2610
2835
|
).search(text, start)
|
|
2611
2836
|
if m is None:
|
|
2612
2837
|
return None
|
|
2613
2838
|
end = m.start()
|
|
2839
|
+
elif head and not is_last:
|
|
2840
|
+
# Only punctuation before more math, e.g., the dash in
|
|
2841
|
+
# $1$--$2$, so where the math reads as its source
|
|
2842
|
+
want = calkit.latex.keep_letters_and_digits(
|
|
2843
|
+
calkit.latex.get_math_text(tex)
|
|
2844
|
+
)
|
|
2845
|
+
for m in re.finditer(re.escape(head[0]), text[start:]):
|
|
2846
|
+
if (
|
|
2847
|
+
calkit.latex.keep_letters_and_digits(
|
|
2848
|
+
text[start : start + m.start()]
|
|
2849
|
+
)
|
|
2850
|
+
== want
|
|
2851
|
+
):
|
|
2852
|
+
return start + len(
|
|
2853
|
+
text[start : start + m.start()].rstrip()
|
|
2854
|
+
)
|
|
2855
|
+
return None
|
|
2614
2856
|
# Back before the prose's punctuation, e.g., ")" after the math
|
|
2615
2857
|
for ch in reversed(head):
|
|
2616
2858
|
while end > start and text[end - 1].isspace():
|
|
2617
2859
|
end -= 1
|
|
2860
|
+
if ch in marks:
|
|
2861
|
+
m = re.compile(f"(?:{mark_res[ch]})$").search(
|
|
2862
|
+
text, start, end
|
|
2863
|
+
)
|
|
2864
|
+
if m is None:
|
|
2865
|
+
break
|
|
2866
|
+
end = m.start()
|
|
2867
|
+
continue
|
|
2618
2868
|
if text[end - 1 : end] != ch:
|
|
2619
2869
|
break
|
|
2620
2870
|
end -= 1
|
|
@@ -2622,17 +2872,85 @@ def to_docx(
|
|
|
2622
2872
|
end -= 1
|
|
2623
2873
|
return end
|
|
2624
2874
|
|
|
2875
|
+
def needs_space(spaced: bool, text: str, at: int) -> bool:
|
|
2876
|
+
# The source has a space there and the paragraph has prose
|
|
2877
|
+
return spaced and 0 <= at < len(text) and not text[at].isspace()
|
|
2878
|
+
|
|
2625
2879
|
def mathlike(region: str, tex: str) -> bool:
|
|
2626
|
-
# Math as Word renders it has no prose words in it
|
|
2880
|
+
# Math as Word renders it has no prose words in it, nor digits
|
|
2881
|
+
# its source doesn't, e.g., "x ≠ 1, 2" isn't $x=1$
|
|
2627
2882
|
words = re.findall(r"[A-Za-z]{4,}", region)
|
|
2628
|
-
|
|
2629
|
-
|
|
2883
|
+
rendered = calkit.latex.keep_letters_and_digits(
|
|
2884
|
+
calkit.latex.get_math_text(tex)
|
|
2885
|
+
)
|
|
2886
|
+
return (
|
|
2887
|
+
bool(region.strip())
|
|
2888
|
+
and all(
|
|
2889
|
+
calkit.latex.keep_letters_and_digits(w) in rendered
|
|
2890
|
+
for w in words
|
|
2891
|
+
)
|
|
2892
|
+
and set(re.findall(r"\d", region))
|
|
2893
|
+
<= set(re.findall(r"\d", tex))
|
|
2630
2894
|
)
|
|
2631
2895
|
|
|
2632
2896
|
# Each as (paragraph, start, end, LaTeX), and for math Word broke
|
|
2633
2897
|
# across paragraphs, the rest in the next one as (paragraph, 0,
|
|
2634
2898
|
# end, None)
|
|
2635
2899
|
inline: list[tuple[Any, int, int, str | None]] = []
|
|
2900
|
+
# Whether each needs a space put back before or after it, which
|
|
2901
|
+
# Word's import can drop next to a picture
|
|
2902
|
+
pads: list[tuple[bool, bool]] = []
|
|
2903
|
+
# The paragraph and offset where the last one placed ended
|
|
2904
|
+
pi, cursor = 0, 0
|
|
2905
|
+
|
|
2906
|
+
def place(
|
|
2907
|
+
k: int, pre: str, tex: str, shown: str, post: str, is_last: bool
|
|
2908
|
+
) -> bool:
|
|
2909
|
+
# The math in paragraph k, or from there into the next; shown is
|
|
2910
|
+
# it with the document's macros expanded
|
|
2911
|
+
nonlocal pi, cursor
|
|
2912
|
+
for s0 in find_starts(texts[k], cursor if k == pi else 0, pre):
|
|
2913
|
+
e0 = find_end(texts[k], s0, post, shown, is_last)
|
|
2914
|
+
if e0 is not None and mathlike(texts[k][s0:e0], shown):
|
|
2915
|
+
inline.append((els[k], s0, e0, tex))
|
|
2916
|
+
pads.append(
|
|
2917
|
+
(
|
|
2918
|
+
needs_space(pre.endswith(" "), texts[k], s0 - 1),
|
|
2919
|
+
needs_space(post.startswith(" "), texts[k], e0),
|
|
2920
|
+
)
|
|
2921
|
+
)
|
|
2922
|
+
pi, cursor = k, e0
|
|
2923
|
+
return True
|
|
2924
|
+
e1 = (
|
|
2925
|
+
find_end(texts[k + 1], 0, post, shown, is_last)
|
|
2926
|
+
if e0 is None and k + 1 < len(els)
|
|
2927
|
+
else None
|
|
2928
|
+
)
|
|
2929
|
+
if (
|
|
2930
|
+
e1 is not None
|
|
2931
|
+
and mathlike(texts[k][s0:], shown)
|
|
2932
|
+
and mathlike(texts[k + 1][:e1], shown)
|
|
2933
|
+
):
|
|
2934
|
+
inline.append((els[k], s0, len(texts[k]), tex))
|
|
2935
|
+
inline.append((els[k + 1], 0, e1, None))
|
|
2936
|
+
pads.append(
|
|
2937
|
+
(
|
|
2938
|
+
needs_space(pre.endswith(" "), texts[k], s0 - 1),
|
|
2939
|
+
False,
|
|
2940
|
+
)
|
|
2941
|
+
)
|
|
2942
|
+
pads.append(
|
|
2943
|
+
(
|
|
2944
|
+
False,
|
|
2945
|
+
needs_space(
|
|
2946
|
+
post.startswith(" "), texts[k + 1], e1
|
|
2947
|
+
),
|
|
2948
|
+
)
|
|
2949
|
+
)
|
|
2950
|
+
pi, cursor = k + 1, e1
|
|
2951
|
+
return True
|
|
2952
|
+
return False
|
|
2953
|
+
|
|
2636
2954
|
if has_inline:
|
|
2637
2955
|
by_block: dict[int, list[Any]] = {}
|
|
2638
2956
|
for p, b in zip(paras, matched):
|
|
@@ -2642,33 +2960,22 @@ def to_docx(
|
|
|
2642
2960
|
els = by_block.get(id(b), [])
|
|
2643
2961
|
texts = [doc.linear_text(el) for el in els]
|
|
2644
2962
|
pi, cursor = 0, 0
|
|
2645
|
-
|
|
2963
|
+
found = b.inline_math if els else []
|
|
2964
|
+
for n, (pre, tex, post) in enumerate(found):
|
|
2965
|
+
# Only a superscript or subscript, e.g., $^{13}$C, stays
|
|
2966
|
+
# as Word's formatted text, easier to edit
|
|
2967
|
+
if re.fullmatch(
|
|
2968
|
+
r"\s*(\{\})?\s*[\^_]\s*(\{[^{}]*\}|\w)\s*", tex
|
|
2969
|
+
):
|
|
2970
|
+
continue
|
|
2971
|
+
is_last = n == len(found) - 1
|
|
2972
|
+
shown = calkit.latex.expand_macros(
|
|
2973
|
+
tex, b.macros, math=True
|
|
2974
|
+
)
|
|
2646
2975
|
# In the paragraph where the last one was, or a later
|
|
2647
2976
|
# one when the block continues past a page or float
|
|
2648
2977
|
for k in range(pi, len(els)):
|
|
2649
|
-
|
|
2650
|
-
texts[k], cursor if k == pi else 0, pre
|
|
2651
|
-
)
|
|
2652
|
-
if s0 is None:
|
|
2653
|
-
continue
|
|
2654
|
-
e0 = find_end(texts[k], s0, post)
|
|
2655
|
-
if e0 is not None and mathlike(texts[k][s0:e0], tex):
|
|
2656
|
-
inline.append((els[k], s0, e0, tex))
|
|
2657
|
-
pi, cursor = k, e0
|
|
2658
|
-
break
|
|
2659
|
-
e1 = (
|
|
2660
|
-
find_end(texts[k + 1], 0, post)
|
|
2661
|
-
if e0 is None and k + 1 < len(els)
|
|
2662
|
-
else None
|
|
2663
|
-
)
|
|
2664
|
-
if (
|
|
2665
|
-
e1 is not None
|
|
2666
|
-
and mathlike(texts[k][s0:], tex)
|
|
2667
|
-
and mathlike(texts[k + 1][:e1], tex)
|
|
2668
|
-
):
|
|
2669
|
-
inline.append((els[k], s0, len(texts[k]), tex))
|
|
2670
|
-
inline.append((els[k + 1], 0, e1, None))
|
|
2671
|
-
pi, cursor = k + 1, e1
|
|
2978
|
+
if place(k, pre, tex, shown, post, is_last):
|
|
2672
2979
|
break
|
|
2673
2980
|
inline_maths = iter(
|
|
2674
2981
|
calkit.docx.latex_to_omml(
|
|
@@ -2689,8 +2996,34 @@ def to_docx(
|
|
|
2689
2996
|
math = inline_omml[i]
|
|
2690
2997
|
if itex is not None and math is None:
|
|
2691
2998
|
continue
|
|
2999
|
+
pad_lead, pad_trail = pads[i]
|
|
3000
|
+
trailed = pad_trail and doc.insert_text(el, e0, " ")
|
|
3001
|
+
if pad_lead and doc.insert_text(el, s0, " "):
|
|
3002
|
+
s0, e0 = s0 + 1, e0 + 1
|
|
2692
3003
|
if doc.replace_range(el, s0, e0, math) and itex is not None:
|
|
2693
3004
|
inline_count += 1
|
|
3005
|
+
# After a picture there was no text to put it in, so
|
|
3006
|
+
# it starts the text after
|
|
3007
|
+
if pad_trail and not trailed and math is not None:
|
|
3008
|
+
following = list(el)[list(el).index(math) + 1 :]
|
|
3009
|
+
next_t = next(
|
|
3010
|
+
(
|
|
3011
|
+
x
|
|
3012
|
+
for r in following
|
|
3013
|
+
if r.tag
|
|
3014
|
+
== calkit.docx._tag(calkit.docx.W, "r")
|
|
3015
|
+
for x in r.iter(
|
|
3016
|
+
calkit.docx._tag(calkit.docx.W, "t")
|
|
3017
|
+
)
|
|
3018
|
+
),
|
|
3019
|
+
None,
|
|
3020
|
+
)
|
|
3021
|
+
if next_t is not None and next_t.text:
|
|
3022
|
+
next_t.text = " " + next_t.text.lstrip()
|
|
3023
|
+
next_t.set(
|
|
3024
|
+
calkit.docx.XML_SPACE,
|
|
3025
|
+
"preserve",
|
|
3026
|
+
)
|
|
2694
3027
|
# A paragraph Word broke inside the math is whole again, under
|
|
2695
3028
|
# the first one's bookmark
|
|
2696
3029
|
for i in range(1, len(inline)):
|
|
@@ -2954,6 +3287,15 @@ def merge_docx(
|
|
|
2954
3287
|
)
|
|
2955
3288
|
continue
|
|
2956
3289
|
new_lines = calkit.latex.apply_edit(blk, sent_tex, new_tex)
|
|
3290
|
+
if new_lines == [
|
|
3291
|
+
ln.text.rstrip() for ln in blk.lines if ln.text.strip()
|
|
3292
|
+
]:
|
|
3293
|
+
changes.append(
|
|
3294
|
+
LatexDocxMergeChange(
|
|
3295
|
+
path=blk.path, lineno=blk.lineno, status="already-applied"
|
|
3296
|
+
)
|
|
3297
|
+
)
|
|
3298
|
+
continue
|
|
2957
3299
|
if new_lines is None:
|
|
2958
3300
|
warn(
|
|
2959
3301
|
f"Edit at {loc} touches markup; apply it by hand: {para.text[:60]}"
|