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.
Files changed (23) hide show
  1. calkit/cli/latex.py +400 -58
  2. calkit/docx.py +203 -28
  3. calkit/latex.py +435 -57
  4. calkit/tests/test_docx.py +425 -1
  5. calkit/tests/test_latex.py +54 -2
  6. {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/METADATA +1 -1
  7. {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/RECORD +23 -23
  8. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  9. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  10. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  11. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  12. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  13. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  14. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  15. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  16. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  17. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  18. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  19. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  20. {calkit_python-0.50.0.data → calkit_python-0.50.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  21. {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/WHEEL +0 -0
  22. {calkit_python-0.50.0.dist-info → calkit_python-0.50.1.dist-info}/entry_points.txt +0 -0
  23. {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 find_start(text: str, cursor: int, before: str) -> int | None:
2575
- start = cursor
2576
- bw = tokens(before)[-2:]
2577
- tail = before
2578
- if bw:
2579
- m = re.compile(
2580
- r"(?<![A-Za-z0-9])"
2581
- + r"[^A-Za-z0-9]+".join(map(re.escape, bw))
2582
- + r"(?![A-Za-z0-9])"
2583
- ).search(text, cursor)
2584
- if m is None:
2585
- return None
2586
- start = m.end()
2587
- tail = before[before.rfind(bw[-1]) + len(bw[-1]) :]
2588
- # Past the prose's own punctuation, e.g., "(" before the math
2589
- for ch in tail.replace(" ", ""):
2590
- while start < len(text) and text[start].isspace():
2591
- start += 1
2592
- if text[start : start + 1] != ch:
2593
- break
2594
- start += 1
2595
- while start < len(text) and text[start].isspace():
2596
- start += 1
2597
- return start
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(text: str, start: int, after: str) -> int | None:
2600
- aw = tokens(after)[:2]
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 = re.compile(
2607
- r"(?<![A-Za-z0-9])"
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
- return bool(region.strip()) and all(
2629
- w.lower() in tex.lower() for w in words
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
- for pre, tex, post in b.inline_math if els else []:
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
- s0 = find_start(
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]}"