Chose the side channel over neutralising pointer-shaped document text, because the second fix changes what every document SAYS in order to defend a tool outside the build: a source quoting a bundle listing would come out altered and existing bundles would move bytes. This reads a file the run already writes. `assets.conversion` names the pair, `DocumentAssets.conversions` carries it out of the run, `DocumentAccount.conversions` books it, and the accounting JSON states it per document. `_declared_conversions` reads it; `_conversions` now believes a pair only when the RUN booked it AND a pointer block confirms it for the asset it names. The confirmation can be forged and the ledger cannot, which is why the ledger decides. Measured through the real `okf build`: the three arms PM reproduced (two `<p>`, one `<p>` with `<br>`, a markdown note beside the carrier) go forged -> refused, 3 of 3, with the known-positive True in all three. The text-level regression guard goes 3 arms to 13, the two new ones being a perfectly written pointer block the run never booked. R761, rebuilt: 25 BMP sources, 19 held, 19 of 19 conversions confirmed against 19 declared, 50 assets (29 JPEG + 21 PNG, 0 BMP), SHY 71, u = 0, d = 0, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1045 lines
38 KiB
Python
1045 lines
38 KiB
Python
"""Content accounting for `okf build`: what the SOURCE held, and where each part went.
|
|
|
|
`okf build`'s conservation identity counts FILES. A file can be merged while
|
|
text inside it is gone, and nothing about the identity can say so. This module
|
|
adds the element level:
|
|
|
|
1. an INVENTORY per document, read from the source bytes by the format's own
|
|
rules, independent of whether extraction or the persist gate later succeed
|
|
-- so a refused document still says what it held;
|
|
2. an ACCOUNT after the run, giving every inventoried element exactly one
|
|
fate: `carried`, `pointer` or a coded rejection. What has none is
|
|
UNACCOUNTED, what has two is DOUBLE-BOOKED, and a build asked for the
|
|
account fails on either.
|
|
|
|
**The vocabulary is the gate's.** `tools/okf_accounting_gate.py` compares the
|
|
inventory here against `tools/okf_witness.py`, an implementation that imports
|
|
nothing from this package. The two implement ONE set of definitions (named per
|
|
format below) twice; agreeing on the fixtures is evidence that the definitions
|
|
are implementable as written, and disagreeing is a finding about one of them.
|
|
|
|
**`carried` is checked, not declared.** A persisted document's element is
|
|
carried when every piece of its text is found in the concept bodies written
|
|
for that document, compared on letters and digits alone (case-folded), so
|
|
markdown escapes, table pipes and whitespace do not count as loss. A container
|
|
(a table, a section, a slide) is carried when everything inside it is. An
|
|
element with no text of its own is carried with its document. This finds text
|
|
that the extractor, the segmentation, the gate or the writer lost; it cannot
|
|
find text the extractor never saw AND no inventory counts -- that is what the
|
|
per-format vocabulary bounds, and what the gate's exceptions name.
|
|
|
|
**Images** are booked from what the reader did with each placement, never
|
|
from the bundle: carried (bytes in `assets/`), `pointer` (a remote reference,
|
|
which extraction never fetches) or rejected with the reader's code. A format
|
|
whose reader does not handle images at all books an image as `pointer` when
|
|
its reference is present in the carried text (markdown), and leaves it
|
|
unaccounted otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import posixpath
|
|
import re
|
|
import zipfile
|
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
from xml.etree import ElementTree
|
|
|
|
from .inbox import DocumentAssets, InboxResult, relative_source
|
|
|
|
ACCOUNTING_VERSION = 1
|
|
|
|
CARRIED = "carried"
|
|
POINTER = "pointer"
|
|
MERGED = "merged"
|
|
REJECTED = "rejected"
|
|
PERSISTED = "persisted"
|
|
|
|
#: The reader's code for an image it deliberately did not fetch.
|
|
_REMOTE_CODE = "asset_remote"
|
|
|
|
_REMOTE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//)")
|
|
_NOT_ALNUM = re.compile(r"[\W_]+")
|
|
|
|
|
|
def _norm(text: str) -> str:
|
|
return _NOT_ALNUM.sub("", text.casefold())
|
|
|
|
|
|
# --- inventory ---------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Inventory:
|
|
"""What one source document holds, in the gate's element vocabulary.
|
|
|
|
`chunks` is the text of the document in pieces; each element names the
|
|
pieces it consists of. `refs` holds, per image element, what the source
|
|
points at: an inbox-relative path, a remote reference, or None for an
|
|
embedded picture.
|
|
"""
|
|
|
|
source_file: str
|
|
vocabulary: tuple[str, ...]
|
|
elements: list[tuple[str, tuple[int, ...]]] = field(default_factory=list)
|
|
chunks: list[str] = field(default_factory=list)
|
|
refs: list[tuple[str, str | None]] = field(default_factory=list)
|
|
|
|
def chunk(self, text: str) -> int:
|
|
self.chunks.append(text)
|
|
return len(self.chunks) - 1
|
|
|
|
def add(self, kind: str, chunks: Iterable[int] = ()) -> None:
|
|
if kind not in self.vocabulary:
|
|
raise ValueError(f"{kind!r} is not in this format's vocabulary")
|
|
self.elements.append((kind, tuple(chunks)))
|
|
|
|
def counts(self) -> dict[str, int]:
|
|
result = dict.fromkeys(self.vocabulary, 0)
|
|
for kind, _ in self.elements:
|
|
result[kind] += 1
|
|
return result
|
|
|
|
def pointed_files(self) -> set[str]:
|
|
return {target for kind, target in self.refs if kind == "local" and target}
|
|
|
|
|
|
def _resolve(inbox: Path, document: Path, ref: str, *, sts: bool) -> str | None:
|
|
"""The inbox-relative file a local reference names, contained in the
|
|
document's own directory, or None."""
|
|
directory = document.parent
|
|
tries = [ref] + ([f"graphics/{PurePosixPath(ref).name}"] if sts else [])
|
|
for candidate in tries:
|
|
normal = posixpath.normpath(candidate)
|
|
if normal.startswith(("/", "..")) or normal == ".":
|
|
continue
|
|
target = directory / normal
|
|
if target.is_file():
|
|
return relative_source(target, inbox)
|
|
return None
|
|
|
|
|
|
def _add_image(inv: Inventory, inbox: Path, document: Path, ref: str | None, sts: bool) -> None:
|
|
if ref is None:
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
return
|
|
if not ref or _REMOTE.match(ref):
|
|
inv.refs.append(("remote", ref or None))
|
|
inv.add("image", (inv.chunk(ref),) if ref else ())
|
|
return
|
|
inv.refs.append(("local", _resolve(inbox, document, ref, sts=sts)))
|
|
inv.add("image", (inv.chunk(ref),))
|
|
|
|
|
|
# markdown -- fences as the proposer reads them, so the two cannot disagree
|
|
_ATX_LINE = re.compile(r"^ {0,3}#{1,6}(?:[ \t]|$)")
|
|
_TABLE_DELIMITER = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)*\s*:?-{3,}:?\s*\|?\s*$")
|
|
_IMAGE_REF = re.compile(r"!\[[^\]]*\]\(\s*<?([^)\s>]+)>?[^)]*\)")
|
|
|
|
_MARKDOWN = ("heading", "paragraph", "table", "table_row", "image", "code_block")
|
|
|
|
|
|
def _inventory_markdown(inv: Inventory, inbox: Path, path: Path, text: str) -> None:
|
|
from .propose import fenced_lines
|
|
|
|
lines = text.split("\n")
|
|
fenced = fenced_lines(lines)
|
|
index = 0
|
|
while index < len(lines):
|
|
line = lines[index]
|
|
if index in fenced:
|
|
start = index
|
|
while index < len(lines) and index in fenced:
|
|
index += 1
|
|
inv.add("code_block", (inv.chunk("\n".join(lines[start:index])),))
|
|
continue
|
|
if not line.strip():
|
|
index += 1
|
|
continue
|
|
if _ATX_LINE.match(line):
|
|
inv.add("heading", (inv.chunk(line),))
|
|
index += 1
|
|
continue
|
|
nxt = lines[index + 1] if index + 1 < len(lines) else ""
|
|
if "|" in line and index + 1 not in fenced and _TABLE_DELIMITER.match(nxt):
|
|
header = inv.chunk(line)
|
|
index += 2
|
|
rows: list[int] = []
|
|
while index < len(lines) and index not in fenced and "|" in lines[index]:
|
|
row = inv.chunk(lines[index])
|
|
inv.add("table_row", (row,))
|
|
rows.append(row)
|
|
index += 1
|
|
inv.add("table", (header, *rows))
|
|
continue
|
|
chunks: list[int] = []
|
|
while (
|
|
index < len(lines)
|
|
and index not in fenced
|
|
and lines[index].strip()
|
|
and not _ATX_LINE.match(lines[index])
|
|
):
|
|
current = lines[index]
|
|
for ref in _IMAGE_REF.findall(current):
|
|
_add_image(inv, inbox, path, ref, sts=False)
|
|
rest = _IMAGE_REF.sub("", current)
|
|
if rest.strip():
|
|
chunks.append(inv.chunk(rest))
|
|
index += 1
|
|
if chunks:
|
|
inv.add("paragraph", chunks)
|
|
|
|
|
|
_TEXT = ("paragraph", "line")
|
|
|
|
|
|
def _inventory_text(inv: Inventory, text: str) -> None:
|
|
block: list[int] = []
|
|
for line in [*text.split("\n"), ""]:
|
|
if line.strip():
|
|
piece = inv.chunk(line)
|
|
inv.add("line", (piece,))
|
|
block.append(piece)
|
|
elif block:
|
|
inv.add("paragraph", block)
|
|
block = []
|
|
|
|
|
|
_CSV = ("header_cell", "row", "cell")
|
|
|
|
|
|
def _inventory_csv(inv: Inventory, text: str) -> None:
|
|
rows = [row for row in csv.reader(io.StringIO(text)) if row]
|
|
for position, row in enumerate(rows):
|
|
pieces = [inv.chunk(value) for value in row]
|
|
if position == 0:
|
|
for piece in pieces:
|
|
inv.add("header_cell", (piece,))
|
|
continue
|
|
for piece in pieces:
|
|
inv.add("cell", (piece,))
|
|
inv.add("row", pieces)
|
|
|
|
|
|
_JSON = ("key", "value")
|
|
|
|
|
|
def _inventory_json(inv: Inventory, text: str) -> None:
|
|
"""Fenced verbatim by the reader, so every key and value is carried exactly
|
|
when the document's own text is: each names the whole text."""
|
|
whole = (inv.chunk(text),)
|
|
stack: list[Any] = [json.loads(text)]
|
|
while stack:
|
|
node = stack.pop()
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
inv.add("key", whole)
|
|
stack.append(value)
|
|
elif isinstance(node, list):
|
|
stack.extend(node)
|
|
else:
|
|
inv.add("value", whole)
|
|
|
|
|
|
_HTML = ("cell", "heading", "image", "list_item", "paragraph", "table")
|
|
_HTML_KIND = {
|
|
"p": "paragraph",
|
|
"li": "list_item",
|
|
"table": "table",
|
|
"td": "cell",
|
|
"th": "cell",
|
|
**{f"h{n}": "heading" for n in range(1, 7)},
|
|
}
|
|
_HTML_VOID = {"img", "br", "hr", "meta", "link", "input", "col", "area", "base", "wbr", "source"}
|
|
|
|
|
|
class _HtmlInventory(HTMLParser):
|
|
def __init__(self, inv: Inventory, inbox: Path, path: Path) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.inv, self.inbox, self.path = inv, inbox, path
|
|
self.open: list[tuple[str, list[int]]] = []
|
|
self.skip = 0
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if tag == "img":
|
|
_add_image(self.inv, self.inbox, self.path, dict(attrs).get("src") or "", sts=False)
|
|
return
|
|
if tag in ("script", "style"):
|
|
self.skip += 1
|
|
if tag not in _HTML_VOID:
|
|
self.open.append((tag, []))
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag in ("script", "style") and self.skip:
|
|
self.skip -= 1
|
|
for position in range(len(self.open) - 1, -1, -1):
|
|
if self.open[position][0] == tag:
|
|
break
|
|
else:
|
|
return
|
|
while len(self.open) > position:
|
|
self._close(*self.open.pop())
|
|
|
|
def _close(self, tag: str, pieces: list[int]) -> None:
|
|
kind = _HTML_KIND.get(tag)
|
|
if kind is not None:
|
|
self.inv.add(kind, pieces)
|
|
if self.open:
|
|
self.open[-1][1].extend(pieces)
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self.skip or not data.strip():
|
|
return
|
|
if self.open:
|
|
self.open[-1][1].append(self.inv.chunk(data))
|
|
|
|
def finish(self) -> None:
|
|
self.close()
|
|
while self.open:
|
|
self._close(*self.open.pop())
|
|
|
|
|
|
# xml / sts
|
|
_STS = (
|
|
"cell",
|
|
"footnote",
|
|
"image",
|
|
"list_item",
|
|
"paragraph",
|
|
"section",
|
|
"section_label",
|
|
"table",
|
|
"table_label",
|
|
"title",
|
|
)
|
|
_STS_KIND = {
|
|
"sec": "section",
|
|
"p": "paragraph",
|
|
"table-wrap": "table",
|
|
"td": "cell",
|
|
"th": "cell",
|
|
"list-item": "list_item",
|
|
"fn": "footnote",
|
|
}
|
|
|
|
|
|
def _bare(tag: str) -> str:
|
|
return tag.rpartition("}")[2]
|
|
|
|
|
|
def _inventory_xml(inv: Inventory, inbox: Path, path: Path, data: bytes) -> None:
|
|
if b"<!DOCTYPE" in data:
|
|
raise ValueError("a DOCTYPE is not read")
|
|
root = ElementTree.fromstring(data)
|
|
sts = _bare(root.tag) == "standard" or any(_bare(e.tag) == "sec" for e in root.iter())
|
|
if not sts:
|
|
inv.vocabulary = ("element",)
|
|
inv.add("element", (inv.chunk("".join(root.itertext())),))
|
|
return
|
|
|
|
def walk(node: ElementTree.Element, parent: str) -> list[int]:
|
|
tag = _bare(node.tag)
|
|
pieces: list[int] = []
|
|
if node.text and node.text.strip():
|
|
pieces.append(inv.chunk(node.text))
|
|
if tag in ("graphic", "inline-graphic"):
|
|
href = next((v for k, v in node.attrib.items() if _bare(k) == "href"), "")
|
|
_add_image(inv, inbox, path, href, sts=True)
|
|
for child in node:
|
|
pieces.extend(walk(child, tag))
|
|
if child.tail and child.tail.strip():
|
|
pieces.append(inv.chunk(child.tail))
|
|
kind = _STS_KIND.get(tag)
|
|
if tag == "title" and parent == "sec":
|
|
kind = "title"
|
|
elif tag == "label" and parent == "sec":
|
|
kind = "section_label"
|
|
elif tag == "label" and parent == "table-wrap":
|
|
kind = "table_label"
|
|
if kind is not None:
|
|
inv.add(kind, pieces)
|
|
return pieces
|
|
|
|
walk(root, "")
|
|
|
|
|
|
# office containers
|
|
_WNS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
_ANS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
|
|
_PNS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
|
|
_SNS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
_XDRNS = "{http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing}"
|
|
_TNS = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}"
|
|
_TBNS = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}"
|
|
_DNS = "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}"
|
|
|
|
_DOCX = ("cell", "footnote", "heading", "image", "paragraph", "table")
|
|
_HEADING_STYLES = re.compile(r"(?i)^(?:heading|overskrift|title|tittel)\s*\d*$")
|
|
|
|
|
|
def _joined(node: ElementTree.Element, text_tag: str) -> str:
|
|
return "".join(t.text or "" for t in node.iter(text_tag))
|
|
|
|
|
|
def _docx_lines(para: ElementTree.Element) -> list[str]:
|
|
"""A paragraph's text, split where it breaks a line (`w:br`, `w:cr`).
|
|
|
|
A break is a line boundary in the source, and the converter keeps it one:
|
|
inside a grid-table cell the two halves land on different rows, with other
|
|
cells' text between them (measured on K2).
|
|
"""
|
|
lines = [""]
|
|
for node in para.iter():
|
|
if node.tag == f"{_WNS}t":
|
|
lines[-1] += node.text or ""
|
|
elif node.tag in (f"{_WNS}br", f"{_WNS}cr"):
|
|
lines.append("")
|
|
return lines
|
|
|
|
|
|
def _inventory_docx(inv: Inventory, data: bytes) -> None:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
body = ElementTree.fromstring(archive.read("word/document.xml"))
|
|
notes = (
|
|
ElementTree.fromstring(archive.read("word/footnotes.xml"))
|
|
if "word/footnotes.xml" in archive.namelist()
|
|
else None
|
|
)
|
|
paragraph_pieces: dict[int, list[int]] = {}
|
|
for para in body.iter(f"{_WNS}p"):
|
|
pieces = [inv.chunk(line) for line in _docx_lines(para) if line.strip()]
|
|
paragraph_pieces[id(para)] = pieces
|
|
style = para.find(f"{_WNS}pPr/{_WNS}pStyle")
|
|
if style is not None and _HEADING_STYLES.match(style.get(f"{_WNS}val", "")):
|
|
inv.add("heading", pieces)
|
|
elif pieces:
|
|
inv.add("paragraph", pieces)
|
|
for kind, tag in (("table", "tbl"), ("cell", "tc")):
|
|
for node in body.iter(f"{_WNS}{tag}"):
|
|
inv.add(
|
|
kind,
|
|
[c for p in node.iter(f"{_WNS}p") for c in paragraph_pieces.get(id(p), [])],
|
|
)
|
|
for _ in body.iter(f"{_ANS}blip"):
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
if notes is not None:
|
|
for note in notes.iter(f"{_WNS}footnote"):
|
|
if int(note.get(f"{_WNS}id", "0")) > 0:
|
|
text = _joined(note, f"{_WNS}t")
|
|
inv.add("footnote", (inv.chunk(text),) if text.strip() else ())
|
|
|
|
|
|
_PPTX = ("cell", "image", "paragraph", "slide", "table", "title")
|
|
_SLIDE = re.compile(r"ppt/slides/slide\d+\.xml")
|
|
|
|
|
|
def _inventory_pptx(inv: Inventory, data: bytes) -> None:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
slides = [
|
|
ElementTree.fromstring(archive.read(n))
|
|
for n in archive.namelist()
|
|
if _SLIDE.fullmatch(n)
|
|
]
|
|
for slide in slides:
|
|
pieces: list[int] = []
|
|
for frame in slide.iter(f"{_ANS}tbl"):
|
|
cells: list[int] = []
|
|
for cell in frame.iter(f"{_ANS}tc"):
|
|
text = _joined(cell, f"{_ANS}t")
|
|
own = (inv.chunk(text),) if text.strip() else ()
|
|
inv.add("cell", own)
|
|
cells.extend(own)
|
|
inv.add("table", cells)
|
|
pieces.extend(cells)
|
|
for shape in slide.iter(f"{_PNS}sp"):
|
|
holder = shape.find(f"{_PNS}nvSpPr/{_PNS}nvPr/{_PNS}ph")
|
|
texts = [
|
|
inv.chunk(t)
|
|
for t in (_joined(p, f"{_ANS}t") for p in shape.iter(f"{_ANS}p"))
|
|
if t.strip()
|
|
]
|
|
if holder is not None and holder.get("type") in ("title", "ctrTitle") and texts:
|
|
inv.add("title", texts)
|
|
else:
|
|
for piece in texts:
|
|
inv.add("paragraph", (piece,))
|
|
pieces.extend(texts)
|
|
for _ in slide.iter(f"{_PNS}pic"):
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
inv.add("slide", pieces)
|
|
|
|
|
|
_XLSX = ("cell", "image", "row", "sheet")
|
|
_SHEET = re.compile(r"xl/worksheets/sheet\d+\.xml")
|
|
_DRAWING = re.compile(r"xl/drawings/drawing\d+\.xml")
|
|
|
|
|
|
def _inventory_xlsx(inv: Inventory, data: bytes) -> None:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
names = archive.namelist()
|
|
shared: list[str] = []
|
|
if "xl/sharedStrings.xml" in names:
|
|
table = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
|
|
shared = ["".join(t.text or "" for t in si.iter(f"{_SNS}t")) for si in table]
|
|
sheets = [ElementTree.fromstring(archive.read(n)) for n in names if _SHEET.fullmatch(n)]
|
|
drawings = [ElementTree.fromstring(archive.read(n)) for n in names if _DRAWING.fullmatch(n)]
|
|
for sheet in sheets:
|
|
sheet_pieces: list[int] = []
|
|
for row in sheet.iter(f"{_SNS}row"):
|
|
row_pieces: list[int] = []
|
|
valued = False
|
|
for cell in row.iter(f"{_SNS}c"):
|
|
value = cell.find(f"{_SNS}v")
|
|
inline = cell.find(f"{_SNS}is")
|
|
if value is None and inline is None:
|
|
continue
|
|
valued = True
|
|
if inline is not None:
|
|
text = "".join(t.text or "" for t in inline.iter(f"{_SNS}t"))
|
|
elif cell.get("t") == "s" and value is not None and value.text is not None:
|
|
text = shared[int(value.text)]
|
|
else:
|
|
text = (value.text or "") if value is not None else ""
|
|
own = (inv.chunk(text),) if text.strip() else ()
|
|
inv.add("cell", own)
|
|
row_pieces.extend(own)
|
|
if valued:
|
|
inv.add("row", row_pieces)
|
|
sheet_pieces.extend(row_pieces)
|
|
inv.add("sheet", sheet_pieces)
|
|
for drawing in drawings:
|
|
for _ in drawing.iter(f"{_XDRNS}pic"):
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
|
|
|
|
_ODT = ("cell", "heading", "image", "list_item", "paragraph", "table")
|
|
|
|
|
|
def _inventory_odt(inv: Inventory, data: bytes) -> None:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
content = ElementTree.fromstring(archive.read("content.xml"))
|
|
pieces: dict[int, int] = {}
|
|
in_cells: set[int] = set()
|
|
for cell in content.iter(f"{_TBNS}table-cell"):
|
|
in_cells.update(id(p) for p in cell.iter(f"{_TNS}p"))
|
|
for tag in (f"{_TNS}p", f"{_TNS}h"):
|
|
for node in content.iter(tag):
|
|
text = "".join(node.itertext())
|
|
if text.strip():
|
|
pieces[id(node)] = inv.chunk(text)
|
|
|
|
def within(node: ElementTree.Element) -> list[int]:
|
|
return [pieces[id(n)] for n in node.iter() if id(n) in pieces]
|
|
|
|
for node in content.iter(f"{_TNS}h"):
|
|
inv.add("heading", within(node))
|
|
for node in content.iter(f"{_TNS}p"):
|
|
if id(node) not in in_cells and id(node) in pieces:
|
|
inv.add("paragraph", (pieces[id(node)],))
|
|
for kind, tag in (
|
|
("table", f"{_TBNS}table"),
|
|
("cell", f"{_TBNS}table-cell"),
|
|
("list_item", f"{_TNS}list-item"),
|
|
):
|
|
for node in content.iter(tag):
|
|
inv.add(kind, within(node))
|
|
for _ in content.iter(f"{_DNS}image"):
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
|
|
|
|
_RTF = ("cell", "image", "paragraph", "table_row")
|
|
_RTF_TOKEN = re.compile(
|
|
r"\\([a-zA-Z]+)(-?\d+)? ?|\\'([0-9a-fA-F]{2})|\\([^a-zA-Z])|([{}])|([^\\{}\r\n]+)|[\r\n]"
|
|
)
|
|
_RTF_SKIP = {"fonttbl", "colortbl", "stylesheet", "info", "pict", "header", "footer", "*"}
|
|
|
|
|
|
def _inventory_rtf(inv: Inventory, data: bytes) -> None:
|
|
"""Paragraph: `\\par`. Table row: `\\row`. Cell: `\\cell`. Image: `\\pict`.
|
|
|
|
The text between two such words is the element's text: `\\'hh` is read as
|
|
cp1252, `\\uN` as the code point N with its one fallback character
|
|
skipped, and destination groups (font table, pictures, ...) carry none.
|
|
"""
|
|
raw = data.decode("latin-1")
|
|
depth = 0
|
|
skip_below: int | None = None
|
|
skip_fallback = 0
|
|
buffer: list[str] = []
|
|
row: list[int] = []
|
|
for match in _RTF_TOKEN.finditer(raw):
|
|
word, number, hexa, symbol, brace, text = match.groups()
|
|
if brace == "{":
|
|
depth += 1
|
|
continue
|
|
if brace == "}":
|
|
if skip_below is not None and depth <= skip_below:
|
|
skip_below = None
|
|
depth -= 1
|
|
continue
|
|
if word == "pict":
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
if skip_below is not None:
|
|
continue
|
|
if word in _RTF_SKIP or symbol == "*":
|
|
skip_below = depth
|
|
continue
|
|
if word == "u" and number is not None:
|
|
value = int(number)
|
|
buffer.append(chr(value + 65536 if value < 0 else value))
|
|
skip_fallback = 1
|
|
continue
|
|
if hexa is not None:
|
|
if skip_fallback:
|
|
skip_fallback -= 1
|
|
else:
|
|
buffer.append(bytes([int(hexa, 16)]).decode("cp1252", errors="replace"))
|
|
continue
|
|
if text is not None:
|
|
if skip_fallback:
|
|
text = text[1:]
|
|
skip_fallback = 0
|
|
buffer.append(text)
|
|
continue
|
|
if symbol is not None:
|
|
buffer.append({"~": " ", "-": "", "_": "-"}.get(symbol, symbol))
|
|
continue
|
|
if word in ("par", "cell", "row"):
|
|
piece = inv.chunk("".join(buffer))
|
|
buffer = []
|
|
if word == "par":
|
|
inv.add("paragraph", (piece,))
|
|
elif word == "cell":
|
|
inv.add("cell", (piece,))
|
|
row.append(piece)
|
|
else:
|
|
inv.add("table_row", row)
|
|
row = []
|
|
|
|
|
|
_PDF = ("image", "page")
|
|
|
|
|
|
def _inventory_pdf(inv: Inventory, data: bytes) -> None:
|
|
"""Page: one per page. Image: one per placement on a page, as pdfplumber
|
|
lists them. A page's own text is not inventoried here: a PDF without a
|
|
structure tree declares no headings or paragraphs, which is one of the
|
|
gate's proposed exceptions, and its pages are checked through the images
|
|
they carry and the document's persist outcome alone."""
|
|
import pdfplumber
|
|
|
|
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
|
for page in pdf.pages:
|
|
inv.add("page")
|
|
for _ in page.images:
|
|
inv.refs.append(("embedded", None))
|
|
inv.add("image")
|
|
page.close()
|
|
|
|
|
|
Reader = Callable[[Inventory, Path, Path, bytes], None]
|
|
|
|
|
|
def _text_reader(function: Callable[[Inventory, str], None]) -> Reader:
|
|
def read(inv: Inventory, inbox: Path, path: Path, data: bytes) -> None:
|
|
function(inv, data.decode("utf-8-sig"))
|
|
|
|
return read
|
|
|
|
|
|
def _bytes_reader(function: Callable[[Inventory, bytes], None]) -> Reader:
|
|
def read(inv: Inventory, inbox: Path, path: Path, data: bytes) -> None:
|
|
function(inv, data)
|
|
|
|
return read
|
|
|
|
|
|
def _markdown(inv: Inventory, inbox: Path, path: Path, data: bytes) -> None:
|
|
_inventory_markdown(inv, inbox, path, data.decode("utf-8-sig"))
|
|
|
|
|
|
def _html(inv: Inventory, inbox: Path, path: Path, data: bytes) -> None:
|
|
parser = _HtmlInventory(inv, inbox, path)
|
|
parser.feed(data.decode("utf-8-sig"))
|
|
parser.finish()
|
|
|
|
|
|
_READERS: dict[str, tuple[tuple[str, ...], Reader]] = {
|
|
".md": (_MARKDOWN, _markdown),
|
|
".txt": (_TEXT, _text_reader(_inventory_text)),
|
|
".csv": (_CSV, _text_reader(_inventory_csv)),
|
|
".json": (_JSON, _text_reader(_inventory_json)),
|
|
".html": (_HTML, _html),
|
|
".htm": (_HTML, _html),
|
|
".xml": (_STS, _inventory_xml),
|
|
".docx": (_DOCX, _bytes_reader(_inventory_docx)),
|
|
".pptx": (_PPTX, _bytes_reader(_inventory_pptx)),
|
|
".xlsx": (_XLSX, _bytes_reader(_inventory_xlsx)),
|
|
".odt": (_ODT, _bytes_reader(_inventory_odt)),
|
|
".rtf": (_RTF, _bytes_reader(_inventory_rtf)),
|
|
".pdf": (_PDF, _bytes_reader(_inventory_pdf)),
|
|
}
|
|
|
|
|
|
def inventory(inbox: Path, path: Path, data: bytes | None = None) -> Inventory:
|
|
"""What the source file at `path` holds. A file of a type this module does
|
|
not inventory has an empty vocabulary; one it cannot read raises."""
|
|
reader = _READERS.get(path.suffix.lower())
|
|
inv = Inventory(relative_source(path, inbox), reader[0] if reader else ())
|
|
if reader is not None:
|
|
reader[1](inv, inbox, path, path.read_bytes() if data is None else data)
|
|
return inv
|
|
|
|
|
|
# --- the account -------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Fate:
|
|
carried: int = 0
|
|
pointer: int = 0
|
|
rejected: dict[str, int] = field(default_factory=dict)
|
|
|
|
@property
|
|
def total(self) -> int:
|
|
return self.carried + self.pointer + sum(self.rejected.values())
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
CARRIED: self.carried,
|
|
POINTER: self.pointer,
|
|
REJECTED: dict(sorted(self.rejected.items())),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class DocumentAccount:
|
|
source_file: str
|
|
status: str
|
|
code: str | None
|
|
counts: dict[str, int]
|
|
fates: dict[str, Fate]
|
|
error: str | None = None
|
|
#: The `(source digest, asset digest)` pairs for the images this run
|
|
#: REWROTE -- a BMP that reaches the bundle as a PNG, today's only case.
|
|
#: Booked because the bundle states the same pairs only as prose on a
|
|
#: pointer's second line, and measured by PM 2026-09-19 an ordinary HTML
|
|
#: document with two `<p>` elements writes exactly that prose. A reader
|
|
#: proving a conversion off the bundle text is reading the document; this
|
|
#: is the same fact written by the run.
|
|
conversions: tuple[tuple[str, str], ...] = ()
|
|
#: How many U+00AD the normalisation door removed from this document's
|
|
#: text before the persist gate saw it (operator decision 2026-09-18).
|
|
#: Booked rather than silently applied: a door that changes a source's
|
|
#: bytes and says nothing is the same class of absence this whole module
|
|
#: exists to close.
|
|
normalised_soft_hyphen: int = 0
|
|
|
|
@property
|
|
def unaccounted(self) -> dict[str, int]:
|
|
return {
|
|
kind: count - self.fates[kind].total
|
|
for kind, count in self.counts.items()
|
|
if count > self.fates[kind].total
|
|
}
|
|
|
|
@property
|
|
def double_booked(self) -> dict[str, int]:
|
|
return {
|
|
kind: self.fates[kind].total - count
|
|
for kind, count in self.counts.items()
|
|
if self.fates[kind].total > count
|
|
}
|
|
|
|
@property
|
|
def total(self) -> int:
|
|
return sum(self.counts.values())
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
entry: dict[str, Any] = {
|
|
"source_file": self.source_file,
|
|
"status": self.status,
|
|
"code": self.code,
|
|
"normalised_soft_hyphen": self.normalised_soft_hyphen,
|
|
"conversions": [{"from": before, "to": after} for before, after in self.conversions],
|
|
"inventory": dict(self.counts),
|
|
"fates": {kind: self.fates[kind].to_json() for kind in self.counts},
|
|
"unaccounted": self.unaccounted,
|
|
"double_booked": self.double_booked,
|
|
}
|
|
if self.error is not None:
|
|
entry["inventory_error"] = self.error
|
|
return entry
|
|
|
|
|
|
@dataclass
|
|
class FileAccount:
|
|
source_file: str
|
|
fate: str
|
|
code: str | None
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {"source_file": self.source_file, "fate": self.fate, "code": self.code}
|
|
|
|
|
|
@dataclass
|
|
class Accounting:
|
|
documents: list[DocumentAccount]
|
|
files: list[FileAccount]
|
|
|
|
@property
|
|
def unaccounted(self) -> int:
|
|
return sum(sum(d.unaccounted.values()) for d in self.documents) + sum(
|
|
1 for f in self.files if not f.fate
|
|
)
|
|
|
|
@property
|
|
def double_booked(self) -> int:
|
|
return sum(sum(d.double_booked.values()) for d in self.documents)
|
|
|
|
@property
|
|
def refused(self) -> int:
|
|
"""Documents the build read and persisted NOTHING of.
|
|
|
|
Their elements are booked honestly, as coded rejections, so neither
|
|
`unaccounted` nor `double_booked` moves and the loss is invisible in
|
|
the two numbers a reader looks at. A run that persisted at least one
|
|
document exits 0 -- the exit code belongs to the whole run, and a
|
|
corpus holding one unreadable file is the ordinary case -- so this
|
|
count is what keeps a partial refusal from being silent (H1).
|
|
"""
|
|
return sum(1 for d in self.documents if d.status == REJECTED)
|
|
|
|
@property
|
|
def normalised_soft_hyphen(self) -> int:
|
|
"""Soft hyphens the normalisation door removed across the whole run."""
|
|
return sum(d.normalised_soft_hyphen for d in self.documents)
|
|
|
|
@property
|
|
def images_found(self) -> int:
|
|
return sum(d.counts.get("image", 0) for d in self.documents)
|
|
|
|
@property
|
|
def elements(self) -> int:
|
|
return sum(d.total for d in self.documents)
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
"accounting_version": ACCOUNTING_VERSION,
|
|
"unaccounted": self.unaccounted,
|
|
"double_booked": self.double_booked,
|
|
"refused": self.refused,
|
|
"normalised_soft_hyphen": self.normalised_soft_hyphen,
|
|
"documents": [d.to_json() for d in self.documents],
|
|
"files": [f.to_json() for f in self.files],
|
|
}
|
|
|
|
def log_lines(self) -> list[str]:
|
|
lines = [
|
|
f"* **Accounting**: {len(self.documents)} document(s) and {len(self.files)} "
|
|
f"other file(s); {self.elements} elements found in the sources; "
|
|
f"{self.unaccounted} unaccounted, {self.double_booked} double-booked; "
|
|
f"{self.refused} of {len(self.documents)} document(s) refused whole."
|
|
]
|
|
touched = sum(1 for d in self.documents if d.normalised_soft_hyphen)
|
|
lines.append(
|
|
f"* **Normalisation**: {self.normalised_soft_hyphen} soft hyphen(s) (U+00AD) "
|
|
f"removed from {touched} of {len(self.documents)} document(s) before the persist "
|
|
"gate. No other character is touched. The count is the door's own, read off the "
|
|
"run and not recounted from the source."
|
|
)
|
|
for doc in self.documents:
|
|
if doc.status == REJECTED:
|
|
lines.append(
|
|
f"* {doc.source_file}: {doc.total} elements found in the source, 0 carried: "
|
|
f"document rejected `{doc.code}`."
|
|
)
|
|
for kind, count in sorted(doc.unaccounted.items()):
|
|
lines.append(f"* {doc.source_file}: {kind} {count} unaccounted.")
|
|
for kind, count in sorted(doc.double_booked.items()):
|
|
lines.append(f"* {doc.source_file}: {kind} {count} double-booked.")
|
|
return lines
|
|
|
|
|
|
def _bodies(paths: Sequence[Path]) -> str:
|
|
parts = []
|
|
for path in paths:
|
|
text = path.read_text(encoding="utf-8")
|
|
if text.startswith("---\n"):
|
|
end = text.find("\n---\n", 4)
|
|
text = text[end + 5 :] if end >= 0 else text
|
|
parts.append(text)
|
|
return "\n".join(parts)
|
|
|
|
|
|
#: A converter attribute block such as `{.mark}` or `{#slide-1}`. Its letters
|
|
#: stand between the words of text that WAS carried (`[Sted]{.mark}`), so it is
|
|
#: removed before comparing -- measured on K2, where a highlighted placeholder
|
|
#: otherwise read as lost.
|
|
_CONVERTER_ATTRIBUTE = re.compile(r"\{[#.][^{}\n]*\}")
|
|
|
|
|
|
class _Finder:
|
|
"""Is a piece of text in the carried text? Searched from where the last
|
|
piece was found first, because readers keep the source order."""
|
|
|
|
def __init__(self, text: str) -> None:
|
|
self.text = _norm(_CONVERTER_ATTRIBUTE.sub("", text))
|
|
self.cursor = 0
|
|
|
|
def __call__(self, piece: str) -> bool:
|
|
needle = _norm(piece)
|
|
if not needle:
|
|
return True
|
|
at = self.text.find(needle, self.cursor)
|
|
if at < 0:
|
|
at = self.text.find(needle)
|
|
if at < 0:
|
|
return False
|
|
self.cursor = at + len(needle)
|
|
return True
|
|
|
|
|
|
def _book_images(
|
|
inv: Inventory, fate: Fate, assets: DocumentAssets | None, found: Callable[[str], bool]
|
|
) -> None:
|
|
placements = [chunks for kind, chunks in inv.elements if kind == "image"]
|
|
handled = assets is not None and (assets.carried or assets.rejected)
|
|
if assets is not None and handled:
|
|
fate.carried += assets.carried
|
|
for rejection in assets.rejected:
|
|
if rejection.code == _REMOTE_CODE:
|
|
fate.pointer += 1
|
|
else:
|
|
fate.rejected[rejection.code] = fate.rejected.get(rejection.code, 0) + 1
|
|
return
|
|
# The reader did not handle images for this document: a reference kept in
|
|
# the carried text is a pointer; anything else has no fate.
|
|
for chunks in placements:
|
|
if chunks and all(found(inv.chunks[c]) for c in chunks):
|
|
fate.pointer += 1
|
|
|
|
|
|
def _persisted_account(
|
|
inv: Inventory, bodies: str, assets: DocumentAssets | None
|
|
) -> DocumentAccount:
|
|
finder = _Finder(bodies)
|
|
seen: dict[int, bool] = {}
|
|
|
|
def found(index: int) -> bool:
|
|
if index not in seen:
|
|
seen[index] = finder(inv.chunks[index])
|
|
return seen[index]
|
|
|
|
counts = inv.counts()
|
|
fates = {kind: Fate() for kind in counts}
|
|
for kind, chunks in inv.elements:
|
|
if kind == "image":
|
|
continue
|
|
if all(found(c) for c in chunks):
|
|
fates[kind].carried += 1
|
|
if "image" in fates:
|
|
_book_images(inv, fates["image"], assets, finder)
|
|
return DocumentAccount(
|
|
inv.source_file,
|
|
PERSISTED,
|
|
None,
|
|
counts,
|
|
fates,
|
|
conversions=() if assets is None else assets.conversions,
|
|
)
|
|
|
|
|
|
def _refused_account(inv: Inventory, code: str) -> DocumentAccount:
|
|
counts = inv.counts()
|
|
fates = {kind: Fate(rejected={code: n} if n else {}) for kind, n in counts.items()}
|
|
return DocumentAccount(inv.source_file, REJECTED, code, counts, fates)
|
|
|
|
|
|
def account_run(inbox: Path, walked: Sequence[Path], result: InboxResult) -> Accounting:
|
|
"""Every walked file's account after one `process_inbox` run."""
|
|
persisted = {item.source_file for item in result.persisted}
|
|
concepts: dict[str, list[Path]] = {}
|
|
for item in result.concepts:
|
|
concepts.setdefault(item.source_file, []).append(item.path)
|
|
refused = {item.source_file: item.disposition for item in result.quarantined + result.rejected}
|
|
failed = {item.source_file: item.error.code for item in result.failed}
|
|
assets = {item.source_file: item for item in result.document_assets}
|
|
carried = set(result.carried_files)
|
|
documents: list[DocumentAccount] = []
|
|
files: list[FileAccount] = []
|
|
for path in walked:
|
|
name = relative_source(path, inbox)
|
|
if path.suffix.lower() not in _READERS:
|
|
if name in carried:
|
|
files.append(FileAccount(name, CARRIED, None))
|
|
elif name in persisted:
|
|
files.append(FileAccount(name, MERGED, None))
|
|
else:
|
|
files.append(
|
|
FileAccount(name, REJECTED if name in failed else "", failed.get(name))
|
|
)
|
|
continue
|
|
try:
|
|
inv = inventory(inbox, path)
|
|
# Broad on purpose, and it diagnoses nothing: the row carries the
|
|
# exception's own type and text, never a guessed cause.
|
|
except Exception as exc:
|
|
error = f"{type(exc).__name__}: {exc}"
|
|
code = refused.get(name) or failed.get(name) or "inventory_unreadable"
|
|
documents.append(
|
|
DocumentAccount(
|
|
name,
|
|
REJECTED if name not in persisted else PERSISTED,
|
|
code,
|
|
{},
|
|
{},
|
|
error=error,
|
|
)
|
|
)
|
|
continue
|
|
if name in persisted:
|
|
documents.append(
|
|
_persisted_account(inv, _bodies(concepts.get(name, [])), assets.get(name))
|
|
)
|
|
elif name in refused:
|
|
documents.append(_refused_account(inv, refused[name]))
|
|
elif name in failed:
|
|
documents.append(_refused_account(inv, failed[name]))
|
|
else:
|
|
documents.append(
|
|
DocumentAccount(name, "", None, inv.counts(), {k: Fate() for k in inv.counts()})
|
|
)
|
|
# Read off the run, never recounted from the source: a second count would
|
|
# be a second reader, and the number this publishes has to be the number
|
|
# the normalisation door acted on. It is also the one number here with no
|
|
# independent denominator behind it, and the door acts on the EXTRACTED
|
|
# text -- so a second counter over the source bytes would disagree by
|
|
# construction on every type extraction does not carry verbatim. The
|
|
# provenance is stated in `log_lines` instead of hidden.
|
|
removed = {item.source_file: item.soft_hyphens for item in result.normalised}
|
|
for document in documents:
|
|
document.normalised_soft_hyphen = removed.get(document.source_file, 0)
|
|
return Accounting(documents, files)
|
|
|
|
|
|
def summary(accounting: Accounting) -> Mapping[str, int]:
|
|
return {
|
|
"documents": len(accounting.documents),
|
|
"files": len(accounting.files),
|
|
"elements": accounting.elements,
|
|
"unaccounted": accounting.unaccounted,
|
|
"double_booked": accounting.double_booked,
|
|
}
|