Spaces:
Running
Running
unknown
Journal Formatter: footnotes, web references, books and reports, author marks, cleaner LaTeX
2eb3609 Download formatmatch/render_docx.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 40 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/render_docx.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/formatmatch/render_docx.py
-
curl -L -o render_docx.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/render_docx.py
40 kB
| """Write a prepared Document to DOCX — either by cloning a template's formatting or from the spec.""" | |
| from __future__ import annotations | |
| import copy | |
| import io | |
| import re | |
| from typing import List, Optional | |
| import docx | |
| from docx.enum.section import WD_SECTION | |
| from docx.enum.table import WD_TABLE_ALIGNMENT | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT | |
| from docx.oxml import OxmlElement, parse_xml | |
| from docx.oxml.ns import nsdecls, qn | |
| from docx.shared import Cm, Emu, Inches, Pt, RGBColor | |
| from lxml import etree | |
| from . import model as M | |
| from .model import Run | |
| from .raw_import import RawImporter, fit_equation_tabs, fit_table | |
| ALIGN = {"left": WD_ALIGN_PARAGRAPH.LEFT, "center": WD_ALIGN_PARAGRAPH.CENTER, | |
| "right": WD_ALIGN_PARAGRAPH.RIGHT, "justify": WD_ALIGN_PARAGRAPH.JUSTIFY} | |
| PAGE = {"A4": (21.0, 29.7), "Letter": (21.59, 27.94)} | |
| NS_M = "http://schemas.openxmlformats.org/officeDocument/2006/math" | |
| TBLPR_ORDER = ["tblStyle", "tblpPr", "tblOverlap", "bidiVisual", "tblStyleRowBandSize", "tblStyleColBandSize", | |
| "tblW", "jc", "tblCellSpacing", "tblInd", "tblBorders", "shd", "tblLayout", "tblCellMar", "tblLook", | |
| "tblCaption", "tblDescription"] | |
| PPR_ORDER = ["pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr", "widowControl", "numPr", | |
| "suppressLineNumbers", "pBdr", "shd", "tabs", "suppressAutoHyphens", "kinsoku", "wordWrap", | |
| "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN", "bidi", "adjustRightInd", "snapToGrid", | |
| "spacing", "ind", "contextualSpacing", "mirrorIndents", "suppressOverlap", "jc", "textDirection", | |
| "textAlignment", "textboxTightWrap", "outlineLvl", "divId", "cnfStyle", "rPr", "sectPr", "pPrChange"] | |
| MATH_PR_ORDER = { | |
| "dPr": ["begChr", "sepChr", "endChr", "grow", "shp", "ctrlPr"], | |
| "mcPr": ["count", "mcJc"], | |
| "mPr": ["baseJc", "plcHide", "rSpRule", "cGpRule", "rSp", "cSp", "cGp", "mcs", "ctrlPr"], | |
| "eqArrPr": ["baseJc", "maxDist", "objDist", "rSpRule", "rSp", "ctrlPr"], | |
| "naryPr": ["chr", "limLoc", "grow", "subHide", "supHide", "ctrlPr"], | |
| "fPr": ["type", "ctrlPr"], | |
| } | |
| def _fix_math_order(root): | |
| """pandoc writes m:dPr children out of schema order; Word is lenient but validators are not.""" | |
| for t in root.iter(f"{{{NS_M}}}t"): | |
| if t.text and "\u200b" in t.text: | |
| t.text = t.text.replace("\u200b", "") | |
| for tag, order in MATH_PR_ORDER.items(): | |
| for pr in root.iter(f"{{{NS_M}}}{tag}"): | |
| kids = list(pr) | |
| key = lambda e: order.index(etree.QName(e).localname) if etree.QName(e).localname in order else 99 | |
| if [key(k) for k in kids] != sorted(key(k) for k in kids): | |
| for k in kids: | |
| pr.remove(k) | |
| for k in sorted(kids, key=key): | |
| pr.append(k) | |
| def _put(parent, child, order): | |
| """Insert/replace `child` in `parent` respecting the schema child order.""" | |
| name = etree.QName(child).localname | |
| for old in parent.findall(qn("w:" + name)): | |
| parent.remove(old) | |
| idx = order.index(name) | |
| for k, ch in enumerate(parent): | |
| ln = etree.QName(ch).localname | |
| if ln in order and order.index(ln) > idx: | |
| ch.addprevious(child) | |
| return child | |
| parent.append(child) | |
| return child | |
| def _clean_ppr(xml: str): | |
| el = parse_xml(xml) | |
| for s in el.findall(qn("w:sectPr")): | |
| el.remove(s) | |
| for s in el.findall(qn("w:rPr")): # paragraph-mark run props: harmless but drop to avoid oddities | |
| pass | |
| return el | |
| class DocxRenderer: | |
| def __init__(self, spec: dict, profile=None): | |
| self.spec = spec | |
| self.prof = profile | |
| self.warnings: List[str] = [] | |
| self.imp: Optional[RawImporter] = None | |
| # ------------------------------------------------------------ setup | |
| def _new_document(self): | |
| if self.prof is not None: | |
| d = docx.Document(io.BytesIO(self.prof.data)) | |
| body = d.element.body | |
| for ch in list(body): | |
| if etree.QName(ch).localname != "sectPr": | |
| body.remove(ch) | |
| return d | |
| d = docx.Document() | |
| self._setup_page(d) | |
| self._setup_styles(d) | |
| return d | |
| def _text_width_cm(self) -> float: | |
| pg = self.spec["page"] | |
| w = PAGE.get(pg.get("size"), (21.0, 29.7))[0] | |
| m = pg.get("margins_cm", [2.54] * 4) | |
| return w - m[2] - m[3] | |
| def _col_width_cm(self) -> float: | |
| pg = self.spec["page"] | |
| n = max(1, int(pg.get("columns", 1))) | |
| return (self._text_width_cm() - pg.get("col_gap_cm", 0.5) * (n - 1)) / n | |
| def _setup_page(self, d): | |
| pg = self.spec["page"] | |
| s = d.sections[0] | |
| w, h = PAGE.get(pg.get("size"), (21.0, 29.7)) | |
| s.page_width, s.page_height = Cm(w), Cm(h) | |
| m = pg.get("margins_cm", [2.54] * 4) | |
| s.top_margin, s.bottom_margin, s.left_margin, s.right_margin = Cm(m[0]), Cm(m[1]), Cm(m[2]), Cm(m[3]) | |
| sp = s._sectPr | |
| cols = sp.find(qn("w:cols")) | |
| if cols is None: | |
| cols = OxmlElement("w:cols") | |
| sp.append(cols) | |
| cols.set(qn("w:num"), str(int(pg.get("columns", 1)))) | |
| cols.set(qn("w:space"), str(int(pg.get("col_gap_cm", 0.5) / 2.54 * 1440))) | |
| if pg.get("line_numbers"): | |
| ln = OxmlElement("w:lnNumType") | |
| ln.set(qn("w:countBy"), "1"); ln.set(qn("w:restart"), "continuous") | |
| cols.addprevious(ln) | |
| if pg.get("page_numbers", True): | |
| fp = s.footer.paragraphs[0] | |
| fp.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| self._add_field(fp, "PAGE") | |
| def _add_field(p, instr): | |
| r = p.add_run() | |
| b = OxmlElement("w:fldChar"); b.set(qn("w:fldCharType"), "begin"); r._r.append(b) | |
| r2 = p.add_run() | |
| it = OxmlElement("w:instrText"); it.set(qn("xml:space"), "preserve"); it.text = f" {instr} "; r2._r.append(it) | |
| r3 = p.add_run() | |
| e = OxmlElement("w:fldChar"); e.set(qn("w:fldCharType"), "end"); r3._r.append(e) | |
| def _font_on_style(self, st, family=None, size=None, bold=None, italic=None, smallcaps=None): | |
| f = st.font | |
| if family: | |
| f.name = family | |
| rpr = st.element.get_or_add_rPr() | |
| rf = rpr.find(qn("w:rFonts")) | |
| if rf is None: | |
| rf = OxmlElement("w:rFonts"); rpr.insert(0, rf) | |
| for k in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"): | |
| rf.set(qn(k), family) | |
| for k in ("w:asciiTheme", "w:hAnsiTheme", "w:eastAsiaTheme", "w:cstheme"): | |
| if rf.get(qn(k)) is not None: | |
| del rf.attrib[qn(k)] | |
| if size: | |
| f.size = Pt(size) | |
| if bold is not None: | |
| f.bold = bold | |
| if italic is not None: | |
| f.italic = italic | |
| if smallcaps is not None: | |
| f.small_caps = smallcaps | |
| f.color.rgb = RGBColor(0, 0, 0) | |
| def _setup_styles(self, d): | |
| S = self.spec | |
| fnt = S["font"] | |
| normal = d.styles["Normal"] | |
| self._font_on_style(normal, fnt["family"], fnt["size"], False, False) | |
| pf = normal.paragraph_format | |
| pf.space_before = Pt(fnt.get("space_before_pt", 0)) | |
| pf.space_after = Pt(fnt.get("space_after_pt", 6)) | |
| pf.line_spacing = fnt.get("line_spacing", 1.0) | |
| pf.alignment = ALIGN.get(fnt.get("align", "justify")) | |
| # theme fonts in docDefaults would otherwise override East-Asian etc. | |
| rpr_def = d.styles.element.find(qn("w:docDefaults") + "/" + qn("w:rPrDefault") + "/" + qn("w:rPr")) | |
| if rpr_def is not None: | |
| rf = rpr_def.find(qn("w:rFonts")) | |
| if rf is not None: | |
| for k in list(rf.attrib): | |
| del rf.attrib[k] | |
| for k in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"): | |
| rf.set(qn(k), fnt["family"]) | |
| levels = S["headings"]["levels"] | |
| for i in range(4): | |
| st = d.styles[f"Heading {i + 1}"] | |
| L = levels[i] if i < len(levels) else levels[-1] | |
| self._font_on_style(st, fnt["family"], L.get("size", fnt["size"]), L.get("bold", True), | |
| L.get("italic", False), L.get("smallcaps", False)) | |
| pf = st.paragraph_format | |
| pf.alignment = ALIGN.get(L.get("align", "left")) | |
| pf.space_before = Pt(L.get("space_before_pt", 12)) | |
| pf.space_after = Pt(L.get("space_after_pt", 6)) | |
| pf.keep_with_next = True | |
| pf.line_spacing = 1.0 if fnt.get("line_spacing", 1) < 1.5 else fnt.get("line_spacing") | |
| pf.first_line_indent = Cm(0) | |
| t = S["title"] | |
| st = d.styles["Title"] | |
| self._font_on_style(st, fnt["family"], t.get("size", 16), t.get("bold", True), t.get("italic", False)) | |
| ppr = st.element.find(qn("w:pPr")) | |
| if ppr is not None: | |
| for bd in ppr.findall(qn("w:pBdr")): | |
| ppr.remove(bd) | |
| rpr = st.element.find(qn("w:rPr")) | |
| if rpr is not None: | |
| for tag in ("w:spacing", "w:kern"): | |
| for x in rpr.findall(qn(tag)): | |
| rpr.remove(x) | |
| st.paragraph_format.alignment = ALIGN.get(t.get("align", "center")) | |
| st.paragraph_format.space_after = Pt(t.get("space_after_pt", 12)) | |
| st.paragraph_format.line_spacing = 1.0 | |
| try: | |
| cap = d.styles["Caption"] | |
| c = S["captions"] | |
| self._font_on_style(cap, fnt["family"], c.get("size", fnt["size"]), False, False) | |
| cap.paragraph_format.alignment = ALIGN.get(c.get("align", "center")) | |
| cap.paragraph_format.line_spacing = 1.0 | |
| except KeyError: | |
| pass | |
| # ------------------------------------------------------------ primitives | |
| def _ex(self, *keys) -> Optional[dict]: | |
| if self.prof is None: | |
| return None | |
| for k in keys: | |
| e = self.prof.exemplars.get(k) | |
| if e is not None: | |
| return e | |
| return None | |
| def _para(self, ex: Optional[dict] = None, style: Optional[str] = None): | |
| p = self.d.add_paragraph() | |
| if ex is not None: | |
| if ex.get("pPr"): | |
| old = p._p.find(qn("w:pPr")) | |
| if old is not None: | |
| p._p.remove(old) | |
| p._p.insert(0, _clean_ppr(ex["pPr"])) | |
| elif style: | |
| try: | |
| p.style = self.d.styles[style] | |
| except KeyError: | |
| pass | |
| return p | |
| def _fmt(self, p, align=None, size=None, indent_first=None, hanging=None, before=None, after=None, | |
| line=None, keep_next=None, left=None): | |
| pf = p.paragraph_format | |
| if align: | |
| pf.alignment = ALIGN.get(align) | |
| if indent_first is not None: | |
| pf.first_line_indent = Cm(indent_first) | |
| if hanging: | |
| pf.left_indent = Cm(hanging) | |
| pf.first_line_indent = Cm(-hanging) | |
| if left is not None: | |
| pf.left_indent = Cm(left) | |
| if before is not None: | |
| pf.space_before = Pt(before) | |
| if after is not None: | |
| pf.space_after = Pt(after) | |
| if line is not None: | |
| pf.line_spacing = line | |
| if keep_next is not None: | |
| pf.keep_with_next = keep_next | |
| def _run(self, p, text, base_rpr: Optional[str] = None, size=None, bold=None, italic=None, sup=False, | |
| sub=False, smallcaps=None, family=None): | |
| if text == "": | |
| return None | |
| parts = text.split("\n") | |
| r = None | |
| for k, part in enumerate(parts): | |
| if k > 0: | |
| r.add_break() | |
| if part == "" and k > 0: | |
| continue | |
| tabs = part.split("\t") | |
| for j, seg in enumerate(tabs): | |
| if j > 0: | |
| r = p.add_run() | |
| if base_rpr: | |
| r._r.insert(0, parse_xml(base_rpr)) | |
| r.add_tab() | |
| if seg == "" and j > 0: | |
| continue | |
| r = p.add_run(seg) | |
| if base_rpr: | |
| old = r._r.find(qn("w:rPr")) | |
| if old is not None: | |
| r._r.remove(old) | |
| r._r.insert(0, parse_xml(base_rpr)) | |
| f = r.font | |
| if family: | |
| f.name = family | |
| if size: | |
| f.size = Pt(size) | |
| if bold is not None: | |
| f.bold = bold | |
| if italic is not None: | |
| f.italic = italic | |
| if smallcaps is not None: | |
| f.small_caps = smallcaps | |
| if sup: | |
| f.superscript = True | |
| if sub: | |
| f.subscript = True | |
| return r | |
| def _runs(self, p, runs: List[Run], base_rpr=None, size=None, bold=None, italic=None, smallcaps=None, | |
| honor_all=False): | |
| """Write runs; inline bold/italic from the source are kept only where they are partial emphasis.""" | |
| textual = [r for r in runs if not r.is_math and r.text.strip()] | |
| all_b = bool(textual) and all(r.bold for r in textual) | |
| all_i = bool(textual) and all(r.italic for r in textual) | |
| for r in runs: | |
| if r.is_math: | |
| self._math(p, r) | |
| continue | |
| if r.note is not None: | |
| self._footnote(p, r.note) | |
| continue | |
| b = bold | |
| i = italic | |
| if honor_all or (r.bold and not all_b): | |
| b = True if r.bold else b | |
| if honor_all or (r.italic and not all_i): | |
| i = True if r.italic else i | |
| self._run(p, r.text, base_rpr, size=size, bold=b, italic=i, sup=r.sup, sub=r.sub, smallcaps=smallcaps) | |
| def _footnote(self, p, text): | |
| """A real Word footnote: the reference mark in the paragraph, the text in the footnotes part.""" | |
| from docx.opc.constants import RELATIONSHIP_TYPE as RT | |
| from docx.opc.packuri import PackURI | |
| from docx.opc.part import Part | |
| from lxml import etree | |
| W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" | |
| part = self.d.part | |
| if getattr(self, "_fn_part", None) is None: | |
| fpart = next((rel.target_part for rel in part.rels.values() if rel.reltype == RT.FOOTNOTES), None) | |
| if fpart is None: | |
| xml = (f'<w:footnotes xmlns:w="{W}">' | |
| '<w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote>' | |
| '<w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/>' | |
| '</w:r></w:p></w:footnote></w:footnotes>') | |
| fpart = Part(PackURI("/word/footnotes.xml"), | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", | |
| xml.encode(), part.package) | |
| part.relate_to(fpart, RT.FOOTNOTES) | |
| self._fn_part = fpart | |
| self._fn_root = etree.fromstring(fpart.blob) | |
| ids = [int(x) for x in self._fn_root.xpath("//w:footnote/@w:id", namespaces={"w": W})] or [0] | |
| self._fn_next = max(max(ids), 0) + 1 | |
| n = self._fn_next | |
| self._fn_next += 1 | |
| size = self.spec.get("font", {}).get("size", 12) | |
| half = int(round(max(8, size - 2) * 2)) | |
| esc = text.replace("&", "&").replace("<", "<").replace(">", ">") | |
| self._fn_root.append(etree.fromstring( | |
| f'<w:footnote xmlns:w="{W}" w:id="{n}"><w:p><w:pPr><w:spacing w:after="0"/></w:pPr>' | |
| f'<w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:footnoteRef/></w:r>' | |
| f'<w:r><w:rPr><w:sz w:val="{half}"/></w:rPr><w:t xml:space="preserve"> {esc}</w:t></w:r></w:p></w:footnote>')) | |
| self._fn_part._blob = etree.tostring(self._fn_root, xml_declaration=True, encoding="UTF-8", standalone=True) | |
| p._p.append(parse_xml(f'<w:r xmlns:w="{W}"><w:rPr><w:vertAlign w:val="superscript"/></w:rPr>' | |
| f'<w:footnoteReference w:id="{n}"/></w:r>')) | |
| def _insert(self, el): | |
| body = self.d.element.body | |
| sp = body.find(qn("w:sectPr")) | |
| if sp is not None: | |
| sp.addprevious(el) | |
| else: | |
| body.append(el) | |
| return el | |
| def _picture(self, p, img): | |
| """An equation / symbol picture at its own height, made smaller when it would be wider than the column | |
| (it used to run past the page edge and over the equation number).""" | |
| h = img.get("h") or 0.2 | |
| try: | |
| from PIL import Image | |
| iw, ih = Image.open(io.BytesIO(img["blob"])).size | |
| room = self._col_width_cm() / 2.54 * 0.82 # the rest of the line is for the number "(n)" | |
| if ih and h * iw / ih > room: | |
| h = room * ih / iw | |
| except Exception: | |
| pass | |
| p.add_run().add_picture(io.BytesIO(img["blob"]), height=Inches(h)) | |
| def _math(self, p, r: Run, inline=True): | |
| if r.raw is None and r.img is not None and r.omml is None and r.latex is None: | |
| try: | |
| self._picture(p, r.img) | |
| except Exception: | |
| self._run(p, "[equation]", italic=True) | |
| return | |
| if r.raw is not None: | |
| if self.imp is not None: | |
| p._p.append(self.imp.prepare(r.raw)) | |
| elif r.img and r.img.get("blob"): | |
| try: | |
| self._picture(p, r.img) | |
| except Exception: | |
| self._run(p, "[equation]", italic=True) | |
| return | |
| if r.omml: | |
| el = parse_xml(r.omml) | |
| if etree.QName(el).localname == "oMathPara" and inline: | |
| for om in el.iter(f"{{{NS_M}}}oMath"): | |
| p._p.append(copy.deepcopy(om)) | |
| else: | |
| p._p.append(el) | |
| elif r.latex: | |
| self._run(p, r.latex, italic=True) | |
| # ------------------------------------------------------------ block writers | |
| def _front_para(self, b: M.Block, key: str, spec_key: str, style=None): | |
| S = self.spec | |
| ex = self._ex(key, "author" if key == "affiliation" else key) | |
| p = self._para(ex, style) | |
| if ex: | |
| self._runs(p, b.runs, ex.get("rPr")) | |
| else: | |
| c = S[spec_key] | |
| self._fmt(p, align=c.get("align", "center"), indent_first=0, after=c.get("space_after_pt", 4), | |
| line=1.0) | |
| self._runs(p, b.runs, size=c.get("size"), bold=c.get("bold"), italic=c.get("italic")) | |
| return p | |
| def _abstract(self, blocks: List[M.Block]): | |
| S = self.spec["abstract"] | |
| fsz = S.get("size") | |
| ex = self._ex("abstract") | |
| layout = S.get("layout", "block") | |
| if layout == "block": | |
| hx = self._ex("abstract_heading") | |
| hp = self._para(hx) | |
| if hx: | |
| self._run(hp, S.get("heading", "Abstract"), hx.get("rPr")) | |
| else: | |
| self._fmt(hp, align="center" if self.spec["title"].get("align") == "center" else "left", | |
| indent_first=0, before=6, after=3, keep_next=True) | |
| self._run(hp, S.get("heading", "Abstract"), size=fsz, bold=S.get("label_bold", True), | |
| italic=S.get("label_italic", False)) | |
| for k, b in enumerate(blocks): | |
| p = self._para(ex) | |
| if not ex: | |
| self._fmt(p, align=S.get("align", "justify"), indent_first=0, after=3, line=None, | |
| left=S.get("indent_cm") or None) | |
| lab_rpr = ex.get("label_rPr") if ex else None | |
| body_rpr = ex.get("rPr") if ex else None | |
| if layout == "inline" and k == 0: | |
| lab = S.get("heading", "Abstract") + S.get("suffix", "—") | |
| self._run(p, lab, lab_rpr, size=None if lab_rpr else fsz, | |
| bold=None if lab_rpr else S.get("label_bold", True), | |
| italic=None if lab_rpr else S.get("label_italic", False)) | |
| if b.label: | |
| self._run(p, b.label + " – ", lab_rpr or body_rpr, size=None if (lab_rpr or body_rpr) else fsz, bold=True) | |
| self._runs(p, b.runs, body_rpr, size=None if body_rpr else fsz, | |
| bold=None if body_rpr else (S.get("body_bold") or None), | |
| italic=None if body_rpr else (S.get("body_italic") or None)) | |
| def _keywords(self, b: M.Block): | |
| K = self.spec["keywords"] | |
| ex = self._ex("keywords") | |
| p = self._para(ex) | |
| if not ex: | |
| self._fmt(p, align=self.spec["abstract"].get("align", "justify"), indent_first=0, before=3, after=6) | |
| lab_rpr = ex.get("label_rPr") if ex else None | |
| body_rpr = ex.get("rPr") if ex else None | |
| self._run(p, K.get("label", "Keywords") + K.get("suffix", ": "), lab_rpr, | |
| size=None if lab_rpr else K.get("size"), bold=None if lab_rpr else K.get("label_bold", True), | |
| italic=None if lab_rpr else K.get("label_italic", False)) | |
| self._runs(p, b.runs, body_rpr, size=None if body_rpr else K.get("size"), | |
| italic=None if body_rpr else (K.get("body_italic") or None), | |
| bold=None if body_rpr else (K.get("body_bold") or None)) | |
| def _heading(self, b: M.Block): | |
| lvl = max(1, min(b.level, 4)) | |
| ex = self._ex(*[f"h{x}" for x in range(lvl, 0, -1)]) if self.prof else None | |
| if ex and self.prof.exemplars.get(f"h{lvl}") is None: | |
| ex = None # don't reuse a higher-level look for a lower level; fall back to spec | |
| if ex: | |
| p = self._para(ex) | |
| self._run(p, b.prefix, ex.get("rPr")) | |
| self._runs(p, b.runs, ex.get("rPr")) | |
| return | |
| p = self.d.add_paragraph(style=f"Heading {lvl}") | |
| L = self.spec["headings"]["levels"][lvl - 1] | |
| self._run(p, b.prefix) | |
| self._runs(p, b.runs) | |
| def _body_para(self, b: M.Block, after_heading: bool): | |
| F = self.spec["font"] | |
| ex = self._ex("para_first", "para", "para_short") if after_heading else self._ex("para", "para_short") | |
| p = self._para(ex) | |
| if not ex: | |
| ind = F.get("first_line_indent_cm", 0) | |
| # APA/IEEE: no indent right after a heading is common; keep spec indent otherwise | |
| self._fmt(p, indent_first=0 if (after_heading and ind and self.spec["headings"]["scheme"] != "none") else ind) | |
| self._runs(p, b.runs, ex.get("rPr") if ex else None) | |
| def _list(self, b: M.Block): | |
| key = "list_number" if b.ordered else "list_bullet" | |
| ex = self._ex(key) if self.prof else None | |
| if ex: | |
| p = self._para(ex) | |
| if not ex.get("auto_num"): | |
| self._run(p, ("• " if not b.ordered else "– "), ex.get("rPr")) | |
| self._runs(p, b.runs, ex.get("rPr")) | |
| return | |
| style = "List Number" if b.ordered else "List Bullet" | |
| if self.prof is not None: | |
| try: | |
| self.d.styles[style] | |
| except KeyError: | |
| style = None | |
| p = self._para(None, style) | |
| if style is None: | |
| self._fmt(p, left=0.63, indent_first=-0.4) | |
| self._run(p, "• ") | |
| self._runs(p, b.runs) | |
| def _caption(self, b: M.Block): | |
| C = self.spec["captions"] | |
| key = f"caption_{b.cap_kind}" | |
| ex = self._ex(key, "caption_figure", "caption_table") | |
| p = self._para(ex, "Caption") | |
| lab_rpr = ex.get("label_rPr") if ex else None | |
| body_rpr = ex.get("rPr") if ex else None | |
| auto = bool(ex and ex.get("auto_num") and self.prof.exemplars.get(key)) | |
| if not ex: | |
| self._fmt(p, align=C.get("align", "center"), indent_first=0, before=4, after=6, line=1.0, | |
| keep_next=(b.cap_kind == "table" and C.get("table_position", "above") == "above")) | |
| if not auto: | |
| pre = b.prefix | |
| label_part, sep = (pre.split("\n")[0], "\n") if "\n" in pre else (pre.rstrip(), pre[len(pre.rstrip()):]) | |
| self._run(p, label_part, lab_rpr, size=None if lab_rpr else C.get("size"), | |
| bold=None if lab_rpr else C.get("label_bold", False), | |
| italic=None if lab_rpr else C.get("label_italic", False), | |
| smallcaps=True if (b.cap_kind == "table" and C.get("table_label_newline") and not lab_rpr) else None) | |
| if sep == "\n": | |
| last = p.runs[-1] if p.runs else self._run(p, "") | |
| if last is not None: | |
| last.add_break() | |
| else: | |
| self._run(p, sep, body_rpr or lab_rpr, size=None if (body_rpr or lab_rpr) else C.get("size")) | |
| sc = True if (b.cap_kind == "table" and C.get("table_caption_case") == "upper" and not body_rpr) else None | |
| self._runs(p, b.runs, body_rpr, size=None if body_rpr else C.get("size"), | |
| italic=None if body_rpr else (C.get("body_italic") or None), smallcaps=sc) | |
| def _figure(self, b: M.Block): | |
| ex = self._ex("figure") | |
| p = self._para(ex) | |
| if not ex: | |
| self._fmt(p, align="center", indent_first=0, before=6, after=3, keep_next=True, line=1.0) | |
| maxw = self._col_width_cm() / 2.54 | |
| w = min(b.width_in or maxw, maxw) | |
| data = b.image | |
| try: | |
| p.add_run().add_picture(io.BytesIO(data), width=Inches(w)) | |
| except Exception: | |
| try: | |
| from PIL import Image | |
| im = Image.open(io.BytesIO(data)) | |
| buf = io.BytesIO(); im.convert("RGB").save(buf, format="PNG"); buf.seek(0) | |
| p.add_run().add_picture(buf, width=Inches(w)) | |
| except Exception: | |
| self._run(p, "[Figure could not be converted — insert the original image here]", italic=True) | |
| self.warnings.append("A figure in an unsupported image format (e.g. EMF) must be re-inserted manually.") | |
| def _table(self, b: M.Block): | |
| T = self.spec["tables"] | |
| if b.raw and self.imp is not None: | |
| # complex table (maths, merged cells, images): keep it exactly, only fit it to the column | |
| for el in b.raw: | |
| new = self.imp.prepare(el) | |
| fit_table(new, self._col_width_cm()) | |
| self._insert(new) | |
| sp = self.d.add_paragraph() | |
| sp.paragraph_format.space_after = Pt(2) | |
| sp.paragraph_format.line_spacing = 0.6 | |
| return | |
| rows = b.rows or [[""]] | |
| ncol = max(len(r) for r in rows) | |
| t = self.d.add_table(rows=len(rows), cols=ncol) | |
| t.alignment = WD_TABLE_ALIGNMENT.CENTER | |
| if self.prof is not None and self.prof.table_tblPr: | |
| old = t._tbl.find(qn("w:tblPr")) | |
| new = parse_xml(self.prof.table_tblPr) | |
| t._tbl.replace(old, new) | |
| else: | |
| self._table_borders(t, T.get("borders", "booktabs")) | |
| tblPr = t._tbl.tblPr | |
| tw = OxmlElement("w:tblW") | |
| tw.set(qn("w:type"), "pct"); tw.set(qn("w:w"), "5000") | |
| _put(tblPr, tw, TBLPR_ORDER) | |
| for i, row in enumerate(rows): | |
| for j in range(ncol): | |
| cell = t.cell(i, j) | |
| txt = row[j] if j < len(row) else "" | |
| cp = cell.paragraphs[0] | |
| cp.paragraph_format.first_line_indent = Cm(0) | |
| cp.paragraph_format.space_before = Pt(1) | |
| cp.paragraph_format.space_after = Pt(1) | |
| cp.paragraph_format.line_spacing = 1.0 | |
| cp.alignment = WD_ALIGN_PARAGRAPH.CENTER if (i == 0 or re.fullmatch(r"[\d.,%±\-–+()\s]+", txt or "x")) else WD_ALIGN_PARAGRAPH.LEFT | |
| cell_runs = b.rows_runs[i][j] if (b.rows_runs and i < len(b.rows_runs) and j < len(b.rows_runs[i])) else None | |
| if cell_runs and any(r.is_math for r in cell_runs): | |
| self._runs(cp, cell_runs, size=T.get("size"), bold=(i == 0 and T.get("header_bold", True)) or None, | |
| honor_all=True) | |
| else: | |
| self._run(cp, txt, size=T.get("size"), bold=(i == 0 and T.get("header_bold", True)) or None) | |
| if T.get("borders", "booktabs") == "booktabs" and not (self.prof and self.prof.table_tblPr): | |
| for c in t.rows[0].cells: | |
| tcPr = c._tc.get_or_add_tcPr() | |
| bd = OxmlElement("w:tcBorders") | |
| bt = OxmlElement("w:bottom") | |
| bt.set(qn("w:val"), "single"); bt.set(qn("w:sz"), "6"); bt.set(qn("w:color"), "000000") | |
| bd.append(bt); tcPr.append(bd) | |
| # small spacer after table | |
| sp = self.d.add_paragraph() | |
| sp.paragraph_format.space_after = Pt(2) | |
| sp.paragraph_format.line_spacing = 0.6 | |
| def _table_borders(t, kind): | |
| tblPr = t._tbl.tblPr | |
| bd = OxmlElement("w:tblBorders") | |
| spec = {"top": 12, "bottom": 12} if kind == "booktabs" else \ | |
| {k: 4 for k in ("top", "left", "bottom", "right", "insideH", "insideV")} | |
| for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): | |
| e = OxmlElement(f"w:{edge}") | |
| if edge in spec: | |
| e.set(qn("w:val"), "single"); e.set(qn("w:sz"), str(spec[edge])); e.set(qn("w:color"), "000000") | |
| else: | |
| e.set(qn("w:val"), "nil") | |
| bd.append(e) | |
| _put(tblPr, bd, TBLPR_ORDER) | |
| def _equation(self, b: M.Block): | |
| if b.raw and self.imp is not None: | |
| for el in b.raw: | |
| new = self.imp.prepare(el) | |
| if etree.QName(new).localname == "tbl": | |
| fit_table(new, self._col_width_cm()) | |
| for tc in new.iter(qn("w:tc")): # number aligned with the equation | |
| tcpr = tc.find(qn("w:tcPr")) | |
| if tcpr is None: | |
| tcpr = OxmlElement("w:tcPr"); tc.insert(0, tcpr) | |
| for old in tcpr.findall(qn("w:vAlign")): | |
| tcpr.remove(old) | |
| va = OxmlElement("w:vAlign"); va.set(qn("w:val"), "center") | |
| tcpr.append(va) | |
| else: | |
| fit_equation_tabs(new, self._col_width_cm()) | |
| self._insert(new) | |
| return | |
| ex = self._ex("equation") | |
| p = self._para(ex) | |
| if not ex: | |
| self._fmt(p, align="left" if b.eq_num else "center", indent_first=0, before=4, after=4, line=1.0) | |
| if b.image and not b.runs: | |
| # equation captured from a PDF as a picture: keep its exact size (shrink only if wider than the column) | |
| col_in = self._col_width_cm() / 2.54 * (0.82 if b.eq_num else 1.0) | |
| w = min(b.width_in or col_in, col_in) | |
| pic_run = Run(img={"blob": b.image, "ext": b.image_ext, "h": (b.height_in or 0.3) * w / (b.width_in or w)}) | |
| b = M.Block(M.EQUATION, runs=[pic_run], eq_num=b.eq_num) | |
| if b.eq_num: | |
| w = self._col_width_cm() | |
| ind = p.paragraph_format.left_indent.cm if p.paragraph_format.left_indent else 0 | |
| ts = p.paragraph_format.tab_stops | |
| ts.add_tab_stop(Cm(w / 2 - ind), WD_TAB_ALIGNMENT.CENTER) | |
| ts.add_tab_stop(Cm(w - ind), WD_TAB_ALIGNMENT.RIGHT) | |
| p.paragraph_format.first_line_indent = Cm(0) | |
| p.add_run().add_tab() | |
| for r in b.runs: | |
| self._math(p, r, inline=True) | |
| p.add_run().add_tab() | |
| nr = self._run(p, f"({b.eq_num})") | |
| pics = [r for r in b.runs if r.img is not None and r.raw is None and r.omml is None] | |
| if pics and nr is not None: | |
| h_pt = max((r.img.get("h") or 0.2) for r in pics) * 72 | |
| rpr = nr._r.get_or_add_rPr() | |
| pos = OxmlElement("w:position") | |
| pos.set(qn("w:val"), str(int(max(0, h_pt / 2 - 3) * 2))) | |
| rpr.append(pos) | |
| else: | |
| for r in b.runs: | |
| self._math(p, r, inline=False) | |
| def _algorithm(self, b: M.Block): | |
| if b.raw and self.imp is not None: | |
| news = [self.imp.prepare(el) for el in b.raw] | |
| paras = [n for n in news if etree.QName(n).localname == "p"] | |
| for n in news: | |
| if etree.QName(n).localname == "tbl": | |
| fit_table(n, self._col_width_cm()) | |
| self._insert(n) | |
| if etree.QName(news[-1]).localname == "tbl": | |
| sp = self.d.add_paragraph() | |
| sp.paragraph_format.space_after = Pt(4) | |
| sp.paragraph_format.line_spacing = 0.6 | |
| if 1 < len(paras) <= 45: # keep the algorithm on one page/column | |
| for pe in paras[:-1]: | |
| ppr = pe.find(qn("w:pPr")) | |
| if ppr is None: | |
| ppr = OxmlElement("w:pPr"); pe.insert(0, ppr) | |
| if ppr.find(qn("w:keepNext")) is None: | |
| kn = OxmlElement("w:keepNext") | |
| anchor = ppr.find(qn("w:pStyle")) | |
| if anchor is not None: | |
| anchor.addnext(kn) | |
| else: | |
| ppr.insert(0, kn) | |
| return | |
| # rebuilt (PDF / LaTeX sources): ruled algorithm box | |
| F = self.spec["font"] | |
| size = max(7.0, F["size"] - 1) | |
| def rule(p, edge): | |
| ppr = p._p.get_or_add_pPr() | |
| bdr = ppr.find(qn("w:pBdr")) | |
| if bdr is None: | |
| bdr = OxmlElement("w:pBdr") | |
| _put(ppr, bdr, PPR_ORDER) | |
| e = OxmlElement(f"w:{edge}") | |
| e.set(qn("w:val"), "single"); e.set(qn("w:sz"), "8"); e.set(qn("w:space"), "1"); e.set(qn("w:color"), "000000") | |
| bdr.append(e) | |
| head = self.d.add_paragraph() | |
| self._fmt(head, align="left", indent_first=0, before=6, after=2, line=1.0, keep_next=True) | |
| if b.runs: | |
| self._runs(head, b.runs, size=size, honor_all=True) | |
| else: | |
| self._run(head, b.orig or "Algorithm", size=size, bold=True) | |
| rule(head, "top"); rule(head, "bottom") | |
| n = len(b.lines or []) | |
| for k, (lvl, runs) in enumerate(b.lines or []): | |
| p = self.d.add_paragraph() | |
| self._fmt(p, align="left", indent_first=0, before=0, after=0, line=1.0, keep_next=k < n - 1 and n <= 45, | |
| left=0.2 + 0.5 * float(lvl)) | |
| self._runs(p, runs, size=size, honor_all=True) | |
| if k == n - 1: | |
| rule(p, "bottom") | |
| sp = self.d.add_paragraph() | |
| sp.paragraph_format.space_after = Pt(4) | |
| sp.paragraph_format.line_spacing = 0.6 | |
| def _reference(self, b: M.Block): | |
| R = self.spec["references"] | |
| ex = self._ex("reference") | |
| p = self._para(ex) | |
| runs = b.runs | |
| if ex and ex.get("auto_num") and runs and re.match(r"^\s*(\[\d+\]|\d+\.)\s*", runs[0].text): | |
| runs = [Run(re.sub(r"^\s*(\[\d+\]|\d+\.)\s*", "", runs[0].text))] + runs[1:] | |
| if ex and runs and "\t" in runs[0].text and not (ex.get("pPr") and "hanging" in ex["pPr"]): | |
| runs = [Run(runs[0].text.replace("\t", " "))] + runs[1:] | |
| if not ex: | |
| h = R.get("hanging_indent_cm", 0.63) | |
| self._fmt(p, align="justify" if R.get("style") == "ieee" else "left", hanging=h, after=2, | |
| line=1.0 if self.spec["font"].get("line_spacing", 1) < 2 else None) | |
| if runs and "\t" in runs[0].text: | |
| p.paragraph_format.tab_stops.add_tab_stop(Cm(h)) | |
| self._runs(p, runs, ex.get("rPr") if ex else None, size=None if ex else R.get("size"), honor_all=True) | |
| # ------------------------------------------------------------ main | |
| def render(self, doc: M.Document) -> bytes: | |
| self.d = self._new_document() | |
| if doc.src_docx is not None: | |
| self.imp = RawImporter(doc.src_docx, self.d) | |
| S = self.spec | |
| blocks = doc.blocks | |
| front_roles = {M.TITLE, M.AUTHOR, M.AFFIL, M.OTHER_FRONT, M.ABSTRACT, M.KEYWORDS} | |
| # index where the body starts (first block not in front matter) | |
| body_start = next((k for k, b in enumerate(blocks) if b.role not in front_roles), len(blocks)) | |
| i = 0 | |
| prev_role = None | |
| while i < len(blocks): | |
| b = blocks[i] | |
| if i == body_start: | |
| self._section_break_after_front() | |
| r = b.role | |
| if r == M.TITLE: | |
| self._front_para(b, "title", "title", style="Title" if self.prof is None else None) | |
| elif r == M.AUTHOR: | |
| self._front_para(b, "author", "authors") | |
| elif r == M.AFFIL: | |
| self._front_para(b, "affiliation", "affiliation") | |
| elif r == M.OTHER_FRONT: | |
| self._front_para(b, "front", "affiliation") | |
| elif r == M.ABSTRACT: | |
| group = [] | |
| while i < len(blocks) and blocks[i].role == M.ABSTRACT: | |
| group.append(blocks[i]); i += 1 | |
| self._abstract(group) | |
| prev_role = M.ABSTRACT | |
| continue | |
| elif r == M.KEYWORDS: | |
| self._keywords(b) | |
| elif r == M.HEADING: | |
| self._heading(b) | |
| elif r == M.PARA: | |
| self._body_para(b, after_heading=prev_role == M.HEADING) | |
| elif r == M.LIST: | |
| self._list(b) | |
| elif r == M.CAPTION: | |
| self._caption(b) | |
| elif r == M.FIGURE: | |
| self._figure(b) | |
| elif r == M.TABLE: | |
| self._table(b) | |
| elif r == M.EQUATION: | |
| self._equation(b) | |
| elif r == M.ALGORITHM: | |
| self._algorithm(b) | |
| elif r == M.REFERENCE: | |
| self._reference(b) | |
| prev_role = r | |
| i += 1 | |
| if body_start >= len(blocks): | |
| pass | |
| _fix_math_order(self.d.element.body) | |
| zoom = self.d.settings.element.find(qn("w:zoom")) | |
| if zoom is not None and zoom.get(qn("w:percent")) is None: | |
| zoom.set(qn("w:percent"), "100") | |
| buf = io.BytesIO() | |
| self.d.save(buf) | |
| return buf.getvalue() | |
| def _section_break_after_front(self): | |
| pg = self.spec["page"] | |
| final = self.d.element.body.find(qn("w:sectPr")) | |
| if self.prof is not None: | |
| if self.prof.front_section: | |
| p = self.d.add_paragraph() | |
| sp = parse_xml(self.prof.front_section) | |
| p._p.get_or_add_pPr().append(sp) | |
| p.paragraph_format.space_after = Pt(0) | |
| return | |
| if int(pg.get("columns", 1)) > 1 and pg.get("front_matter_single_column", True): | |
| p = self.d.add_paragraph() | |
| p.paragraph_format.space_after = Pt(0) | |
| sp = copy.deepcopy(final) | |
| for hf in sp.findall(qn("w:headerReference")) + sp.findall(qn("w:footerReference")): | |
| sp.remove(hf) | |
| cols = sp.find(qn("w:cols")) | |
| cols.set(qn("w:num"), "1") | |
| p._p.get_or_add_pPr().append(sp) | |
| # final section continues on the same page | |
| ty = final.find(qn("w:type")) | |
| if ty is None: | |
| ty = OxmlElement("w:type") | |
| pgsz = final.find(qn("w:pgSz")) | |
| pgsz.addprevious(ty) | |
| ty.set(qn("w:val"), "continuous") | |
| def render_docx(doc: M.Document, spec: dict, profile=None): | |
| r = DocxRenderer(spec, profile) | |
| data = r.render(doc) | |
| return data, r.warnings | |