Spaces:
Paused
Paused
unknown
Claude Opus 5.5
Journal Formatter and Journal Finder as native tabs of the suite (work in progress)
7ee2a84 Download formatmatch/texblocks.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/texblocks.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/formatmatch/texblocks.py
-
curl -L -o texblocks.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/texblocks.py
19.8 kB
| """LaTeX input: protect algorithm and display-equation environments from pandoc. | |
| Each environment is replaced by a marker paragraph before conversion and restored afterwards as a block that | |
| carries the *original LaTeX source* (used verbatim in the LaTeX output) plus a Word rendering (OMML maths via | |
| pandoc, algorithm lines parsed from algorithmic / algorithm2e commands). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| from typing import Dict, List, Tuple | |
| import docx | |
| from . import model as M | |
| from .model import Run | |
| NS_M = "http://schemas.openxmlformats.org/officeDocument/2006/math" | |
| ALGO_ENVS = r"algorithm\*?|algorithm2e\*?|algorithmic|algorithmicx|procedure" | |
| MATH_ENVS = r"equation\*?|align\*?|gather\*?|multline\*?|eqnarray\*?|flalign\*?|alignat\*?" | |
| PKG_RE = re.compile(r"^\s*\\usepackage(?:\[[^\]]*\])?\{[^}]*(?:algorithm|algpseudocode|algorithmicx|algorithmic|" | |
| r"algcompatible|algpascal|algc)[^}]*\}.*$", re.M) | |
| MACRO_RE = re.compile(r"^\s*\\(?:algrenewcommand|algnewcommand|algdef|SetKw\w*|SetAlgo\w*|DontPrintSemicolon|" | |
| r"SetAlFnt|renewcommand\{\\algorithmic\w*\}).*$", re.M) | |
| def _strip_comments(s: str) -> str: | |
| return re.sub(r"(?<!\\)%.*", "", s) | |
| def expand_inputs(tex: str, root: str, depth: int = 0) -> str: | |
| """Inline \\input{…} / \\include{…} files so environments in sub-files are protected too.""" | |
| if depth > 5: | |
| return tex | |
| def sub(m): | |
| name = m.group(2).strip() | |
| for cand in (name, name + ".tex"): | |
| path = os.path.join(root, cand) | |
| if os.path.isfile(path): | |
| try: | |
| return expand_inputs(open(path, encoding="utf-8", errors="ignore").read(), root, depth + 1) | |
| except OSError: | |
| break | |
| return m.group(0) | |
| return re.sub(r"\\(input|include)\{([^}]+)\}", sub, tex) | |
| def protect(tex: str): | |
| """Return (modified_tex, blocks, preamble_lines). blocks[k] = dict(kind, src, number).""" | |
| blocks: List[dict] = [] | |
| head, sep, body = tex.partition("\\begin{document}") | |
| if not sep: | |
| head, body = "", tex | |
| preamble = PKG_RE.findall(head) + MACRO_RE.findall(head) | |
| preamble = [p.strip() for p in preamble] | |
| # 1. algorithms (outermost environments only) | |
| def algo_sub(m): | |
| k = len(blocks) | |
| blocks.append({"kind": "algo", "src": m.group(0)}) | |
| return f"\n\nFMBLOCK{k}X\n\n" | |
| body = re.sub(r"\\begin\{(" + ALGO_ENVS + r")\}.*?\\end\{\1\}", algo_sub, body, flags=re.S) | |
| # 2. display maths | |
| eq_counter = [0] | |
| def numbers_for(env: str, src: str) -> str: | |
| if env.endswith("*") or env in ("displaymath",): | |
| return "" | |
| rows = re.split(r"\\\\(?!\w)", src) if env not in ("equation", "multline") else [src] | |
| start = eq_counter[0] + 1 | |
| for r in rows: | |
| if r.strip() and not re.search(r"\\(?:nonumber|notag)\b", r): | |
| eq_counter[0] += 1 | |
| end = eq_counter[0] | |
| if end < start: | |
| return "" | |
| return str(start) if end == start else f"{start}–{end}" | |
| labels: Dict[str, str] = {} | |
| def math_sub(m): | |
| env = m.group(1) or "" | |
| src = m.group(0) | |
| num = numbers_for(env, m.group(2) or "") if env else "" | |
| for lab in re.findall(r"\\label\{([^}]*)\}", src): | |
| labels[lab] = num.split("–")[0] if num else "" | |
| k = len(blocks) | |
| blocks.append({"kind": "eq", "src": src, "env": env, "number": num, "body": m.group(2) if env else m.group(3)}) | |
| return f"\n\nFMBLOCK{k}X\n\n" | |
| body = re.sub(r"\\begin\{(" + MATH_ENVS + r")\}(.*?)\\end\{\1\}|\\\[(?P<x>)(.*?)\\\]", | |
| lambda m: math_sub(_EqMatch(m)), body, flags=re.S) | |
| # algorithm labels -> numbers (in order of appearance) | |
| n_alg = 0 | |
| for b in blocks: | |
| if b["kind"] == "algo" and re.search(r"\\caption", b["src"]): | |
| n_alg += 1 | |
| b["number"] = str(n_alg) | |
| for lab in re.findall(r"\\label\{([^}]*)\}", b["src"]): | |
| labels[lab] = str(n_alg) | |
| # \ref / \eqref to protected blocks become literal numbers (pandoc cannot resolve them) | |
| def ref_sub(m): | |
| lab = m.group(2) | |
| if lab not in labels: | |
| return m.group(0) | |
| n = labels[lab] | |
| return f"({n})" if m.group(1) == "eqref" else n | |
| body = re.sub(r"\\(eqref|ref|autoref|cref|Cref)\{([^}]*)\}", ref_sub, body) | |
| return head + sep + body, blocks, preamble | |
| class _EqMatch: | |
| """Normalise the two alternatives of the display-maths regex.""" | |
| def __init__(self, m): | |
| self._m = m | |
| if m.group(1): | |
| self.g = (m.group(0), m.group(1), m.group(2), None) | |
| else: | |
| self.g = (m.group(0), None, None, m.group(4)) | |
| def group(self, i): | |
| return self.g[i] | |
| # ---------------------------------------------------------------- LaTeX maths → OMML (batched) | |
| def latex_to_omml(items: List[str]) -> List[str]: | |
| """Convert LaTeX maths snippets to OMML XML strings via pandoc ("" when conversion fails).""" | |
| if not items or not shutil.which("pandoc"): | |
| return [""] * len(items) | |
| md = [] | |
| for k, t in enumerate(items): | |
| md.append(f"FMEQ{k}X\n\n$${t}$$\n") | |
| out = [""] * len(items) | |
| with tempfile.TemporaryDirectory() as td: | |
| src = os.path.join(td, "m.md"); dst = os.path.join(td, "m.docx") | |
| open(src, "w", encoding="utf-8").write("\n".join(md)) | |
| r = subprocess.run(["pandoc", src, "-f", "markdown+tex_math_dollars", "-o", dst], capture_output=True, | |
| timeout=180) | |
| if r.returncode != 0: | |
| return out | |
| d = docx.Document(dst) | |
| cur = None | |
| from lxml import etree | |
| for p in d.paragraphs: | |
| m = re.fullmatch(r"FMEQ(\d+)X", p.text.strip()) | |
| if m: | |
| cur = int(m.group(1)); continue | |
| if cur is not None: | |
| om = next(p._p.iter(f"{{{NS_M}}}oMathPara"), None) | |
| if om is None: | |
| om = next(p._p.iter(f"{{{NS_M}}}oMath"), None) | |
| if om is not None and not out[cur]: | |
| out[cur] = etree.tostring(om, encoding="unicode") | |
| return out | |
| def _env_math_for_pandoc(b: dict) -> str: | |
| env = (b.get("env") or "").rstrip("*") | |
| body = b.get("body") or "" | |
| body = re.sub(r"\\label\{[^}]*\}|\\(?:nonumber|notag)\b", "", body) | |
| if env in ("align", "flalign", "alignat", "eqnarray"): | |
| body = re.sub(r"^\{\d+\}", "", body) if env == "alignat" else body | |
| body = body.replace("&=&", "&=") | |
| return "\\begin{aligned}" + body + "\\end{aligned}" | |
| if env in ("gather", "multline"): | |
| return "\\begin{gathered}" + body + "\\end{gathered}" | |
| return body | |
| # ---------------------------------------------------------------- algorithmic → lines | |
| CMD_TEXT = { | |
| "Require": "Input:", "Ensure": "Output:", "Input": "Input:", "Output": "Output:", "KwIn": "Input:", | |
| "KwOut": "Output:", "KwData": "Data:", "KwResult": "Result:", "REQUIRE": "Input:", "ENSURE": "Output:", | |
| } | |
| def _brace_arg(s: str, i: int) -> Tuple[str, int]: | |
| """Read a {…} group starting at s[i] == '{'. Returns (content, index after group).""" | |
| if i >= len(s) or s[i] != "{": | |
| return "", i | |
| depth = 0 | |
| for j in range(i, len(s)): | |
| if s[j] == "{" and (j == 0 or s[j - 1] != "\\"): | |
| depth += 1 | |
| elif s[j] == "}" and s[j - 1] != "\\": | |
| depth -= 1 | |
| if depth == 0: | |
| return s[i + 1:j], j + 1 | |
| return s[i + 1:], len(s) | |
| def parse_algorithm(src: str): | |
| """(caption, [(indent, text_with_$math$)], numbered)""" | |
| s = _strip_comments(src) | |
| cap = "" | |
| m = re.search(r"\\caption\{", s) | |
| if m: | |
| cap, _ = _brace_arg(s, m.end() - 1) | |
| numbered = bool(re.search(r"\\begin\{algorithmic\}\[\s*1\s*\]", s)) or "\\LinesNumbered" in s | |
| body = re.sub(r"\\caption\{", "", s, count=0) | |
| lines: List[Tuple[int, str]] = [] | |
| indent = 0 | |
| is_a2e = "\\SetKw" in s or "\\KwIn" in s or "\\eIf" in s or "algorithm2e" in s or "\\;" in s | |
| if is_a2e: | |
| return cap, _parse_a2e(s), numbered | |
| tokens = re.split(r"(\\(?:State|STATE|Statex|Require|Ensure|REQUIRE|ENSURE|Input|Output|If|IF|ElsIf|ELSIF|" | |
| r"Else|ELSE|EndIf|ENDIF|For|FOR|ForAll|FORALL|EndFor|ENDFOR|While|WHILE|EndWhile|ENDWHILE|" | |
| r"Repeat|REPEAT|Until|UNTIL|Loop|LOOP|EndLoop|ENDLOOP|Function|EndFunction|Procedure|" | |
| r"EndProcedure|Return|RETURN|Comment|COMMENT|Call|label|caption|begin|end)\b\*?)", s) | |
| i = 0 | |
| cur_text = None | |
| def flush(): | |
| nonlocal cur_text | |
| if cur_text is not None and cur_text.strip(): | |
| lines.append((max(0, indent_at[0]), cur_text.strip())) | |
| cur_text = None | |
| indent_at = [0] | |
| k = 0 | |
| while k < len(tokens): | |
| tok = tokens[k] | |
| rest = tokens[k + 1] if k + 1 < len(tokens) else "" | |
| if not tok.startswith("\\"): | |
| if cur_text is not None: | |
| cur_text += tok | |
| k += 1 | |
| continue | |
| name = tok[1:].rstrip("*") | |
| args = [] | |
| j = 0 | |
| r = rest | |
| while r[j:j + 1] == "{" or (r[j:j + 1].isspace() and r[j:].lstrip().startswith("{") and len(args) < 2 and | |
| name in ("If", "IF", "ElsIf", "ELSIF", "For", "FOR", "ForAll", "FORALL", "While", | |
| "WHILE", "Until", "UNTIL", "Function", "Procedure", "Comment", | |
| "COMMENT", "Call", "label", "caption", "begin", "end")): | |
| j += len(r[j:]) - len(r[j:].lstrip()) | |
| a, j = _brace_arg(r, j) | |
| args.append(a) | |
| if name not in ("Function", "Procedure", "Call"): | |
| break | |
| tail = r[j:] | |
| tokens[k + 1] = tail | |
| if name in ("label", "caption", "begin", "end"): | |
| k += 1 | |
| continue | |
| if name in ("Comment", "COMMENT"): | |
| if cur_text is not None: | |
| cur_text += " ▷ " + (args[0] if args else "") | |
| k += 1 | |
| continue | |
| flush() | |
| lower = name.lower() | |
| if lower in ("endif", "endfor", "endwhile", "endloop", "endfunction", "endprocedure"): | |
| indent = max(0, indent - 1) | |
| indent_at[0] = indent | |
| word = {"endif": "end if", "endfor": "end for", "endwhile": "end while", "endloop": "end loop", | |
| "endfunction": "end function", "endprocedure": "end procedure"}[lower] | |
| cur_text = word | |
| flush() | |
| elif lower in ("if", "for", "forall", "while", "loop", "repeat", "function", "procedure"): | |
| indent_at[0] = indent | |
| a = args[0] if args else "" | |
| if lower == "if": | |
| cur_text = f"if {a} then" | |
| elif lower in ("for", "forall"): | |
| cur_text = f"for {'all ' if lower == 'forall' else ''}{a} do" | |
| elif lower == "while": | |
| cur_text = f"while {a} do" | |
| elif lower in ("function", "procedure"): | |
| cur_text = f"{lower} {a}({args[1] if len(args) > 1 else ''})" | |
| else: | |
| cur_text = lower | |
| indent += 1 | |
| elif lower in ("elsif", "else"): | |
| indent_at[0] = max(0, indent - 1) | |
| cur_text = f"else if {args[0]} then" if lower == "elsif" and args else "else" | |
| elif lower == "until": | |
| indent = max(0, indent - 1) | |
| indent_at[0] = indent | |
| cur_text = f"until {args[0] if args else ''}" | |
| elif lower == "return": | |
| indent_at[0] = indent | |
| cur_text = "return" | |
| elif name in CMD_TEXT: | |
| indent_at[0] = 0 | |
| cur_text = CMD_TEXT[name] + " " | |
| else: # State / Statex / Call | |
| indent_at[0] = indent | |
| cur_text = "" | |
| k += 1 | |
| flush() | |
| return cap, [(lvl, re.sub(r"\s+", " ", t)) for lvl, t in lines], numbered | |
| def _parse_a2e(s: str): | |
| """algorithm2e: statements end with \\; and blocks are {…} arguments.""" | |
| s = re.sub(r"\\(?:caption|label)\{[^}]*\}", "", s) | |
| s = re.sub(r"\\(?:begin|end)\{algorithm2e\*?\}(\[[^\]]*\])?", "", s) | |
| s = re.sub(r"\\(?:SetKw\w*|DontPrintSemicolon|SetAlgo\w*|LinesNumbered|SetAlFnt)(\{[^}]*\})*", "", s) | |
| out: List[Tuple[int, str]] = [] | |
| def walk(text: str, ind: int): | |
| i = 0 | |
| buf = "" | |
| while i < len(text): | |
| m = re.match(r"\\(KwIn|KwOut|KwData|KwResult|Input|Output)\{", text[i:]) | |
| if m: | |
| a, j = _brace_arg(text, i + m.end() - 1) | |
| out.append((0, CMD_TEXT.get(m.group(1), m.group(1) + ":") + " " + a)) | |
| i = j | |
| continue | |
| m = re.match(r"\\(eIf|If|uIf|ElseIf|uElseIf|Else|uElse|For|ForEach|ForAll|While|Repeat|Fn|Begin)\b\s*", text[i:]) | |
| if m: | |
| if buf.strip(): | |
| out.append((ind, buf.strip())); buf = "" | |
| name = m.group(1) | |
| j = i + m.end() | |
| args = [] | |
| while j < len(text) and text[j] == "{": | |
| a, j = _brace_arg(text, j) | |
| args.append(a) | |
| while j < len(text) and text[j].isspace(): | |
| j += 1 | |
| if len(args) >= (3 if name == "eIf" else 2 if name not in ("Else", "uElse", "Begin") else 1): | |
| break | |
| if name in ("eIf", "If", "uIf"): | |
| out.append((ind, f"if {args[0] if args else ''} then")) | |
| if len(args) > 1: | |
| walk(args[1], ind + 1) | |
| if name == "eIf" and len(args) > 2: | |
| out.append((ind, "else")); walk(args[2], ind + 1) | |
| out.append((ind, "end if")) | |
| elif name in ("ElseIf", "uElseIf"): | |
| out.append((ind, f"else if {args[0] if args else ''} then")) | |
| if len(args) > 1: | |
| walk(args[1], ind + 1) | |
| elif name in ("Else", "uElse"): | |
| out.append((ind, "else")) | |
| if args: | |
| walk(args[0], ind + 1) | |
| elif name in ("For", "ForEach", "ForAll", "While"): | |
| word = {"For": "for", "ForEach": "foreach", "ForAll": "for all", "While": "while"}[name] | |
| out.append((ind, f"{word} {args[0] if args else ''} do")) | |
| if len(args) > 1: | |
| walk(args[1], ind + 1) | |
| out.append((ind, f"end {word.split()[0]}")) | |
| elif name == "Repeat": | |
| out.append((ind, "repeat")) | |
| if len(args) > 1: | |
| walk(args[1], ind + 1) | |
| out.append((ind, f"until {args[0] if args else ''}")) | |
| else: | |
| if args: | |
| walk(args[-1], ind + 1 if name != "Begin" else ind) | |
| i = j | |
| continue | |
| if text.startswith("\\;", i): | |
| if buf.strip(): | |
| out.append((ind, buf.strip())) | |
| buf = "" | |
| i += 2 | |
| continue | |
| m = re.match(r"\\(Return|KwRet)\{", text[i:]) | |
| if m: | |
| a, j = _brace_arg(text, i + m.end() - 1) | |
| buf += "return " + a | |
| i = j | |
| continue | |
| m = re.match(r"\\(?:tcc|tcp)\*?\{", text[i:]) | |
| if m: | |
| a, j = _brace_arg(text, i + m.end() - 1) | |
| buf += " ▷ " + a | |
| i = j | |
| continue | |
| buf += text[i] | |
| i += 1 | |
| if buf.strip(): | |
| out.append((ind, buf.strip())) | |
| walk(s, 0) | |
| return [(l, re.sub(r"\\KwTo\b", "to", re.sub(r"\s+", " ", t))) for l, t in out if t.strip()] | |
| # ---------------------------------------------------------------- text with $math$ → runs | |
| _TEXT_CMDS = [(r"\\textbf\{([^{}]*)\}", "b"), (r"\\textit\{([^{}]*)\}", "i"), (r"\\emph\{([^{}]*)\}", "i"), | |
| (r"\\texttt\{([^{}]*)\}", ""), (r"\\text\{([^{}]*)\}", "")] | |
| def _plain(t: str) -> str: | |
| t = re.sub(r"\\(?:KwTo)\b", "to", t) | |
| t = re.sub(r"\\(?:gets|leftarrow)\b", "←", t) | |
| t = t.replace("\\&", "&").replace("\\%", "%").replace("\\_", "_").replace("~", " ") | |
| t = re.sub(r"\\[a-zA-Z]+\*?", "", t) | |
| return t.replace("{", "").replace("}", "") | |
| def text_to_runs(t: str, math_pool: list) -> List[Run]: | |
| """Split "for $i=1$ to $n$" into text runs and maths runs (maths collected in math_pool for OMML).""" | |
| runs: List[Run] = [] | |
| pos = 0 | |
| for m in re.finditer(r"(?<!\\)\$(.+?)(?<!\\)\$|\\\((.+?)\\\)", t): | |
| if m.start() > pos: | |
| runs += _styled(t[pos:m.start()]) | |
| lx = (m.group(1) or m.group(2)).strip() | |
| r = Run(latex=lx) | |
| math_pool.append(r) | |
| runs.append(r) | |
| pos = m.end() | |
| if pos < len(t): | |
| runs += _styled(t[pos:]) | |
| return [r for r in runs if r.text or r.is_math] | |
| def _styled(t: str) -> List[Run]: | |
| out = [] | |
| pos = 0 | |
| pat = re.compile(r"\\(textbf|textit|emph|texttt|text)\{([^{}]*)\}") | |
| for m in pat.finditer(t): | |
| if m.start() > pos: | |
| out.append(Run(_plain(t[pos:m.start()]))) | |
| out.append(Run(_plain(m.group(2)), bold=m.group(1) == "textbf", italic=m.group(1) in ("textit", "emph"))) | |
| pos = m.end() | |
| if pos < len(t): | |
| out.append(Run(_plain(t[pos:]))) | |
| return out | |
| def restore(doc: M.Document, blocks: List[dict], preamble: List[str]): | |
| """Replace marker paragraphs with ALGORITHM / EQUATION blocks.""" | |
| if not blocks: | |
| return | |
| math_pool: List[Run] = [] | |
| built = {} | |
| eq_items = [] | |
| for k, b in enumerate(blocks): | |
| if b["kind"] == "eq": | |
| eq_items.append((k, _env_math_for_pandoc(b))) | |
| continue | |
| cap, lines, numbered = parse_algorithm(b["src"]) | |
| cap_runs = text_to_runs(cap, math_pool) if cap else [] | |
| title = f"Algorithm {b.get('number', '')}".strip() | |
| head_runs = [Run(title + (": " if cap else ""), bold=True)] + cap_runs | |
| line_runs = [(lvl, text_to_runs(t, math_pool)) for lvl, t in lines] | |
| if numbered: | |
| n, numbered_runs = 0, [] | |
| for (lvl, rs), (_, t) in zip(line_runs, lines): | |
| if re.match(r"^(Input|Output|Data|Result):", t): | |
| numbered_runs.append((lvl, rs)) | |
| else: | |
| n += 1 | |
| numbered_runs.append((lvl, [Run(f"{n}: ")] + rs)) | |
| line_runs = numbered_runs | |
| built[k] = M.Block(M.ALGORITHM, runs=head_runs, lines=line_runs, tex_src=b["src"], | |
| orig=title + (": " + _plain(cap) if cap else ""), ordered=numbered) | |
| # OMML for every maths snippet in one pandoc call | |
| snippets = [x for _, x in eq_items] + [r.latex for r in math_pool] | |
| omml = latex_to_omml(snippets) | |
| for (k, _), om in zip(eq_items, omml[:len(eq_items)]): | |
| b = blocks[k] | |
| run = Run(omml=om) if om else Run(latex=b.get("body") or "") | |
| built[k] = M.Block(M.EQUATION, runs=[run], eq_num=b.get("number") or None, tex_src=b["src"]) | |
| for r, om in zip(math_pool, omml[len(eq_items):]): | |
| if om: | |
| r.omml = om | |
| new_blocks = [] | |
| for blk in doc.blocks: | |
| m = re.fullmatch(r"\s*FMBLOCK(\d+)X\s*", blk.text or "") | |
| if m and int(m.group(1)) in built: | |
| new_blocks.append(built[int(m.group(1))]) | |
| continue | |
| if m: | |
| continue | |
| new_blocks.append(blk) | |
| doc.blocks = new_blocks | |
| doc.tex_preamble = list(dict.fromkeys(preamble)) | |