feat(raw): raw derive reads PDF and Outlook MSG; doctor shows each import filter
CI / verify (push) Successful in 6m41s
CI / pwsh (push) Successful in 2m24s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped

PDF is read with pypdf (page markers, scan warning, encrypted files refused), Outlook .msg with olefile as a container like an .eml, and a .msg attached to a mail is quoted. Both packages are in tools/requirements.txt and imported only when such a file is derived, so a missing one stops that format alone. doctor reports one import-filter line per package-backed format.

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/ingest-a-source.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/filters/__init__.py
- tools/chemenu/filters/mail.py
- tools/chemenu/filters/msg.py
- tools/chemenu/filters/pdf.py
- tools/chemenu/tests/binary_formats.py
- tools/chemenu/tests/test_raw_derive_binary.py
- tools/requirements.txt

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-08 14:02:16 +02:00
1 parent 6659ca5409
commit 0e6a50aa98
16 files changed
+812 -17

No files matched your search

+2 -1
View File
@@ -1515,7 +1515,7 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`,
- A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.<ext>` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`.
- A folder is derived in place: each file with a filter gets `<stem>.md` beside it, nothing is renamed, and the files without a filter are named in the output.
- `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-<name>` line per package-backed format. A PDF gets `<!-- page N -->` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read.
- A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder `incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` where a filter reads it; the ZIP is removed and the output names it with its sha256. A mail becomes a folder with `original.eml`, `derived.md` and its attachments under their own names; a ZIP attached to it is unpacked once into a folder of its own, and an attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries (`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, `_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists each rename.
- A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it.
- After a success the output prints the warnings the filters raised, at most three `CHECK` lines per format that was derived in this run - what to look at before the text is trusted - and the `raw accept` line for the result.
@@ -3692,6 +3692,7 @@ Check that this instance is correctly configured.
**NOTES**
- Checks dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, the kb/raw/reports/work/instructions structure, and generated files.
- Import filters (`import-filter-<name>`): one line for each `raw derive` format that reads through a package (PDF, Outlook `.msg`) - a `WARN` naming the package when it is missing, since that costs only its own format; fixed by running `tools/preflight.sh` again.
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).
- Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 characters) - the limit the preflight enforces before it sets anything up.
- PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether a group policy sets it, which only whoever administers the computer can change - and a `FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus `tar` do not. `tools/preflight.ps1` checks the same two things before it stops.
+4 -2
View File
@@ -66,7 +66,9 @@ excepted.
`jsonschema` and `PyYAML` are hard dependencies, not optional extras: schema
validation is the tool's whole safety net, so `cli.py` fails loudly with the
fix rather than degrading silently.
fix rather than degrading silently. `pypdf` and `olefile` are in `requirements.txt` too
but are imported only when a PDF or an Outlook `.msg` is derived, so a missing one stops
that format alone; `doctor` reports each.
## Layout
@@ -112,7 +114,7 @@ tools/
kb_state.py the KB version (.wikitool-kb.json) and the migration chain
corpus_diff.py invariant comparison of kb/ between two revisions
web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Office, ZIP), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Outlook MSG, Office, PDF, ZIP), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt; plus the one write back, a single-file commit pushed without force for `export guidelines`
guideline_export.py `export guidelines`' core: select pages by `search` predicates, render them into one deterministic GUIDELINES.md, and recognise an export again (`EXPORT_MARKER`) - with no CLI attached
search/ pluggable search backends, plus service.py - the search core
+25 -1
View File
@@ -21,7 +21,7 @@ from typing import Optional
import typer
from rich.console import Console
from chemenu import cli_contract, config, config_shapes, conventions, kb_collections, prerequisites, toolpaths, version as version_mod
from chemenu import cli_contract, config, config_shapes, conventions, filters, kb_collections, prerequisites, toolpaths, version as version_mod
from chemenu.commands import git_publish, instructions_cmd
from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
@@ -76,6 +76,26 @@ def check_ripgrep() -> Check:
)
def check_import_filters() -> list[Check]:
"""One line per `raw derive` filter that needs a package: a missing one costs only its
own format, so it is a WARN, never a FAIL."""
found = []
for candidate in filters.all_filters():
if not candidate.requires:
continue
missing = candidate.missing()
formats = ", ".join(candidate.extensions)
if missing:
found.append(Check(
f"import-filter-{candidate.name}", "WARN",
f"{', '.join(missing)} missing - raw derive cannot read {formats} until it is installed",
PREFLIGHT_FIX,
))
else:
found.append(Check(f"import-filter-{candidate.name}", "OK", f"{candidate.engine().split(' + ', 1)[1]} reads {formats}"))
return found
def check_tool_paths() -> Check:
"""`.wikitool-tools.json`: written by a preflight that finished, and every
path in it still there. The launcher refuses to start without a complete
@@ -742,6 +762,7 @@ def run_doctor() -> list[Check]:
check_python(),
check_tool_paths(),
check_ripgrep(),
*check_import_filters(),
check_install_dir(),
check_execution_policy(),
check_script_marks(),
@@ -779,6 +800,9 @@ def run_doctor() -> list[Check]:
"Checks dependencies (Python, ripgrep), author resolution, stack version, git "
"identity/branch/remote, published skills, the kb/raw/reports/work/instructions "
"structure, and generated files.",
"Import filters (`import-filter-<name>`): one line for each `raw derive` format that "
"reads through a package (PDF, Outlook `.msg`) - a `WARN` naming the package when it is "
"missing, since that costs only its own format; fixed by running `tools/preflight.sh` again.",
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
"every recorded path still there - a `FAIL` otherwise, fixed by running "
+7 -1
View File
@@ -2135,7 +2135,13 @@ def _derive_next(target: Path) -> str:
"reads the format, its version.",
"Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, "
"`.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library "
"only.",
"only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a "
"package in `tools/requirements.txt` that is imported only when such a file is derived. "
"A missing package refuses that format alone, with the preflight hint; `wikitool "
"doctor` shows one `import-filter-<name>` line per package-backed format. A PDF gets "
"`<!-- page N -->` markers and a WARN when it yields almost no text (a scan; there is "
"no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an "
"embedded message inside it is named, not read.",
"A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder "
"`incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` "
"where a filter reads it; the ZIP is removed and the output names it with its sha256. A "
+1 -1
View File
@@ -205,4 +205,4 @@ def unique(lines: Iterable[str]) -> list[str]:
return out
from chemenu.filters import archive, html_page, mail, ooxml # noqa: E402,F401 (register themselves)
from chemenu.filters import archive, html_page, mail, msg, ooxml, pdf # noqa: E402,F401 (register themselves)
+19 -6
View File
@@ -1,4 +1,5 @@
"""E-mail: `.eml` (RFC 5322 / MIME), read with the standard library."""
"""E-mail: `.eml` (RFC 5322 / MIME), read with the standard library. The Outlook
`.msg` filter (`msg.py`) shares everything after the parsing."""
from __future__ import annotations
import re
@@ -26,7 +27,7 @@ class Attachment:
@property
def is_message(self) -> bool:
return self.content_type == "message/rfc822" or self.name.lower().endswith(".eml")
return self.content_type == "message/rfc822" or self.name.lower().endswith((".eml", ".msg"))
@dataclass
@@ -178,6 +179,14 @@ def _quote_section(inner: ParsedMail) -> str:
return "\n".join(lines)
def read_attached(attachment: Attachment) -> ParsedMail:
if attachment.name.lower().endswith(".msg"):
from chemenu.filters import msg # imports this module; kept lazy
return msg.MsgFilter().read(attachment.data)
return parse(attachment.data)
def _subject(parsed: ParsedMail) -> str:
subject = parsed.headers["subject"]
return subject if isinstance(subject, str) else ""
@@ -190,8 +199,11 @@ class EmlFilter(Filter):
name = "eml"
extensions = (".eml",)
def read(self, data: bytes) -> ParsedMail:
return parse(data)
def derive(self, data: bytes, name: str) -> Derivation:
parsed = parse(data)
parsed = self.read(data)
warnings = list(parsed.warnings)
if parsed.attachments:
warnings.append(
@@ -207,7 +219,7 @@ class EmlFilter(Filter):
)
def container(self, data: bytes, name: str) -> Optional[Container]:
parsed = parse(data)
parsed = self.read(data)
collector = Collector(reserve_top=True)
sections: list[str] = []
warnings = list(parsed.warnings)
@@ -215,9 +227,10 @@ class EmlFilter(Filter):
file_name = clean_name(attachment.name, f"attachment-{number}")
if attachment.is_message:
try:
inner = parse(attachment.data)
except ValidationError:
inner = read_attached(attachment)
except ValidationError as exc:
collector.add_file(file_name, attachment.data)
warnings.append(f"The attachment {file_name!r} looks like a message but is written as a file: {exc}")
continue
sections.append(_quote_section(inner))
if inner.attachments:
+213
View File
@@ -0,0 +1,213 @@
"""Outlook `.msg` (a compound file), read with `olefile` (imported when one is derived).
Only what a reader of the mail needs: the headers, the body (plain text, else HTML)
and the attachments by name and bytes. The result is the same `ParsedMail` an `.eml`
yields, so everything after the parsing is shared with `mail.py`.
"""
from __future__ import annotations
import datetime
import io
import re
import struct
from email import message_from_bytes, policy
from email.utils import parsedate_to_datetime
from typing import Optional
from chemenu import web_capture
from chemenu.errors import ValidationError
from chemenu.filters import register
from chemenu.filters.mail import Attachment, EmlFilter, ParsedMail
_SUBJECT = "0037"
_BODY = "1000"
_HTML = "1013"
_RTF = "1009"
_HEADERS = "007D"
_SENDER_NAME = "0C1A"
_SENDER_ADDRESS = "5D01"
_SENDER_FALLBACK = "0C1F"
_DISPLAY_TO = "0E04"
_DISPLAY_CC = "0E03"
_ATT_LONG_NAME = "3707"
_ATT_SHORT_NAME = "3704"
_ATT_DATA = "3701"
_ATT_MIME = "370E"
_ATT_CONTENT_ID = "3712"
_ATT_METHOD = "3705"
_METHOD_EMBEDDED_MESSAGE = 5
_FILETIME_EPOCH = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc)
def _norm_header(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
class _Message:
"""The streams of one message (or of one attached message storage) by property."""
def __init__(self, ole, prefix: str = "") -> None:
self._ole = ole
self._prefix = prefix
def _path(self, tag: str, kind: str) -> str:
return f"{self._prefix}__substg1.0_{tag}{kind}"
def raw(self, tag: str) -> Optional[bytes]:
for kind in ("001F", "001E", "0102"):
path = self._path(tag, kind)
if self._ole.exists(path):
return self._ole.openstream(path).read()
return None
def text(self, tag: str) -> str:
for kind, encoding in (("001F", "utf-16-le"), ("001E", "cp1252")):
path = self._path(tag, kind)
if self._ole.exists(path):
return self._ole.openstream(path).read().decode(encoding, errors="replace").strip("\x00").strip()
return ""
def _properties(self) -> dict[tuple[int, int], bytes]:
"""The fixed-size properties from `__properties_version1.0`, by (id, type). Its
header is 32 bytes for the message itself and 8 for an attachment."""
path = f"{self._prefix}__properties_version1.0"
if not self._ole.exists(path):
return {}
data = self._ole.openstream(path).read()
found: dict[tuple[int, int], bytes] = {}
for offset in range(32 if not self._prefix else 8, len(data) - 15, 16):
(tag,) = struct.unpack_from("<I", data, offset)
found[(tag >> 16, tag & 0xFFFF)] = data[offset + 8 : offset + 16]
return found
def integer(self, tag: str) -> Optional[int]:
value = self._properties().get((int(tag, 16), 0x0003))
return None if value is None else struct.unpack_from("<i", value)[0]
def time(self, tag: str) -> str:
value = self._properties().get((int(tag, 16), 0x0040))
if value is None:
return ""
try:
return (_FILETIME_EPOCH + datetime.timedelta(microseconds=struct.unpack("<Q", value)[0] // 10)).isoformat()
except OverflowError:
return ""
def _header_value(headers: str, name: str) -> str:
if not headers:
return ""
parsed = message_from_bytes(headers.encode("utf-8", errors="replace"), policy=policy.default)
try:
return _norm_header(str(parsed[name] or ""))
except Exception:
return ""
def _header_date(headers: str) -> str:
value = _header_value(headers, "date")
try:
return parsedate_to_datetime(value).isoformat() if value else ""
except (TypeError, ValueError):
return value
def _address_list(text: str) -> list[str]:
return [part.strip() for part in text.split(";") if part.strip()]
def _html_text(raw: bytes) -> str:
for encoding in ("utf-8", "cp1252"):
try:
html = raw.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
html = raw.decode("utf-8", errors="replace")
return web_capture.derive_text(html, "").strip()
def _body(message: _Message, out: ParsedMail) -> None:
plain = message.text(_BODY)
html = message.raw(_HTML)
if plain:
out.body = plain.replace("\r\n", "\n").replace("\r", "\n").strip()
elif html:
out.body = _html_text(html)
else:
what = "has only an RTF body" if message.raw(_RTF) else "has no body"
out.warnings.append(f"The message {what}, which raw derive does not read; the text is empty.")
def _read(ole) -> ParsedMail:
message = _Message(ole)
out = ParsedMail()
headers = message.text(_HEADERS)
sender = message.text(_SENDER_NAME)
address = message.text(_SENDER_ADDRESS) or message.text(_SENDER_FALLBACK)
if sender and address and sender != address:
sender = f"{sender} <{address}>"
date = _header_date(headers) or message.time("0039") or message.time("0E06")
out.headers = {
"from": _header_value(headers, "from") or sender or address,
"to": _address_list(message.text(_DISPLAY_TO)),
"cc": _address_list(message.text(_DISPLAY_CC)),
"date": date,
"subject": message.text(_SUBJECT),
"message_id": _header_value(headers, "message-id"),
}
_body(message, out)
inline = 0
for entry in sorted(
{tuple(path[:1]) for path in ole.listdir(streams=False, storages=True) if path[0].startswith("__attach_version1.0_")}
):
storage = f"{entry[0]}/"
attachment = _Message(ole, storage)
name = attachment.text(_ATT_LONG_NAME) or attachment.text(_ATT_SHORT_NAME) or f"attachment-{len(out.attachments) + inline + 1}"
if attachment.integer(_ATT_METHOD) == _METHOD_EMBEDDED_MESSAGE:
out.warnings.append(
f"An embedded message {name!r} is named but not read; open the original for its content."
)
continue
content = attachment.raw(_ATT_DATA) or b""
if attachment.text(_ATT_CONTENT_ID) and attachment.text(_ATT_MIME).startswith("image/"):
inline += 1
continue
out.attachments.append(Attachment(name, content, attachment.text(_ATT_MIME) or "application/octet-stream"))
out.inline_images = inline
if inline:
out.warnings.append(f"{inline} inline image(s) are not in the text.")
return out
class MsgFilter(EmlFilter):
"""An Outlook message: the same text, header and container rules as an `.eml`."""
name = "msg"
extensions = (".msg",)
requires = ("olefile",)
def read(self, data: bytes) -> ParsedMail:
import olefile
if not data.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
raise ValidationError("Not an Outlook message: the file is not a compound document.")
try:
ole = olefile.OleFileIO(io.BytesIO(data))
except (OSError, olefile.OleFileError) as exc:
raise ValidationError(f"Not a readable Outlook message: {exc}.") from None
try:
if not ole.exists("__properties_version1.0"):
raise ValidationError("Not an Outlook message: the compound document has no message properties.")
return _read(ole)
except ValidationError:
raise
except (OSError, struct.error, ValueError, IndexError, KeyError) as exc:
raise ValidationError(f"Not a readable Outlook message: {exc}.") from None
finally:
ole.close()
register(MsgFilter())
+60
View File
@@ -0,0 +1,60 @@
"""PDF, read with `pypdf` (imported when a PDF is derived, never at start-up)."""
from __future__ import annotations
import io
from chemenu.errors import ValidationError
from chemenu.filters import Derivation, Filter, register
SCAN_CHARS_PER_PAGE = 50
class PdfFilter(Filter):
name = "pdf"
extensions = (".pdf",)
requires = ("pypdf",)
def derive(self, data: bytes, name: str) -> Derivation:
import pypdf
from pypdf import errors
try:
reader = pypdf.PdfReader(io.BytesIO(data))
if reader.is_encrypted:
raise ValidationError(
f"{name} is encrypted; raw derive does not read it. Save an unprotected copy "
"into incoming/ and derive that."
)
pages = [(page.extract_text() or "").strip() for page in reader.pages]
title = ""
if reader.metadata is not None and reader.metadata.title:
title = " ".join(str(reader.metadata.title).split())
except ValidationError:
raise
except (errors.PyPdfError, ValueError, KeyError, TypeError, RecursionError, OSError) as exc:
raise ValidationError(f"{name} is not a readable PDF: {exc}.") from None
if not pages:
raise ValidationError(f"{name} has no pages.")
blocks = [f"<!-- page {number} -->\n{text}".rstrip() for number, text in enumerate(pages, 1)]
characters = sum(len(text) for text in pages)
warnings: list[str] = []
checks = [
"Page breaks are the <!-- page N --> markers; tables and multi-column layouts may "
"come out in reading order, not as laid out - check figures against the original."
]
if characters < SCAN_CHARS_PER_PAGE * len(pages):
warnings.append(
f"The PDF yields {characters} character(s) over {len(pages)} page(s): it is probably "
"a scan. raw derive does no OCR, so the text is missing or incomplete."
)
return Derivation(
text="\n\n".join(blocks),
title=title,
fields=(("pages", str(len(pages))),),
warnings=tuple(warnings),
checks=tuple(checks),
)
register(PdfFilter())
+224
View File
@@ -0,0 +1,224 @@
"""Builders for the two binary formats `raw derive` reads with a package: a PDF and an
Outlook `.msg` (a compound file). Built here, byte by byte, so no binary sits in the
repository and every fixture is readable in the test that uses it. Whether each one is a
valid document is settled by the package that reads it in the tests."""
from __future__ import annotations
import struct
from typing import Optional
END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
def build_pdf(pages: list[list[str]], *, title: Optional[str] = None) -> bytes:
"""A PDF with one text line per list item; an empty page is an empty content stream."""
objects: dict[int, bytes] = {}
page_ids = [3 + 2 * n for n in range(len(pages))]
font_id = 3 + 2 * len(pages)
info_id = font_id + 1
objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{page} 0 R" for page in page_ids)
objects[2] = f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode()
for page_id, lines in zip(page_ids, pages):
ops = ["BT /F1 12 Tf 72 720 Td 14 TL"]
for line in lines:
escaped = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
ops.append(f"({escaped}) Tj T*")
ops.append("ET")
stream = "\n".join(ops).encode("latin-1") if lines else b""
objects[page_id] = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {page_id + 1} 0 R "
f"/Resources << /Font << /F1 {font_id} 0 R >> >> >>"
).encode()
objects[page_id + 1] = b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream"
objects[font_id] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
trailer = "/Root 1 0 R"
if title is not None:
objects[info_id] = f"<< /Title ({title}) >>".encode("latin-1")
trailer += f" /Info {info_id} 0 R"
out = bytearray(b"%PDF-1.4\n")
offsets = {}
for number in sorted(objects):
offsets[number] = len(out)
out += f"{number} 0 obj\n".encode() + objects[number] + b"\nendobj\n"
xref = len(out)
size = max(objects) + 1
out += f"xref\n0 {size}\n".encode() + b"0000000000 65535 f \n"
for number in range(1, size):
out += f"{offsets[number]:010d} 00000 n \n".encode()
out += f"trailer\n<< /Size {size} {trailer} >>\nstartxref\n{xref}\n%%EOF\n".encode()
return bytes(out)
class _Node:
def __init__(self, name: str, kind: int, data: bytes = b"") -> None:
self.name, self.kind, self.data = name, kind, data
self.children: dict[str, "_Node"] = {}
self.index = 0
self.start = END
self.size = 0
def build_cfb(files: dict[str, bytes]) -> bytes:
"""A compound file (version 3) holding `files` (path with `/` -> bytes). Streams under
4096 bytes go into the mini stream, as the format requires."""
root = _Node("Root Entry", 5)
for path, data in files.items():
*folders, leaf = path.split("/")
node = root
for folder in folders:
node = node.children.setdefault(folder, _Node(folder, 1))
node.children[leaf] = _Node(leaf, 2, data)
order: list[_Node] = []
def number(node: _Node) -> None:
node.index = len(order)
order.append(node)
for child in node.children.values():
number(child)
number(root)
streams = [n for n in order if n.kind == 2]
small = [n for n in streams if 0 < len(n.data) < 4096]
big = [n for n in streams if len(n.data) >= 4096]
mini = bytearray()
mini_chain: list[int] = []
for node in small:
node.start = len(mini) // 64
node.size = len(node.data)
blocks = -(-len(node.data) // 64)
mini += node.data.ljust(blocks * 64, b"\x00")
mini_chain += [node.start + i + 1 for i in range(blocks - 1)] + [END]
for node in streams:
node.size = len(node.data)
n_minifat = -(-len(mini_chain) // 128)
n_dir = -(-len(order) // 4)
n_mini = -(-len(mini) // 512)
big_sectors = {id(n): -(-len(n.data) // 512) for n in big}
total = 1 + n_minifat + n_dir + n_mini + sum(big_sectors.values())
assert total <= 128, "the builder writes one FAT sector"
fat = [FREE] * 128
fat[0] = FATSECT
cursor = 1
first_minifat = cursor if n_minifat else END
for i in range(n_minifat):
fat[cursor + i] = cursor + i + 1 if i < n_minifat - 1 else END
cursor += n_minifat
first_dir = cursor
for i in range(n_dir):
fat[cursor + i] = cursor + i + 1 if i < n_dir - 1 else END
cursor += n_dir
root.start = cursor if n_mini else END
root.size = len(mini)
for i in range(n_mini):
fat[cursor + i] = cursor + i + 1 if i < n_mini - 1 else END
cursor += n_mini
for node in big:
node.start = cursor
count = big_sectors[id(node)]
for i in range(count):
fat[cursor + i] = cursor + i + 1 if i < count - 1 else END
cursor += count
def entry(node: Optional[_Node], right: int = FREE) -> bytes:
if node is None:
return struct.pack("<64sHBBIII16sIQQIQ", b"", 0, 0, 0, FREE, FREE, FREE, b"", 0, 0, 0, 0, 0)
name = node.name.encode("utf-16-le")
kids = list(node.children.values())
return struct.pack(
"<64sHBBIII16sIQQIQ", name.ljust(64, b"\x00"), len(name) + 2, node.kind, 1, FREE, right,
kids[0].index if kids else FREE, b"", 0, 0, 0, node.start, node.size,
)
rights: dict[int, int] = {}
for node in order:
kids = list(node.children.values())
for current, following in zip(kids, kids[1:]):
rights[current.index] = following.index
directory = b"".join(entry(n, rights.get(n.index, FREE)) for n in order)
empty_entry = entry(None)
directory = directory[: len(order) * 128] + empty_entry * (n_dir * 4 - len(order))
header = struct.pack(
"<8s16sHHHHH6sIIIIIIIII", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", b"", 0x3E, 3, 0xFFFE, 9, 6, b"", 0, 1,
first_dir, 0, 4096, first_minifat, n_minifat, END, 0,
) + struct.pack("<109I", 0, *([FREE] * 108))
minifat = struct.pack(f"<{n_minifat * 128}I", *(mini_chain + [FREE] * (n_minifat * 128 - len(mini_chain))))
out = bytearray(header)
out += struct.pack("<128I", *fat)
out += minifat
out += directory
out += bytes(mini).ljust(n_mini * 512, b"\x00")
for node in big:
out += node.data.ljust(big_sectors[id(node)] * 512, b"\x00")
return bytes(out)
def _utf16(text: str) -> bytes:
return text.encode("utf-16-le")
def build_msg(
*,
subject: str = "",
body: Optional[str] = None,
html: Optional[str] = None,
rtf: Optional[bytes] = None,
sender: str = "",
sender_address: str = "",
to: str = "",
cc: str = "",
attachments: Optional[list[tuple[str, bytes]]] = None,
inline_images: Optional[list[tuple[str, bytes]]] = None,
embedded: Optional[list[str]] = None,
sent: Optional[int] = None,
) -> bytes:
"""An Outlook message. `sent` is a FILETIME (100 ns ticks since 1601)."""
files: dict[str, bytes] = {}
def text(prefix: str, tag: str, value: str) -> None:
if value:
files[f"{prefix}__substg1.0_{tag}001F"] = _utf16(value)
text("", "0037", subject)
text("", "0C1A", sender)
text("", "5D01", sender_address)
text("", "0E04", to)
text("", "0E03", cc)
if body is not None:
text("", "1000", body)
if html is not None:
files["__substg1.0_10130102"] = html.encode("utf-8")
if rtf is not None:
files["__substg1.0_10090102"] = rtf
props = b"\x00" * 32
if sent is not None:
props += struct.pack("<IIQ", 0x00390040, 6, sent)
files["__properties_version1.0"] = props
number = 0
for name, data in attachments or []:
prefix = f"__attach_version1.0_#{number:08X}/"
text(prefix, "3707", name)
text(prefix, "370E", "application/octet-stream")
files[f"{prefix}__substg1.0_37010102"] = data
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 1)
number += 1
for name, data in inline_images or []:
prefix = f"__attach_version1.0_#{number:08X}/"
text(prefix, "3707", name)
text(prefix, "370E", "image/png")
text(prefix, "3712", "cid-" + name)
files[f"{prefix}__substg1.0_37010102"] = data
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 1)
number += 1
for name in embedded or []:
prefix = f"__attach_version1.0_#{number:08X}/"
text(prefix, "3707", name)
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 5)
number += 1
return build_cfb(files)
@@ -0,0 +1,246 @@
"""`wikitool raw derive` for the two formats read through a package: PDF (`pypdf`) and
Outlook `.msg` (`olefile`). The documents come from `binary_formats`, so no binary sits
in the repository; whether the builders write valid files is settled by the packages
reading them here. The tests that need a package skip where it is not installed - CI
installs `tools/requirements.txt`, so they run there."""
from __future__ import annotations
import importlib.util
import io
from pathlib import Path
import pytest
from chemenu import filters
from chemenu.commands import doctor
from chemenu.tests.binary_formats import build_msg, build_pdf
from chemenu.tests.test_raw_derive import ( # noqa: F401 (tree is a fixture)
_derive,
_header_and_text,
_put,
_refused,
_snapshot,
make_docx,
make_mail,
make_zip,
tree,
)
needs_pypdf = pytest.mark.skipif(importlib.util.find_spec("pypdf") is None, reason="pypdf is not installed")
needs_olefile = pytest.mark.skipif(importlib.util.find_spec("olefile") is None, reason="olefile is not installed")
LONG_LINE = "A sentence long enough that the page is clearly not a scan of a picture."
# --- PDF ---------------------------------------------------------------------
@needs_pypdf
def test_a_pdf_becomes_a_folder_with_page_markers(tree, capsys):
original = build_pdf([[LONG_LINE, "Second line (with brackets)"], [LONG_LINE + " Two."]], title="Quarterly report")
_put(tree, "Report.pdf", original)
_derive("incoming/Report.pdf")
out = capsys.readouterr().out
folder = tree / "incoming/Report"
assert (folder / "original.pdf").read_bytes() == original
header, text = _header_and_text(folder / "derived.md")
assert header["filter"] == "pdf" and "pypdf" in header["engine"]
assert int(header["pages"]) == 2 and header["title"] == "Quarterly report"
assert text.index("<!-- page 1 -->") < text.index("Second line (with brackets)") < text.index("<!-- page 2 -->")
assert "WARN" not in out
assert out.count("CHECK") == 1 and "page" in out
@needs_pypdf
def test_a_pdf_without_text_is_derived_with_a_scan_warning(tree, capsys):
_put(tree, "scan.pdf", build_pdf([[], [], []]))
_derive("incoming/scan.pdf")
out = capsys.readouterr().out
assert "WARN" in out and "scan" in out and "3 page" in out
assert (tree / "incoming/scan/original.pdf").is_file()
@needs_pypdf
def test_a_pdf_that_is_not_a_pdf_is_refused_untouched(tree):
_put(tree, "fake.pdf", b"this is not a pdf at all")
before = _snapshot(tree / "incoming")
_refused("incoming/fake.pdf")
assert _snapshot(tree / "incoming") == before
@needs_pypdf
def test_an_encrypted_pdf_is_refused_untouched(tree, capsys):
import pypdf
writer = pypdf.PdfWriter(clone_from=pypdf.PdfReader(io.BytesIO(build_pdf([[LONG_LINE]]))))
try:
writer.encrypt("secret", algorithm="RC4-128")
except Exception as exc: # no crypto provider for the algorithm on this machine
pytest.skip(f"cannot build an encrypted PDF here: {exc}")
buffer = io.BytesIO()
writer.write(buffer)
_put(tree, "locked.pdf", buffer.getvalue())
before = _snapshot(tree / "incoming")
_refused("incoming/locked.pdf")
assert "encrypted" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
@needs_pypdf
def test_a_pdf_inside_a_zip_gets_its_md(tree):
_put(tree, "bundle.zip", make_zip({"a.pdf": build_pdf([[LONG_LINE]]), "b.txt": b"x"}))
_derive("incoming/bundle.zip")
folder = tree / "incoming/bundle"
assert LONG_LINE in (folder / "a.md").read_text(encoding="utf-8")
assert (folder / "a.pdf").is_file() and not (folder / "b.md").exists()
@needs_pypdf
def test_a_pdf_attached_to_a_mail_gets_its_md(tree):
_put(tree, "mail.eml", make_mail([("offer.pdf", build_pdf([[LONG_LINE]]), "application/pdf")]))
_derive("incoming/mail.eml")
assert LONG_LINE in (tree / "incoming/mail/offer.md").read_text(encoding="utf-8")
def test_a_missing_package_stops_only_its_own_format(tree, monkeypatch, capsys):
pdf_filter = filters.filter_for("a.pdf")
monkeypatch.setattr(type(pdf_filter), "missing", lambda self: ["pypdf"])
_put(tree, "a.pdf", b"%PDF-1.4")
_put(tree, "b.docx", make_docx("<w:p><w:r><w:t>Still works</w:t></w:r></w:p>"))
before = _snapshot(tree / "incoming")
_refused("incoming/a.pdf")
out = capsys.readouterr().out
assert "pypdf" in out and "preflight" in out
assert _snapshot(tree / "incoming") == before
_derive("incoming/b.docx")
assert "Still works" in (tree / "incoming/b/derived.md").read_text(encoding="utf-8")
def test_a_package_missing_inside_a_zip_refuses_the_whole_zip(tree, monkeypatch):
monkeypatch.setattr(type(filters.filter_for("a.pdf")), "missing", lambda self: ["pypdf"])
_put(tree, "bundle.zip", make_zip({"a.pdf": b"%PDF-1.4", "b.txt": b"x"}))
before = _snapshot(tree / "incoming")
_refused("incoming/bundle.zip")
assert _snapshot(tree / "incoming") == before
# --- Outlook .msg --------------------------------------------------------------
SENT = 134_000_000_000_000_000 # 2025-08-18T14:13:20+00:00
@needs_olefile
def test_a_msg_becomes_a_folder_like_a_mail(tree, capsys):
data = [b"attachment bytes" * 400, b"small"]
original = build_msg(
subject="Unterlagen", body="Hello Bob\r\nSee attached.", sender="Anna", sender_address="anna@example.org",
to="bob@example.org; carol@example.org", sent=SENT,
attachments=[("report.bin", data[0]), ("note.txt", data[1])],
)
_put(tree, "Mail.msg", original)
_derive("incoming/Mail.msg")
out = capsys.readouterr().out
folder = tree / "incoming/Mail"
assert (folder / "original.msg").read_bytes() == original
assert (folder / "report.bin").read_bytes() == data[0]
assert (folder / "note.txt").read_bytes() == data[1]
header, text = _header_and_text(folder / "derived.md")
assert header["filter"] == "msg" and "olefile" in header["engine"]
assert header["from"] == "Anna <anna@example.org>"
assert header["to"] == ["bob@example.org", "carol@example.org"]
assert header["subject"] == "Unterlagen" and str(header["date"]).startswith("2025-08-18")
assert header["attachments"] == ["report.bin", "note.txt"]
assert text.strip() == "Hello Bob\nSee attached."
assert "report.bin" in out
@needs_olefile
def test_a_msg_with_only_html_takes_the_html_text(tree):
_put(tree, "h.msg", build_msg(subject="S", html="<html><body><p>Only <b>html</b> here</p></body></html>"))
_derive("incoming/h.msg")
_header, text = _header_and_text(tree / "incoming/h/derived.md")
assert "Only" in text and "html here" in text and "<p>" not in text
@needs_olefile
def test_a_msg_with_only_rtf_warns_and_has_no_text(tree, capsys):
_put(tree, "r.msg", build_msg(subject="S", rtf=b"{\\rtf1 hello}"))
_derive("incoming/r.msg")
out = capsys.readouterr().out
assert "WARN" in out and "RTF" in out
_header, text = _header_and_text(tree / "incoming/r/derived.md")
assert text.strip() == ""
@needs_olefile
def test_a_msg_names_inline_images_and_embedded_messages_without_writing_them(tree, capsys):
_put(tree, "m.msg", build_msg(
subject="S", body="text", inline_images=[("logo.png", b"png")], embedded=["forwarded.msg"],
attachments=[("real.bin", b"x")],
))
_derive("incoming/m.msg")
out = capsys.readouterr().out
assert sorted(p.name for p in (tree / "incoming/m").iterdir()) == ["derived.md", "original.msg", "real.bin"]
assert "inline image" in out and "forwarded.msg" in out
@needs_olefile
def test_a_msg_attached_to_a_mail_is_quoted(tree, capsys):
inner = build_msg(subject="Inner subject", body="Inner words", sender="Dora", to="x@example.org")
_put(tree, "mail.eml", make_mail([("inner.msg", inner, "application/vnd.ms-outlook")]))
_derive("incoming/mail.eml")
folder = tree / "incoming/mail"
assert sorted(p.name for p in folder.iterdir()) == ["derived.md", "original.eml"]
_header, text = _header_and_text(folder / "derived.md")
assert "## Attached message: Inner subject" in text and "> Inner words" in text
@needs_olefile
def test_a_file_that_is_not_a_msg_is_refused_untouched(tree):
_put(tree, "x.msg", b"plain text pretending to be a message")
before = _snapshot(tree / "incoming")
_refused("incoming/x.msg")
assert _snapshot(tree / "incoming") == before
@needs_olefile
def test_a_compound_file_that_is_not_a_message_is_refused(tree):
from chemenu.tests.binary_formats import build_cfb
_put(tree, "x.msg", build_cfb({"WordDocument": b"abc"}))
_refused("incoming/x.msg")
assert not (tree / "incoming/x").exists()
def test_a_msg_without_the_package_is_refused_and_a_docx_still_works(tree, monkeypatch, capsys):
monkeypatch.setattr(type(filters.filter_for("a.msg")), "missing", lambda self: ["olefile"])
_put(tree, "a.msg", b"\xd0\xcf\x11\xe0")
before = _snapshot(tree / "incoming")
_refused("incoming/a.msg")
assert "olefile" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
# --- doctor ---------------------------------------------------------------------
def test_doctor_names_each_filter_that_needs_a_package():
names = {check.name for check in doctor.check_import_filters()}
assert names == {"import-filter-pdf", "import-filter-msg"}
def test_doctor_warns_for_a_missing_package_and_never_fails(monkeypatch):
monkeypatch.setattr(type(filters.filter_for("a.pdf")), "missing", lambda self: ["pypdf"])
checks = {check.name: check for check in doctor.check_import_filters()}
assert checks["import-filter-pdf"].status == "WARN"
assert "pypdf" in checks["import-filter-pdf"].detail and checks["import-filter-pdf"].fix
assert all(check.status != "FAIL" for check in checks.values())
@needs_pypdf
def test_doctor_reports_an_installed_package_with_its_version():
check = next(c for c in doctor.check_import_filters() if c.name == "import-filter-pdf")
assert check.status == "OK" and check.detail.startswith("pypdf ") and ".pdf" in check.detail
+2
View File
@@ -4,3 +4,5 @@ rich>=13.0
jsonschema>=4.18
referencing>=0.30
icalendar>=6.0
pypdf>=5.0
olefile>=0.47