316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""The reader: parse ``word/document.xml`` into the typed AST.
|
|
|
|
This is the *view* builder — it walks the parsed XML tree and populates a
|
|
:class:`~doced.ast.model.Document`. It never mutates; it only reads. The
|
|
positional ids (``pN`` / ``tN`` / ``sN`` / ``cN`` / ``tcN`` / …) are assigned
|
|
in document order here, so they are stable within a single read and shift only
|
|
after structural edits (see :mod:`doced.ast.locator`).
|
|
|
|
The reader is namespace-aware (see :mod:`doced.xmlcore`) and tolerant: unknown
|
|
elements are skipped rather than raising, so a document with features we don't
|
|
model still reads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from lxml import etree as ET
|
|
|
|
from .. import xmlcore as xc
|
|
from ..package import Package, PackagePartMissing
|
|
from . import model as M
|
|
|
|
|
|
def _local(el: ET._Element) -> str:
|
|
return xc.local(el.tag)
|
|
|
|
|
|
def _xn(prefix: str, name: str) -> str:
|
|
"""Namespace-qualified attribute name, e.g. ``{w}val``."""
|
|
return f"{{{xc.NSMAP[prefix]}}}{name}"
|
|
|
|
|
|
def _opt(el: ET._Element | None, prefix: str, name: str, attr: str) -> str | None:
|
|
"""Return ``el.{prefix}:{attr}`` if ``el`` and the attr exist, else None."""
|
|
if el is None:
|
|
return None
|
|
val = el.get(_xn(prefix, attr))
|
|
return val if val else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Character formatting extraction.
|
|
# ---------------------------------------------------------------------------
|
|
def _run_format(rPr: ET._Element | None) -> dict:
|
|
"""Extract character formatting from a ``<w:rPr>`` element."""
|
|
fmt: dict = {}
|
|
if rPr is None:
|
|
return fmt
|
|
if xc.find(rPr, "w:b") is not None:
|
|
fmt["bold"] = _opt(xc.find(rPr, "w:b"), "w", "b", "val") != "false"
|
|
if xc.find(rPr, "w:i") is not None:
|
|
fmt["italic"] = True
|
|
u = xc.find(rPr, "w:u")
|
|
if u is not None and u.get(_xn("w", "val")) != "none":
|
|
fmt["underline"] = True
|
|
if xc.find(rPr, "w:strike") is not None:
|
|
fmt["strike"] = True
|
|
color = xc.find(rPr, "w:color")
|
|
col = _opt(color, "w", "color", "val")
|
|
if col and col != "auto":
|
|
fmt["color"] = col
|
|
font = xc.find(rPr, "w:rFonts")
|
|
f = _opt(font, "w", "rFonts", "ascii")
|
|
if f:
|
|
fmt["font"] = f
|
|
sz = xc.find(rPr, "w:sz")
|
|
sz_val = _opt(sz, "w", "sz", "val")
|
|
if sz_val:
|
|
fmt["size_half_points"] = int(sz_val)
|
|
return fmt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paragraph reading.
|
|
# ---------------------------------------------------------------------------
|
|
def _paragraph_runs(pEl: ET._Element) -> tuple[list[M.Run], str | None]:
|
|
"""Extract runs and the paragraph style id from a ``<w:p>`` element."""
|
|
runs: list[M.Run] = []
|
|
style = _opt(xc.find(pEl, "w:pPr"), "w", "pStyle", "val")
|
|
|
|
for child in pEl:
|
|
tag = _local(child)
|
|
if tag == "r":
|
|
run = _run_element(child)
|
|
if run is not None:
|
|
runs.append(run)
|
|
elif tag == "br":
|
|
br_type = _opt(child, "w", "br", "type")
|
|
kind = "line"
|
|
if br_type == "page":
|
|
kind = "page"
|
|
elif br_type == "column":
|
|
kind = "column"
|
|
runs.append(M.BreakRun(kind=kind))
|
|
elif tag == "tab":
|
|
runs.append(M.TabRun())
|
|
return runs, style
|
|
|
|
|
|
def _run_element(rEl: ET._Element) -> M.Run | None:
|
|
"""Build a run from a ``<w:r>`` element."""
|
|
t = xc.find(rEl, "w:t")
|
|
if t is not None and t.text:
|
|
fmt = _run_format(xc.find(rEl, "w:rPr"))
|
|
hyperlink = xc.find(rEl, "r:hyperlink")
|
|
hl_id = _opt(hyperlink, "r", "hyperlink", "id")
|
|
return M.TextRun(
|
|
text=t.text,
|
|
bold=fmt.get("bold", False),
|
|
italic=fmt.get("italic", False),
|
|
underline=fmt.get("underline", False),
|
|
strike=fmt.get("strike", False),
|
|
color=fmt.get("color"),
|
|
font=fmt.get("font"),
|
|
size_half_points=fmt.get("size_half_points"),
|
|
hyperlink_id=hl_id,
|
|
)
|
|
return None
|
|
|
|
|
|
def _paragraph_alignment(pEl: ET._Element) -> str | None:
|
|
return _opt(xc.find(pEl, "w:pPr"), "w", "jc", "val")
|
|
|
|
|
|
def _paragraph_list(pEl: ET._Element) -> tuple[int | None, str | None]:
|
|
numPr = xc.find(pEl, "w:pPr/w:numPr")
|
|
if numPr is None:
|
|
return None, None
|
|
level = _opt(numPr, "w", "ilvl", "val")
|
|
nid = _opt(numPr, "w", "numId", "val")
|
|
return int(level) if level else None, nid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Table reading.
|
|
# ---------------------------------------------------------------------------
|
|
def _table_grid(tbl: ET._Element) -> list[int]:
|
|
widths: list[int] = []
|
|
grid = xc.find(tbl, "w:tblGrid")
|
|
if grid is not None:
|
|
for col in grid.findall(xn("w", "gridCol")):
|
|
w = col.get(_xn("w", "w"))
|
|
if w:
|
|
widths.append(int(w))
|
|
return widths
|
|
|
|
|
|
def _table_cell(tc: ET._Element) -> M.TableCell:
|
|
cell = M.TableCell()
|
|
tcPr = xc.find(tc, "w:tcPr")
|
|
if tcPr is not None:
|
|
span = xc.find(tcPr, "w:gridSpan")
|
|
if span is not None and span.get(_xn("w", "val")):
|
|
cell.grid_span = int(span.get(_xn("w", "val")))
|
|
vMerge = xc.find(tcPr, "w:vMerge")
|
|
if vMerge is not None:
|
|
val = vMerge.get(_xn("w", "val"))
|
|
cell.v_merge = "restart" if val == "restart" else "continue"
|
|
shading = xc.find(tcPr, "w:shd")
|
|
if shading is not None and shading.get(_xn("w", "val")) not in (None, "clear"):
|
|
cell.shading = shading.get(_xn("w", "fill"))
|
|
tcW = xc.find(tcPr, "w:tcW")
|
|
if tcW is not None and tcW.get(_xn("w", "w")):
|
|
cell.width_twips = int(tcW.get(_xn("w", "w")))
|
|
vAlign = xc.find(tcPr, "w:vAlign")
|
|
if vAlign is not None:
|
|
cell.v_align = vAlign.get(_xn("w", "val"))
|
|
|
|
for child in tc:
|
|
if _local(child) == "p":
|
|
runs, style = _paragraph_runs(child)
|
|
para = M.Paragraph(id="", style=style, runs=runs)
|
|
cell.blocks.append(para)
|
|
return cell
|
|
|
|
|
|
def _read_table(tbl: ET._Element, idx: int) -> M.Table:
|
|
return M.Table(
|
|
id=f"t{idx}",
|
|
rows=[M.TableRow(cells=(_table_cell(tc) for tc in tbl.findall(_xn("w", "tr"))))],
|
|
grid=_table_grid(tbl),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section break reading.
|
|
# ---------------------------------------------------------------------------
|
|
def _section_break(sid: str, sectPr: ET._Element) -> M.SectionBreak:
|
|
sb = M.SectionBreak(id=sid)
|
|
for child in sectPr:
|
|
tag = _local(child)
|
|
if tag == "cols" and child.get(_xn("w", "num")):
|
|
sb.columns = int(child.get(_xn("w", "num")))
|
|
elif tag == "pgSz":
|
|
w = int(child.get(_xn("w", "w"), "0"))
|
|
h = int(child.get(_xn("w", "h"), "0"))
|
|
sb.page_orientation = "landscape" if w > h else "portrait"
|
|
elif tag == "pgMar":
|
|
sb.margin_top = _twips(child, "top")
|
|
sb.margin_right = _twips(child, "right")
|
|
sb.margin_bottom = _twips(child, "bottom")
|
|
sb.margin_left = _twips(child, "left")
|
|
return sb
|
|
|
|
|
|
def _twips(el: ET._Element, name: str) -> int | None:
|
|
val = el.get(_xn("w", name))
|
|
return int(val) if val else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Side parts.
|
|
# ---------------------------------------------------------------------------
|
|
def _read_comments(pkg: Package) -> list[M.Comment]:
|
|
if "word/comments.xml" not in pkg.parts:
|
|
return []
|
|
root = pkg.parse("word/comments.xml")
|
|
comments: list[M.Comment] = []
|
|
for c in root.findall(xn("w", "comment")):
|
|
cid = c.get(_xn("w", "id")) or f"c{len(comments)}"
|
|
author = (xc.find(c, "w:author") or ET.Element("x")).get(_xn("w", "val"), "")
|
|
date = (xc.find(c, "w:date") or ET.Element("x")).get(_xn("w", "val"))
|
|
text = "".join(t.text or "" for t in c.iter(xn("w", "t")))
|
|
comments.append(M.Comment(id=cid, author=author, date=date, text=text))
|
|
return comments
|
|
|
|
|
|
def _read_notes(pkg: Package, part: str, kind: str) -> list[M.Note]:
|
|
if part not in pkg.parts:
|
|
return []
|
|
root = pkg.parse(part)
|
|
notes: list[M.Note] = []
|
|
for n in root.findall(xn("w", kind)):
|
|
nid = n.get(_xn("w", "id")) or f"{kind[0]}{len(notes) + 1}"
|
|
notes.append(M.Note(id=nid, kind=kind, text=n.text or ""))
|
|
return notes
|
|
|
|
|
|
def _read_properties(pkg: Package) -> M.DocProperties:
|
|
if "docProps/core.xml" not in pkg.parts:
|
|
return M.DocProperties()
|
|
root = pkg.parse("docProps/core.xml")
|
|
props = M.DocProperties()
|
|
for tag, attr in (
|
|
("dc:title", "title"),
|
|
("dc:creator", "author"),
|
|
("dcterms:created", "created"),
|
|
("dcterms:modified", "modified"),
|
|
("dc:subject", "subject"),
|
|
("dc:keywords", "keywords"),
|
|
):
|
|
el = xc.find(root, tag)
|
|
if el is not None and el.text:
|
|
setattr(props, attr, el.text)
|
|
return props
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Document reading.
|
|
# ---------------------------------------------------------------------------
|
|
def _iter_body_elements(body: ET._Element) -> list[ET._Element]:
|
|
"""Return body elements in order, separating ``<w:sectPr>`` out."""
|
|
els: list[ET._Element] = []
|
|
sect_pr: ET._Element | None = None
|
|
for child in body:
|
|
if _local(child) == "sectPr":
|
|
sect_pr = child
|
|
else:
|
|
els.append(child)
|
|
return els, sect_pr
|
|
|
|
|
|
def build_document(pkg: Package) -> M.Document:
|
|
"""Parse the package into a typed :class:`M.Document`.
|
|
|
|
Raises:
|
|
PackagePartMissing: if ``word/document.xml`` is absent.
|
|
"""
|
|
if "word/document.xml" not in pkg.parts:
|
|
raise PackagePartMissing("word/document.xml not found in package")
|
|
|
|
root = pkg.parse("word/document.xml")
|
|
body = xc.find(root, "w:body")
|
|
if body is None:
|
|
body = root # degenerate document; treat root as body
|
|
|
|
doc = M.Document(path=pkg.path)
|
|
p_idx, t_idx, s_idx = 0, 0, 0
|
|
|
|
for el in body:
|
|
tag = _local(el)
|
|
if tag == "p":
|
|
runs, style = _paragraph_runs(el)
|
|
doc.blocks.append(M.Paragraph(
|
|
id=f"p{p_idx}",
|
|
style=style,
|
|
runs=runs,
|
|
alignment=_paragraph_alignment(el),
|
|
list_level=_paragraph_list(el)[0],
|
|
list_id=_paragraph_list(el)[1],
|
|
))
|
|
p_idx += 1
|
|
elif tag == "tbl":
|
|
doc.blocks.append(_read_table(el, t_idx))
|
|
t_idx += 1
|
|
|
|
# The trailing sectPr defines document-wide section properties.
|
|
sect_pr = xc.find(body, "w:sectPr")
|
|
if sect_pr is not None:
|
|
doc.blocks.append(_section_break(f"s{s_idx}", sect_pr))
|
|
|
|
doc.comments = _read_comments(pkg)
|
|
doc.footnotes = _read_notes(pkg, "word/footnotes.xml", "footnote")
|
|
doc.endnotes = _read_notes(pkg, "word/endnotes.xml", "endnote")
|
|
doc.properties = _read_properties(pkg)
|
|
return doc
|