"""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")
@staticmethod
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''
''
''
'')
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''
f''
f' {esc}'))
self._fn_part._blob = etree.tostring(self._fn_root, xml_declaration=True, encoding="UTF-8", standalone=True)
p._p.append(parse_xml(f''
f''))
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
@staticmethod
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