217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
"""Establishing a document from a nested tree, minting IDs, and structural checks.
|
|
|
|
Non-normative; see spec/03-nodes.md and spec/04-identity.md.
|
|
"""
|
|
|
|
import copy
|
|
|
|
from . import hashing
|
|
from .model import (
|
|
AT_MOST_ONCE, CHILDREN, KINDS, LABELLED, LEAVES, MATH_KINDS, NONEMPTY, RUN_KINDS, RUNS, STYLES,
|
|
VERBATIM_KINDS, Doc,
|
|
)
|
|
from .text import NODE_ID_RE, mint_node_id, norm
|
|
|
|
|
|
class Refusal(Exception):
|
|
"""A rule was broken. `rule` names it, as spec/22-conformance.md lists."""
|
|
|
|
def __init__(self, rule, message, node=None):
|
|
super().__init__("%s: %s" % (rule, message))
|
|
self.rule = rule
|
|
self.message = message
|
|
self.node = node
|
|
|
|
|
|
def canonical_runs(runs):
|
|
"""Drop empty text runs and join adjacent text runs with the same styles."""
|
|
out = []
|
|
for r in runs:
|
|
r = dict(r)
|
|
if r["kind"] == "text":
|
|
if "style" in r:
|
|
r["style"] = sorted(set(r["style"]))
|
|
if not r["style"]:
|
|
del r["style"]
|
|
if r["text"] == "":
|
|
continue
|
|
if out and out[-1]["kind"] == "text" and out[-1].get("style") == r.get("style"):
|
|
out[-1] = dict(out[-1], text=out[-1]["text"] + r["text"])
|
|
continue
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def _flatten(spec, parent, ord_, out):
|
|
node = {k: v for k, v in spec.items() if k != "children"}
|
|
node["_parent"] = parent
|
|
node["ord"] = ord_
|
|
out.append(node)
|
|
idx = len(out) - 1
|
|
for i, c in enumerate(spec.get("children", [])):
|
|
_flatten(c, idx, i, out)
|
|
return idx
|
|
|
|
|
|
def mint_ids(doc_key, flat, used, preassigned=None):
|
|
"""Mint IDs for flat nodes in document order. `flat` nodes carry `_parent` indices."""
|
|
preassigned = preassigned or {}
|
|
tmp = []
|
|
for i, n in enumerate(flat):
|
|
m = {k: v for k, v in n.items() if not k.startswith("_")}
|
|
m["id"] = "tmp%d" % i
|
|
m["parent"] = None if n["_parent"] is None else "tmp%d" % n["_parent"]
|
|
tmp.append(m)
|
|
_resolve_child_refs(tmp)
|
|
doc = Doc(doc_key, tmp)
|
|
seen = {}
|
|
ids = {}
|
|
for i, n in enumerate(tmp):
|
|
if i in preassigned:
|
|
ids[n["id"]] = preassigned[i]
|
|
used.add(preassigned[i])
|
|
continue
|
|
th = hashing.PLAIN(norm(hashing.plain(doc, n["id"])))
|
|
k = (n["kind"], th)
|
|
occ = seen.get(k, 0)
|
|
seen[k] = occ + 1
|
|
nid = mint_node_id(doc_key, n["kind"], th, occ)
|
|
while nid in used:
|
|
occ += 1
|
|
nid = mint_node_id(doc_key, n["kind"], th, occ)
|
|
used.add(nid)
|
|
ids[n["id"]] = nid
|
|
result = []
|
|
for n in tmp:
|
|
m = dict(n)
|
|
m["id"] = ids[n["id"]]
|
|
m["parent"] = None if n["parent"] is None else ids[n["parent"]]
|
|
if m["kind"] in RUN_KINDS:
|
|
m["content"] = [
|
|
dict(r, node=ids[r["node"]]) if r["kind"] == "equation" else r for r in m["content"]
|
|
]
|
|
result.append(m)
|
|
return result
|
|
|
|
|
|
def _resolve_child_refs(tmp):
|
|
"""Equation runs in a spec name their child by position: {"kind": "equation", "child": i}."""
|
|
by_parent = {}
|
|
for n in tmp:
|
|
by_parent.setdefault(n["parent"], []).append(n)
|
|
for n in tmp:
|
|
if n["kind"] in RUN_KINDS:
|
|
kids = sorted(by_parent.get(n["id"], []), key=lambda c: c["ord"])
|
|
runs = []
|
|
for r in n["content"]:
|
|
if r["kind"] == "equation" and "child" in r:
|
|
r = {"kind": "equation", "node": kids[r["child"]]["id"]}
|
|
runs.append(r)
|
|
n["content"] = canonical_runs(runs)
|
|
|
|
|
|
def establish(doc_key, spec, used=None):
|
|
"""Turn a nested spec into flat node records with minted IDs, in document order."""
|
|
flat = []
|
|
_flatten(copy.deepcopy(spec), None, 0, flat)
|
|
return mint_ids(doc_key, flat, used if used is not None else set())
|
|
|
|
|
|
def with_values(doc_key, nodes, hasher=hashing.PLAIN):
|
|
doc = Doc(doc_key, nodes)
|
|
vals = hashing.compute(doc, hasher)
|
|
out = []
|
|
for nid in doc.preorder():
|
|
n = dict(doc.nodes[nid])
|
|
for k in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash"):
|
|
n.pop(k, None)
|
|
n.update(vals[nid])
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
def validate_structure(doc):
|
|
"""Check the tree against the child rules of spec/03-nodes.md."""
|
|
if doc.root is None:
|
|
raise Refusal("doc.tree.root", "no root node")
|
|
roots = [x for x in doc.order if doc.nodes[x].get("parent") is None]
|
|
if len(roots) != 1 or doc.nodes[doc.root]["kind"] != "document":
|
|
raise Refusal("doc.tree.root", "exactly one root, of kind document, is required")
|
|
if doc.preorder() != doc.order:
|
|
raise Refusal("doc.tree.order", "nodes are not in document order")
|
|
seen = set()
|
|
for nid in doc.order:
|
|
n = doc.nodes[nid]
|
|
if nid in seen:
|
|
raise Refusal("doc.id.unique", "duplicate ID " + nid, nid)
|
|
seen.add(nid)
|
|
if not NODE_ID_RE.match(nid):
|
|
raise Refusal("doc.id.form", "ID is not seven letter-first base32 characters", nid)
|
|
k = n["kind"]
|
|
if k not in KINDS:
|
|
raise Refusal("doc.node.kind", "unknown kind " + k, nid)
|
|
kids = doc.kids(nid)
|
|
if [doc.nodes[c]["ord"] for c in kids] != list(range(len(kids))):
|
|
raise Refusal("doc.tree.ord", "child order is not 0..n-1", nid)
|
|
counts = {}
|
|
for i, c in enumerate(kids):
|
|
ck = doc.nodes[c]["kind"]
|
|
if ck not in CHILDREN[k]:
|
|
raise Refusal("doc.tree.child", "%s may not contain %s" % (k, ck), c)
|
|
counts[ck] = counts.get(ck, 0) + 1
|
|
if ck == "heading" and i != 0:
|
|
raise Refusal("doc.tree.child", "a heading must be a section's first child", c)
|
|
for ck, cnt in counts.items():
|
|
if cnt > 1 and (ck in AT_MOST_ONCE or ck == "heading"):
|
|
raise Refusal("doc.tree.child", "%s may occur once in %s" % (ck, k), nid)
|
|
if k == "table" and counts.get("table-body", 0) != 1:
|
|
raise Refusal("doc.tree.child", "a table has exactly one table-body", nid)
|
|
if k in NONEMPTY and not kids:
|
|
raise Refusal("doc.tree.empty", "%s must have children" % k, nid)
|
|
if n.get("label") is not None and k not in LABELLED:
|
|
raise Refusal("doc.node.label", "%s does not take a label" % k, nid)
|
|
_check_content(doc, nid)
|
|
|
|
|
|
def _check_content(doc, nid):
|
|
n = doc.nodes[nid]
|
|
k = n["kind"]
|
|
if n.get("withheld"):
|
|
if "content" in n:
|
|
raise Refusal("doc.policy.withheld", "a withheld node keeps no content", nid)
|
|
return
|
|
if k in RUN_KINDS:
|
|
runs = n.get("content")
|
|
if not isinstance(runs, list) or not runs:
|
|
raise Refusal("doc.node.content", "runs are required", nid)
|
|
if canonical_runs(runs) != runs:
|
|
raise Refusal("doc.run.canonical", "runs are not in canonical form", nid)
|
|
eq_refs = []
|
|
for r in runs:
|
|
if r["kind"] not in RUNS:
|
|
raise Refusal("doc.run.kind", "unknown run kind " + r["kind"], nid)
|
|
st = r.get("style", [])
|
|
if any(s not in STYLES for s in st) or ("sup" in st and "sub" in st):
|
|
raise Refusal("doc.run.style", "bad style set", nid)
|
|
if r["kind"] == "equation":
|
|
eq_refs.append(r["node"])
|
|
kids = doc.kids(nid)
|
|
if eq_refs != kids:
|
|
raise Refusal("doc.run.equation", "equation runs must name the sentence's children in order", nid)
|
|
elif k in MATH_KINDS:
|
|
c = n.get("content") or {}
|
|
if c.get("notation") not in ("tex", "typst") or not isinstance(c.get("source"), str):
|
|
raise Refusal("doc.node.content", "equations need a notation and a source", nid)
|
|
elif k in VERBATIM_KINDS:
|
|
c = n.get("content") or {}
|
|
if not isinstance(c.get("text"), str):
|
|
raise Refusal("doc.node.content", "verbatim nodes need text", nid)
|
|
elif "content" in n:
|
|
raise Refusal("doc.node.content", "%s holds no content" % k, nid)
|
|
if k == "section" and not isinstance(n.get("attrs", {}).get("level"), int):
|
|
raise Refusal("doc.node.attrs", "a section needs a level", nid)
|
|
|
|
|
|
def leaf_kinds():
|
|
return LEAVES
|