brewdoc 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brewdoc/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Brew documents, spreadsheets and static HTML into LLM-ready Markdown."""
2
+
3
+ from brewdoc.common import ArtifactRef, BrewdocError
4
+ from brewdoc.docx import render_doc
5
+ from brewdoc.htmltext import render_html
6
+ from brewdoc.pdf import render_pdf
7
+ from brewdoc.pptx import render_presentation
8
+ from brewdoc.selfcheck import self_check
9
+ from brewdoc.service import run
10
+ from brewdoc.sheets import (CellFormula, FormulaArtifact, list_book_artifacts, read_book_artifact,
11
+ read_formulas, render_book)
12
+
13
+ __version__ = "0.2.0"
14
+ __all__ = ["ArtifactRef", "BrewdocError", "CellFormula", "FormulaArtifact", "__version__",
15
+ "list_book_artifacts", "read_book_artifact", "read_formulas", "render_book",
16
+ "render_doc", "render_html", "render_pdf", "render_presentation", "run", "self_check"]
brewdoc/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """Console entry point: `brewdoc <document> [--out FILE]` or `brewdoc --self-check`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from brewdoc.common import BrewdocError
11
+ from brewdoc.selfcheck import self_check
12
+ from brewdoc.service import EXIT_FAIL, EXIT_OK, EXIT_USAGE, SUFFIXES, _line, _route, run
13
+
14
+ RENDERED_BY = "brewdoc"
15
+
16
+
17
+ def _artifact_assignments(assignments) -> dict[str, str] | None:
18
+ """Turn repeated CLI KEY=PATH values into the `run` mapping; refuse bad or repeated keys."""
19
+ if assignments is None:
20
+ return None
21
+ mapping = {}
22
+ for assignment in assignments:
23
+ key, separator, path = assignment.partition("=")
24
+ if not (separator and key and path):
25
+ raise BrewdocError("malformed artifact assignment: %s" % assignment)
26
+ if key in mapping:
27
+ raise BrewdocError("duplicate artifact key: %s" % key)
28
+ mapping[key] = path
29
+ return mapping
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ """Build the CLI parser for document, sheet, artifact, and self-check requests."""
34
+ parser = argparse.ArgumentParser(
35
+ prog=RENDERED_BY,
36
+ description="Render a PDF, a Word document, a presentation, a spreadsheet or static HTML as "
37
+ "deterministic, sanitised, LLM-readable "
38
+ "Markdown. PDF regions route by their own ruling edges: a lined table becomes "
39
+ "a Markdown table, an unlined captioned table is cropped then read, other "
40
+ "regions become fixed-width or plain text, and two-column prose is cropped at "
41
+ "the gutter. A spreadsheet becomes one anchored '## Sheet N: \"<name>\"' "
42
+ "section per sheet, using only the --sheet names when given. A .docx becomes "
43
+ "one anchored '## Chapter N: \"<heading>\"' section per Word heading and "
44
+ "keeps its tables. A .pptx becomes one anchored "
45
+ "'## Slide N: \"<title>\"' section per declared slide. Static .html becomes one "
46
+ "chapter and requires CPython 3.12-3.14 with selectolax 0.4.13. Other runtimes "
47
+ "explicitly refuse HTML; a missing or broken parser on supported CPython "
48
+ "fails HTML conversion and self-check.",
49
+ epilog="Both paths may be absolute or relative; a relative one is resolved against the "
50
+ "current working directory, never against this script's location, and --out "
51
+ "creates its parent directories. "
52
+ "Reads %s. A document with no text layer is REFUSED by name - there is no OCR here. "
53
+ "One JSON route line always goes to stdout: it names the route, what was rendered, "
54
+ "every sanitised item dropped, and what this route structurally cannot carry. "
55
+ "Without --out the Markdown follows that line on stdout."
56
+ % SUFFIXES)
57
+ parser.add_argument("document", nargs="?",
58
+ help="the .pdf, .docx, .pptx, .html or spreadsheet to render")
59
+ parser.add_argument("--out", help="write Markdown here (HTML UTF-8, other formats ASCII); default stdout")
60
+ parser.add_argument("--sheet", action="append",
61
+ help="render only this exact workbook sheet; repeat for caller order")
62
+ parser.add_argument("--artifact", action="append",
63
+ help="write one listed workbook artifact as KEY=PATH; repeat as needed")
64
+ parser.add_argument("--self-check", action="store_true", dest="self_check",
65
+ help="render synthetic fixtures twice and compare; exits 0 when green")
66
+ return parser
67
+
68
+
69
+ def main(argv=None) -> int:
70
+ """Command-line entry: prints the JSON receipt, then the Markdown unless `--out` was given."""
71
+ parser = build_parser()
72
+ args = parser.parse_args(argv)
73
+ if args.self_check:
74
+ return self_check()
75
+ if not args.document:
76
+ parser.print_usage()
77
+ return EXIT_USAGE
78
+ try:
79
+ artifact_outputs = _artifact_assignments(args.artifact)
80
+ except BrewdocError as exc:
81
+ rc, line, markdown = EXIT_FAIL, _line(
82
+ _route(Path(args.document)), False, str(exc), args.document), ""
83
+ else:
84
+ rc, line, markdown = run(
85
+ args.document, args.out, sheets=args.sheet, artifact_outputs=artifact_outputs)
86
+ print(json.dumps(line, ensure_ascii=True, sort_keys=True))
87
+ if rc == EXIT_OK and not args.out:
88
+ if line["route"] == "html" and hasattr(sys.stdout, "buffer"):
89
+ sys.stdout.flush()
90
+ sys.stdout.buffer.write(markdown.encode("utf-8"))
91
+ else:
92
+ sys.stdout.write(markdown)
93
+ return rc
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())
brewdoc/common.py ADDED
@@ -0,0 +1,405 @@
1
+ """Shared layer: error type, tally, ASCII normalization and schema 2 Markdown assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import datetime
7
+ import hashlib
8
+ import posixpath
9
+ import re
10
+ import unicodedata
11
+ import xml.etree.ElementTree as ET
12
+ import zipfile
13
+ from collections.abc import Callable, Iterator
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ MARKDOWN_SCHEMA = "brewdoc.markdown/2"
18
+ # Receipt versions on its own: its key set moves when a format lands, the Markdown schema does not.
19
+ RECEIPT_SCHEMA = "brewdoc.receipt/1"
20
+
21
+ CID_RE = re.compile(r"\(cid:\d+\)")
22
+ FOLD = {
23
+ "‐": "-", "‑": "-", "‒": "-", "–": "-", "—": "-", "―": "-",
24
+ "‘": "'", "’": "'", "‚": "'", "‛": "'",
25
+ "“": '"', "”": '"', "„": '"', "′": "'", "″": '"',
26
+ "•": "-", "‣": "-", "▪": "-", "■": "-", "●": "-", "·": "-",
27
+ "…": "...", "≤": "<=", "≥": ">=", "≠": "!=", "±": "+/-",
28
+ "×": "x", "÷": "/", "°": " deg", "®": "(R)", "©": "(C)",
29
+ "™": "(TM)", "€": "EUR", "£": "GBP", "²": "2", "³": "3",
30
+ "½": "1/2", "¼": "1/4", "¾": "3/4", "→": "->", "←": "<-",
31
+ # Shade blocks are the only content of a spreadsheet gantt bar; `?` would erase it.
32
+ "░": "#", "▒": "#", "▓": "#", "█": "#",
33
+ " ": " ", " ": " ", " ": " ", " ": " ", "": "",
34
+ }
35
+ LIGATURES = {"ff": "ff", "fi": "fi", "fl": "fl", "ffi": "ffi", "ffl": "ffl",
36
+ "ſt": "st", "st": "st"}
37
+ # A broken ToUnicode CMap seen in the wild maps `ff` to U+0161 inside an otherwise ASCII word.
38
+ BROKEN_LIGATURE = "š"
39
+ BROKEN_WORD_RE = re.compile(r"[A-Za-z]+%s[A-Za-z]*|[A-Za-z]*%s[A-Za-z]+"
40
+ % (BROKEN_LIGATURE, BROKEN_LIGATURE))
41
+
42
+ DROP_KEYS = ("control_chars", "soft_hyphens", "nbsp", "pua_glyphs", "cid_survivors",
43
+ "ligatures", "running_heads", "page_numbers", "non_ascii_replaced")
44
+
45
+ DIGITS_RE = re.compile(r"\d+")
46
+
47
+
48
+ class BrewdocError(Exception):
49
+ """A refusal naming what was not found or not parsed, and where."""
50
+
51
+
52
+ @contextlib.contextmanager
53
+ def reading(path: Path, noun: str) -> Iterator[None]:
54
+ """Every parser error becomes one `<noun> unreadable` refusal; a BrewdocError passes through."""
55
+ try:
56
+ yield
57
+ except BrewdocError:
58
+ raise
59
+ except Exception as exc: # each reader raises its own error types
60
+ raise BrewdocError("%s unreadable: %s: %s" % (noun, path, exc)) from exc
61
+
62
+
63
+ def _local_name(tag: str) -> str:
64
+ return tag.rsplit("}", 1)[-1]
65
+
66
+
67
+ def _xml_part(package: zipfile.ZipFile, part: str) -> ET.Element:
68
+ """Read one required OOXML part and name missing or malformed XML."""
69
+ try:
70
+ data = package.read(part)
71
+ except KeyError as exc:
72
+ raise BrewdocError("OOXML part is missing: %s" % part) from exc
73
+ try:
74
+ return ET.fromstring(data)
75
+ except ET.ParseError as exc:
76
+ raise BrewdocError("cannot parse OOXML part %s: %s" % (part, exc)) from exc
77
+
78
+
79
+ def _resolve_ooxml_part(base: str, target: str) -> str:
80
+ """Resolve one internal relationship target without leaving the package root."""
81
+ if not target:
82
+ raise BrewdocError("OOXML relationship target is missing")
83
+ # join keeps an absolute target whole; OPC absolute part names start at the package root.
84
+ joined = posixpath.join(posixpath.dirname(base), target).removeprefix("/")
85
+ resolved = posixpath.normpath(joined)
86
+ if resolved in (".", "..") or resolved.startswith("../"):
87
+ raise BrewdocError("OOXML relationship target leaves the package: %s" % target)
88
+ return resolved
89
+
90
+
91
+ def _relationship_elements(package: zipfile.ZipFile, part: str) -> tuple[ET.Element, ...]:
92
+ """Return every relationship element in document order, duplicate ids and types included."""
93
+ root = _xml_part(package, part)
94
+ return tuple(element for element in root.iter()
95
+ if _local_name(element.tag) == "Relationship")
96
+
97
+
98
+ def _relationships(package: zipfile.ZipFile, part: str) -> dict[str, ET.Element]:
99
+ return {element.attrib["Id"]: element for element in _relationship_elements(package, part)
100
+ if "Id" in element.attrib}
101
+
102
+
103
+ def _relationship_part(part: str) -> str:
104
+ """Return the OPC relationship part paired with one package part."""
105
+ directory, name = posixpath.split(part)
106
+ return posixpath.join(directory, "_rels", name + ".rels")
107
+
108
+
109
+ def _opc_main_part(package: zipfile.ZipFile) -> str:
110
+ """Find a package's main part through its package-level office relationship."""
111
+ for relationship in _relationships(package, "_rels/.rels").values():
112
+ if (relationship.attrib.get("Type", "").endswith("/officeDocument")
113
+ and relationship.attrib.get("TargetMode") != "External"):
114
+ return _resolve_ooxml_part("", relationship.attrib.get("Target", ""))
115
+ raise BrewdocError("OOXML office document relationship is missing")
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class ArtifactRef:
120
+ """A retrievable workbook artifact described without retaining its payload."""
121
+
122
+ key: str
123
+ kind: str
124
+ availability: str
125
+ count: int | None
126
+ location: str
127
+ media_type: str
128
+ byte_size: int | None = None
129
+ sha256: str | None = None
130
+
131
+ def to_dict(self, out=None) -> dict:
132
+ """Return the stable receipt fields for this artifact."""
133
+ return {"availability": self.availability, "byte_size": self.byte_size,
134
+ "count": self.count, "key": self.key, "kind": self.kind,
135
+ "location": self.location, "media_type": self.media_type,
136
+ "out": str(out) if out is not None else None, "sha256": self.sha256}
137
+
138
+
139
+ # The only mapping from a route's unit kind to its tally counter; `_assemble` derives the count.
140
+ UNIT_COUNTERS = {"page": "pages", "sheet": "sheets", "chapter": "chapters",
141
+ "slide": "slides"}
142
+
143
+
144
+ def new_tally() -> dict:
145
+ """An empty render tally: counts of units, tables, regions and every dropped class."""
146
+ return {**dict.fromkeys(UNIT_COUNTERS.values(), 0), "tables": 0, "text_regions": 0,
147
+ "columns_split": 0, "broken_ligature_words": 0,
148
+ "dropped": dict.fromkeys(DROP_KEYS, 0)}
149
+
150
+
151
+ def sha256(text: str) -> str:
152
+ """Hex sha256 of a UTF-8 string."""
153
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
154
+
155
+
156
+ def sanitise(text: str, tally: dict, keep_layout: bool = False) -> str:
157
+ """ASCII out, every dropped class counted; `keep_layout` preserves fixed-width alignment."""
158
+ drop = tally["dropped"]
159
+ tally["broken_ligature_words"] += len(BROKEN_WORD_RE.findall(text))
160
+ drop["cid_survivors"] += len(CID_RE.findall(text))
161
+ text = CID_RE.sub("", text)
162
+ out = []
163
+ for ch in text:
164
+ if ch in LIGATURES:
165
+ drop["ligatures"] += 1
166
+ out.append(LIGATURES[ch])
167
+ continue
168
+ if ch == "­":
169
+ drop["soft_hyphens"] += 1
170
+ continue
171
+ if ch == " ":
172
+ drop["nbsp"] += 1
173
+ out.append(" ")
174
+ continue
175
+ if ch in FOLD:
176
+ out.append(FOLD[ch])
177
+ continue
178
+ if ch in "\n\t" or " " <= ch <= "~":
179
+ out.append(ch)
180
+ continue
181
+ category = unicodedata.category(ch)
182
+ if category == "Co": # Symbol/Wingdings bullets live in the PUA
183
+ drop["pua_glyphs"] += 1
184
+ out.append("-")
185
+ continue
186
+ if category in ("Cc", "Cf", "Zl", "Zp"):
187
+ drop["control_chars"] += 1
188
+ continue
189
+ folded = "".join(c for c in unicodedata.normalize("NFKD", ch)
190
+ if not unicodedata.combining(c))
191
+ if folded.isascii() and folded.strip():
192
+ out.append(folded)
193
+ continue
194
+ drop["non_ascii_replaced"] += 1
195
+ out.append("?")
196
+ text = "".join(out)
197
+ if not keep_layout:
198
+ text = re.sub(r"[ \t]{2,}", " ", text)
199
+ return "\n".join(line.rstrip() for line in text.split("\n"))
200
+
201
+
202
+ def cell_text(value) -> str:
203
+ """One cell as text; calamine returns '' for an empty cell and a float for every number."""
204
+ if value is None:
205
+ return ""
206
+ if isinstance(value, bool):
207
+ return "true" if value else "false"
208
+ if isinstance(value, (datetime.datetime, datetime.date)):
209
+ return value.isoformat()[:10]
210
+ if isinstance(value, (datetime.time, datetime.timedelta)):
211
+ return str(value)
212
+ if isinstance(value, float):
213
+ return repr(value)
214
+ return str(value).replace("\n", " ").strip()
215
+
216
+
217
+ def markdown_table(rows) -> list[str]:
218
+ """Rows -> a Markdown table, first row as the header; ragged rows are padded, never cut."""
219
+ # The only place a cell's pipe is escaped, so no caller can escape it twice.
220
+ width = max((len(row) for row in rows), default=0)
221
+ if not width:
222
+ return []
223
+ out = []
224
+ for index, row in enumerate(rows):
225
+ cells = [cell_text(value).replace("|", "\\|") for value in row] + [""] * (width - len(row))
226
+ out.append("| " + " | ".join(cells) + " |")
227
+ if index == 0:
228
+ out.append("| " + " | ".join(["---"] * width) + " |")
229
+ return out
230
+
231
+
232
+ def head_key(text: str) -> str:
233
+ """A running head's identity without its folio (`Page 31 of 232` never repeats verbatim)."""
234
+ return DIGITS_RE.sub("#", text)
235
+
236
+
237
+ def _drop_furniture(lines: list[str], heads: set[str], numbers: set[str],
238
+ tally: dict) -> list[str]:
239
+ kept = []
240
+ for line in lines:
241
+ text = line.strip()
242
+ if text in numbers:
243
+ tally["dropped"]["page_numbers"] += 1
244
+ elif head_key(text) in heads:
245
+ tally["dropped"]["running_heads"] += 1
246
+ else:
247
+ kept.append(line)
248
+ return kept
249
+
250
+
251
+ # Punctuation gets a backslash, HTML specials and controls an entity; non-ASCII follows in encode.
252
+ # No pipe here: outside a table it is literal, and inside one `markdown_table` escapes it.
253
+ MARKDOWN_ESCAPES = str.maketrans({
254
+ **{character: "\\" + character for character in "\\`*_{}[]()#+-.!"},
255
+ **{chr(code): "&#%d;" % code for code in (*range(32), 127)},
256
+ "\r": " ", "\n": " ", "\t": " ", "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"})
257
+
258
+
259
+ def _escape_markdown_text(text: str) -> str:
260
+ """Preserve semantic text as ASCII without exposing Markdown or HTML syntax."""
261
+ escaped = str(text).translate(MARKDOWN_ESCAPES)
262
+ return escaped.encode("ascii", "xmlcharrefreplace").decode("ascii")
263
+
264
+
265
+ def _unit_key(kind: str, ordinal: int) -> str:
266
+ return "%s/%06d" % (kind, ordinal)
267
+
268
+
269
+ def _unit_anchor(key: str) -> str:
270
+ return "brewdoc-" + key.replace("/", "-")
271
+
272
+
273
+ def _unit_heading(kind: str, ordinal: int, label: str) -> str:
274
+ if kind == "page":
275
+ return "Page %d" % ordinal
276
+ title = _escape_markdown_text(label)
277
+ return "%s %d: \"%s\"" % (kind.title(), ordinal, title)
278
+
279
+
280
+ def _escape_source_html(line: str) -> str:
281
+ """Keep every source HTML-like fragment visible instead of active."""
282
+ return line.replace("<", "&lt;").replace(">", "&gt;")
283
+
284
+
285
+ def _literal_fence(code: list[str]) -> list[str]:
286
+ """Fence source code verbatim; the fence outgrows every backtick run so none can close it."""
287
+ longest = max((len(run) for line in code for run in re.findall("`+", line)), default=0)
288
+ fence = "`" * max(3, longest + 1)
289
+ return [fence, *code, fence]
290
+
291
+
292
+ def _render_units(kind: str, units: list[tuple[int, str, list[tuple]]],
293
+ heads: set[str], numbers: set[str], tally: dict) -> tuple[str, tuple[str, ...]]:
294
+ """Render anchored content units before metadata so all loss tallies are final."""
295
+ out = []
296
+ keys = []
297
+ for ordinal, label, blocks in units:
298
+ key = _unit_key(kind, ordinal)
299
+ keys.append(key)
300
+ out += ['<a id="%s"></a>' % _unit_anchor(key),
301
+ "## " + _unit_heading(kind, ordinal, label), ""]
302
+ for block_kind, payload in blocks:
303
+ if block_kind == "table":
304
+ rendered = markdown_table(payload)
305
+ elif block_kind == "math":
306
+ rendered = payload
307
+ else:
308
+ rendered = _drop_furniture(payload, heads, numbers, tally)
309
+ if block_kind == "block" and len(rendered) <= 2:
310
+ rendered = [line for line in rendered if line != "```"]
311
+ if rendered and block_kind in ("block", "math"):
312
+ out += _literal_fence(rendered[1:-1]) + [""]
313
+ elif rendered:
314
+ out += [_escape_source_html(line) for line in rendered] + [""]
315
+ body = "\n".join(out).rstrip("\n") + "\n"
316
+ return body, tuple(keys)
317
+
318
+
319
+ # Every private renderer returns (Markdown, tally, unit keys, artifact references).
320
+ Rendered = tuple[str, dict, tuple[str, ...], tuple[ArtifactRef, ...]]
321
+
322
+
323
+ @dataclass(frozen=True, slots=True)
324
+ class Route:
325
+ """One adapter's whole contract: each adapter declares exactly one, the service folds them."""
326
+
327
+ name: str
328
+ unit_kind: str
329
+ suffixes: tuple[str, ...]
330
+ render: Callable[..., Rendered] | None
331
+ not_carried: tuple[str, ...]
332
+
333
+
334
+ CAPABILITY_FIELDS = ("Formula capability", "Selected formula count", "VBA project capability",
335
+ "VBA project count", "VBA source module capability", "VBA source module count")
336
+ NOT_APPLICABLE = tuple((field, "not applicable") for field in CAPABILITY_FIELDS)
337
+
338
+
339
+ def _metadata_rows(path: Path, route: str, unit_kind: str, source_units: int,
340
+ unit_keys: tuple[str, ...], body: str, tally: dict) -> list[tuple[str, str]]:
341
+ source, suffix = path.read_bytes(), path.suffix.lower()
342
+ body_bytes = body.encode("utf-8" if route == "html" else "ascii")
343
+ rows = [
344
+ ("Markdown schema", MARKDOWN_SCHEMA),
345
+ ("Source name", '"%s"' % _escape_markdown_text(path.name)),
346
+ ("Source suffix", suffix[:1] + _escape_markdown_text(suffix[1:])),
347
+ ("Source bytes", str(len(source))),
348
+ ("Source SHA-256", hashlib.sha256(source).hexdigest()),
349
+ ("Route", route),
350
+ ("Unit kind", unit_kind),
351
+ ("Source unit count", str(source_units)),
352
+ ("Rendered unit count", str(len(unit_keys))),
353
+ ("Ordered selection keys", ", ".join(unit_keys) or "none"),
354
+ ("Rendered body bytes", str(len(body_bytes))),
355
+ ("Rendered body SHA-256", hashlib.sha256(body_bytes).hexdigest()),
356
+ ("Pages", str(tally["pages"])),
357
+ ("Sheets", str(tally["sheets"])),
358
+ ("Chapters", str(tally["chapters"])),
359
+ ("Tables", str(tally["tables"])),
360
+ ("Text regions", str(tally["text_regions"])),
361
+ ("Column splits", str(tally["columns_split"])),
362
+ ("Broken ligature words", str(tally["broken_ligature_words"])),
363
+ ]
364
+ return rows + [("Dropped " + key.replace("_", " "), str(tally["dropped"][key]))
365
+ for key in DROP_KEYS]
366
+
367
+
368
+ def _artifacts_markdown(artifacts: tuple[ArtifactRef, ...]) -> list[str]:
369
+ if not artifacts:
370
+ return ["None."]
371
+ rows = [["Key", "Kind", "Availability", "Count", "Location", "Media type", "Bytes",
372
+ "SHA-256"]]
373
+ for artifact in artifacts:
374
+ label = "metadata" if artifact.kind == "vba-project" else artifact.key.split("/", 1)[1]
375
+ location = "[%s](%s)" % (label, artifact.location)
376
+ rows.append([artifact.key, artifact.kind, artifact.availability,
377
+ str(artifact.count) if artifact.count is not None else "unknown",
378
+ location, artifact.media_type,
379
+ str(artifact.byte_size) if artifact.byte_size is not None else "not applicable",
380
+ artifact.sha256 or "not applicable"])
381
+ return markdown_table(rows)
382
+
383
+
384
+ def _assemble(path: Path, route: str, unit_kind: str, source_units: int,
385
+ units: list[tuple[int, str, list[tuple]]], tally: dict, *,
386
+ not_carried: tuple[str, ...], heads=frozenset(), numbers=frozenset(),
387
+ capabilities: tuple = NOT_APPLICABLE,
388
+ artifacts: tuple[ArtifactRef, ...] = ()) -> Rendered:
389
+ """Build schema 2 once content and loss counters are final; every route returns this shape."""
390
+ if unit_kind not in UNIT_COUNTERS:
391
+ raise BrewdocError("unknown unit kind %r; expected one of: %s"
392
+ % (unit_kind, ", ".join(UNIT_COUNTERS)))
393
+ body, unit_keys = _render_units(unit_kind, units, heads, numbers, tally)
394
+ tally[UNIT_COUNTERS[unit_kind]] = len(unit_keys)
395
+ metadata = _metadata_rows(path, route, unit_kind, source_units, unit_keys, body, tally)
396
+ out = ['# "%s"' % _escape_markdown_text(path.name), "",
397
+ '<a id="brewdoc-metadata"></a>', "## Metadata", ""]
398
+ out += markdown_table([["Field", "Value"], *metadata, *capabilities])
399
+ out += ["", "## Artifacts", ""] + _artifacts_markdown(artifacts) + ["", "## Known omissions", ""]
400
+ out += ["- " + _escape_markdown_text(item) for item in not_carried]
401
+ out += ["", '<a id="brewdoc-contents"></a>', "## Contents", ""]
402
+ out += ["- [%s](#%s)" % (_unit_heading(unit_kind, ordinal, label), _unit_anchor(
403
+ _unit_key(unit_kind, ordinal))) for ordinal, label, _blocks in units]
404
+ out += ["", body.rstrip("\n")]
405
+ return "\n".join(out).rstrip("\n") + "\n", tally, unit_keys, artifacts
brewdoc/docx.py ADDED
@@ -0,0 +1,131 @@
1
+ """DOCX adapter: OPC main part paragraphs and tables in reading order, stdlib only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from brewdoc.common import (BrewdocError, Rendered, Route, _assemble, _opc_main_part, _xml_part,
10
+ new_tally, reading, sanitise)
11
+
12
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
13
+ W_STRICT = "{http://purl.oclc.org/ooxml/wordprocessingml/main}"
14
+ W_NAMESPACES = (W, W_STRICT)
15
+ DOC_BODY = "Body" # the chapter of a document that declares no headings
16
+ HEADING_STYLE_RE = re.compile(r"(?:Heading[1-9]|Title)")
17
+
18
+
19
+ def _word_tag(element, local_name: str) -> bool:
20
+ return any(element.tag == namespace + local_name for namespace in W_NAMESPACES)
21
+
22
+
23
+ def _word_child(element, local_name: str):
24
+ return next((child for child in element if _word_tag(child, local_name)), None)
25
+
26
+
27
+ def _word_value(element) -> str | None:
28
+ return next((element.get(namespace + "val") for namespace in W_NAMESPACES
29
+ if element.get(namespace + "val") is not None), None)
30
+
31
+
32
+ def _doc_lines(node, tally: dict) -> list[str]:
33
+ # Breaks and tabs are siblings of text; tracked text and references use other tags.
34
+ lines, current = [], []
35
+ for element in node.iter():
36
+ if _word_tag(element, "t"):
37
+ current.append(element.text or "")
38
+ elif _word_tag(element, "tab"):
39
+ current.append(" ")
40
+ elif _word_tag(element, "br") or _word_tag(element, "cr"):
41
+ lines.append("".join(current))
42
+ current = []
43
+ lines.append("".join(current))
44
+ return [text for text in (sanitise(line, tally).strip() for line in lines) if text]
45
+
46
+
47
+ def _doc_label(node) -> str:
48
+ """Read a heading label without folding semantic Unicode before Markdown escaping."""
49
+ parts = []
50
+ for element in node.iter():
51
+ if _word_tag(element, "t"):
52
+ parts.append(element.text or "")
53
+ elif (_word_tag(element, "tab") or _word_tag(element, "br")
54
+ or _word_tag(element, "cr")):
55
+ parts.append(" ")
56
+ return re.sub(r"\s+", " ", "".join(parts)).strip() or "Untitled"
57
+
58
+
59
+ def _doc_style(node) -> str:
60
+ properties = _word_child(node, "pPr")
61
+ style = _word_child(properties, "pStyle") if properties is not None else None
62
+ return (_word_value(style) or "") if style is not None else ""
63
+
64
+
65
+ def _doc_span(cell) -> int:
66
+ properties = _word_child(cell, "tcPr")
67
+ span = _word_child(properties, "gridSpan") if properties is not None else None
68
+ try:
69
+ return max(1, int(_word_value(span))) if span is not None else 1
70
+ except (TypeError, ValueError):
71
+ return 1
72
+
73
+
74
+ def _doc_rows(table, tally: dict) -> list[list[str]]:
75
+ rows = []
76
+ for row in table:
77
+ if not _word_tag(row, "tr"):
78
+ continue
79
+ cells = []
80
+ for cell in row:
81
+ if not _word_tag(cell, "tc"):
82
+ continue
83
+ cells.append(" ".join(_doc_lines(cell, tally)))
84
+ cells += [""] * (_doc_span(cell) - 1)
85
+ if any(cell for cell in cells):
86
+ rows.append(cells)
87
+ return rows
88
+
89
+
90
+ def _render_doc(path: Path, _sheets=None) -> Rendered:
91
+ tally = new_tally()
92
+ with reading(path, "document"), zipfile.ZipFile(path) as package:
93
+ part = _opc_main_part(package)
94
+ body = _word_child(_xml_part(package, part), "body")
95
+ if body is None:
96
+ raise BrewdocError("no document body: %s carries no <w:body> in %s" % (path, part))
97
+ chapters, heading, blocks = [], DOC_BODY, []
98
+ for node in body:
99
+ if _word_tag(node, "tbl"):
100
+ rows = _doc_rows(node, tally)
101
+ if rows:
102
+ tally["tables"] += 1
103
+ blocks.append(("table", rows))
104
+ elif _word_tag(node, "p"):
105
+ lines = _doc_lines(node, tally)
106
+ if not lines:
107
+ continue
108
+ if HEADING_STYLE_RE.fullmatch(_doc_style(node)):
109
+ if blocks or heading != DOC_BODY:
110
+ chapters.append((heading, blocks))
111
+ heading, blocks = _doc_label(node), []
112
+ else:
113
+ tally["text_regions"] += 1
114
+ blocks.append(("text", lines))
115
+ if blocks or heading != DOC_BODY or not chapters:
116
+ chapters.append((heading, blocks))
117
+ units = [(ordinal, heading, chapter_blocks)
118
+ for ordinal, (heading, chapter_blocks) in enumerate(chapters, 1)]
119
+ return _assemble(path, ROUTE.name, ROUTE.unit_kind, len(units), units, tally,
120
+ not_carried=ROUTE.not_carried)
121
+
122
+
123
+ ROUTE = Route("doc", "chapter", (".docx",), _render_doc,
124
+ ("images, charts and the text drawn inside them",
125
+ "tracked changes, comments, footnotes, headers and footers",
126
+ "a table's own formatting - only its cells, row by row"))
127
+
128
+
129
+ def render_doc(path) -> tuple[str, dict]:
130
+ """(Markdown, tally) for a .docx: one anchored chapter per Word heading, else `Body`."""
131
+ return _render_doc(Path(path))[:2]