Six-skill presentation system: ingest talks into a rhetoric vault, run interactive clarification, generate a speaker profile, create presentations that match your documented patterns, produce the deck illustrations + thumbnail visual layer, and publish talk pages to a Jekyll shownotes site. Includes a 111-entry Presentation Patterns taxonomy (81 observable: 62 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns) for scoring, brainstorming, and go-live preparation.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
"""Read a deck's deterministic identity facts out of its OPC package (#176).
`pptx_talk_identity` decides which talk a deck belongs to from facts someone
else observed. This module is that observer for a deck already on disk: it
opens the package, reads two small metadata parts and the title slide, and
returns the mapping `deck_identity_facts` accepts.
Three properties shape it.
* **Bounded.** Only `docProps/core.xml`, `docProps/app.xml`, the presentation
part and its relationships, and the first slide are read, each under an
expanded-size cap. A catalog row is persisted state, and persisted state is a
hint, never a licence to decompress an arbitrary host file
(`stateful-artifacts` -> Hints, Not Authority).
* **Never fatal.** An unreadable, damaged, or absent deck returns the facts
gathered so far with a reason code. Damage must weaken the evidence, never
the identity requirements, so the caller still assesses the deck from its
path rather than skipping it.
* **Nothing invented.** A part that is missing or malformed contributes no
fact. Absence is reported as absence; the identity module's `unknown`
verdict already means "this deck does not carry that fact".
## Why the title slide and not every slide title
`docProps/app.xml` lists every slide's title, and feeding that list in as
rendered text is actively wrong. Measured against the live vault's 74 bound
decks, passing all 95 slide titles of one deck made 69 of the 74 assessments
`identity_ambiguous_candidates`: a deck that mentions another talk's title on
some interior slide agrees with that talk, and agreeing with everything is
indistinguishable from knowing nothing.
The title slide cannot do that. Its runs are this deck's own title and
subtitle, which is what the catalog's title is being compared against.
## Which part IS the title slide
Not `ppt/slides/slide1.xml`. That is a part name, and OPC leaves slide ORDER to
`ppt/presentation.xml`'s `sldIdLst` resolved through the presentation's
relationships. A deck whose slides were reordered can hold an interior slide in
`slide1.xml`, and reading it would feed interior text in as the deck's title —
the same defect this module's title-slide rule exists to avoid, arriving by a
different door.
"""
from __future__ import annotations
import os
import posixpath
import re
import zipfile
from dataclasses import dataclass, field, replace
import hashlib
import tempfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from artifact_locator import (
ArtifactLocatorError,
classify_artifact_locator,
materialize_artifact_locator,
)
from pptx_catalog_selection import open_contained_descriptor
DECK_FACTS_SCHEMA_VERSION = 1
# Reason codes are closed. They report why a fact is absent, never what the
# host filesystem or the archive's bytes look like (`no-secrets` -> Logging).
DECK_FACTS_OK = "deck_facts_read"
DECK_FACTS_LOCATOR_INVALID = "deck_facts_locator_invalid"
DECK_FACTS_UNREADABLE = "deck_facts_unreadable"
DECK_FACTS_NOT_A_PACKAGE = "deck_facts_not_a_package"
DECK_FACTS_PACKAGE_OVERSIZED = "deck_facts_package_oversized"
DECK_FACTS_PART_UNREADABLE = "deck_facts_part_unreadable"
DECK_FACTS_PARTS_ABSENT = "deck_facts_parts_absent"
DECK_FACTS_REASON_CODES = frozenset(
{
DECK_FACTS_OK,
DECK_FACTS_LOCATOR_INVALID,
DECK_FACTS_UNREADABLE,
DECK_FACTS_NOT_A_PACKAGE,
DECK_FACTS_PACKAGE_OVERSIZED,
DECK_FACTS_PART_UNREADABLE,
DECK_FACTS_PARTS_ABSENT,
}
)
_CORE_PART = "docProps/core.xml"
_APP_PART = "docProps/app.xml"
_PRESENTATION_PART = "ppt/presentation.xml"
_PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels"
_PRESENTATION_BASE = "ppt"
# A metadata part and one slide are small. These caps bound decompression on a
# path chosen by persisted state; a real deck's parts are orders below them.
_MAX_PART_EXPANDED_BYTES = 8 * 1024 * 1024
_MAX_ARCHIVE_MEMBERS = 65_536
# Beyond the title, the title slide's remaining runs are the subtitle and
# speaker/venue line. Bounded so a text-heavy first slide cannot reintroduce
# the agree-with-everything failure the module docstring describes.
_MAX_RENDERED_FOOTERS = 8
_MAX_HASHTAGS = 16
_MAX_TEXT_CHARS = 500
_EXT_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/extended-properties}"
_VT_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}"
_DC_NS = "{http://purl.org/dc/elements/1.1/}"
_DCTERMS_NS = "{http://purl.org/dc/terms/}"
_DRAWING_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
_PRESENTATION_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
_RELATIONSHIP_ID_NS = (
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
)
_PACKAGE_RELS_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
_SLIDE_RELATIONSHIP_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
)
_SLIDE_TITLES_HEADING = "Slide Titles"
_HASHTAG_RE = re.compile(r"#\w+", re.UNICODE)
_YEAR_PREFIX_RE = re.compile(r"\A(?:19|20)\d{2}")
@dataclass(frozen=True)
class DeckFactsReading:
"""One deck's observed identity facts plus why anything is missing.
`facts` is always a mapping `deck_identity_facts` accepts, even when the
package could not be opened: `pptx_path` alone is a valid observation, and
the path's own venue and year signals are exactly what must survive damage.
"""
pptx_path: str
facts: dict[str, Any]
reason_code: str
slide_count: int | None = None
parts_read: tuple[str, ...] = field(default=())
# The generation these exact facts were read from, digested from the SAME
# open descriptor that produced them. `None` when the package could not be
# opened at all.
source_identity: dict[str, Any] | None = None
@property
def package_read(self) -> bool:
return self.reason_code == DECK_FACTS_OK
def as_json(self) -> dict[str, Any]:
return {
"schema_version": DECK_FACTS_SCHEMA_VERSION,
"pptx_path": self.pptx_path,
"reason_code": self.reason_code,
"slide_count": self.slide_count,
"parts_read": list(self.parts_read),
"facts": dict(self.facts),
}
def _text(value: object) -> str | None:
if not isinstance(value, str):
return None
collapsed = " ".join(value.split())
if not collapsed:
return None
return collapsed[:_MAX_TEXT_CHARS]
def _slide_titles(app_xml: bytes) -> list[str]:
"""Read `TitlesOfParts` slice that `HeadingPairs` labels as slide titles.
`TitlesOfParts` concatenates several categories — fonts, themes, then slide
titles — and only `HeadingPairs` says where each begins. Slicing by a fixed
offset would read font names as titles on any deck with a different theme
count.
"""
root = ET.fromstring(app_xml)
pairs = root.find(f"{_EXT_NS}HeadingPairs")
parts = root.find(f"{_EXT_NS}TitlesOfParts")
if pairs is None or parts is None:
return []
pair_vector = pairs.find(f"{_VT_NS}vector")
counts: list[tuple[str, int]] = []
label: str | None = None
for variant in [] if pair_vector is None else list(pair_vector):
for child in variant:
if child.tag == f"{_VT_NS}lpstr":
label = child.text
elif child.tag == f"{_VT_NS}i4" and label is not None:
try:
counts.append((label, int(child.text or "")))
except ValueError:
return []
label = None
part_vector = parts.find(f"{_VT_NS}vector")
names = (
[]
if part_vector is None
else [element.text or "" for element in part_vector.findall(f"{_VT_NS}lpstr")]
)
offset = 0
for name, count in counts:
if count < 0:
return []
if name == _SLIDE_TITLES_HEADING:
return names[offset : offset + count]
offset += count
return []
def _slide_count(app_xml: bytes) -> int | None:
root = ET.fromstring(app_xml)
raw = root.findtext(f"{_EXT_NS}Slides")
try:
count = int((raw or "").strip())
except ValueError:
return None
return count if count >= 0 else None
def _title_slide_runs(slide_xml: bytes) -> list[str]:
root = ET.fromstring(slide_xml)
runs: list[str] = []
for element in root.iter(f"{_DRAWING_NS}t"):
text = _text(element.text)
if text is not None:
runs.append(text)
return runs
def _first_slide_relationship_id(presentation_xml: bytes) -> str | None:
"""The r:id of the first entry in the presentation's slide-id list."""
root = ET.fromstring(presentation_xml)
slide_ids = root.find(f"{_PRESENTATION_NS}sldIdLst")
if slide_ids is None:
return None
for slide_id in slide_ids.findall(f"{_PRESENTATION_NS}sldId"):
relationship_id = slide_id.get(f"{_RELATIONSHIP_ID_NS}id")
if isinstance(relationship_id, str) and relationship_id:
return relationship_id
return None
def _slide_part_for_relationship(rels_xml: bytes, relationship_id: str) -> str | None:
"""Resolve one slide relationship to its part name inside the package."""
root = ET.fromstring(rels_xml)
for relationship in root.findall(f"{_PACKAGE_RELS_NS}Relationship"):
if relationship.get("Id") != relationship_id:
continue
if relationship.get("Type") != _SLIDE_RELATIONSHIP_TYPE:
return None
target = relationship.get("Target")
if not isinstance(target, str) or not target:
return None
if target.startswith("/"):
return posixpath.normpath(target.lstrip("/"))
resolved = posixpath.normpath(posixpath.join(_PRESENTATION_BASE, target))
# A target that climbs out of the package is not a part of it.
if resolved.startswith("../") or resolved.startswith("/"):
return None
return resolved
return None
def _title_slide_part(archive: zipfile.ZipFile) -> str | None:
"""Name the part holding the deck's FIRST slide, in presentation order.
`ppt/slides/slide1.xml` is a part name, not a position. OPC leaves slide
order to `ppt/presentation.xml`'s `sldIdLst`, resolved through the
presentation's relationships, so a deck whose slides were reordered can
hold an interior slide in `slide1.xml` — and reading that one would feed
an interior slide's text in as the deck's own title.
Returns None when the chain cannot be resolved. The caller then reads no
slide at all rather than guessing a part name: `docProps/app.xml` already
lists slide titles in presentation order, so the fallback stays ordered.
"""
presentation_xml = _read_part(archive, _PRESENTATION_PART)
rels_xml = _read_part(archive, _PRESENTATION_RELS_PART)
if presentation_xml is None or rels_xml is None:
return None
try:
relationship_id = _first_slide_relationship_id(presentation_xml)
if relationship_id is None:
return None
return _slide_part_for_relationship(rels_xml, relationship_id)
except ET.ParseError:
return None
def _resolve_descriptor(pptx_path: str, pptx_source_dir: object) -> int | None:
"""Open the deck under its configured root, refusing every symlink below it.
Containment is `pptx_catalog_selection`'s rule and is reused rather than
restated: the sweep must not be able to read a file the evidence classifier
would have refused.
"""
if pptx_source_dir is None:
return None
try:
if classify_artifact_locator(pptx_path) != "relative":
return None
resolved = materialize_artifact_locator(pptx_path, pptx_source_dir)
parts = resolved.relative_to(Path(str(pptx_source_dir))).parts
except (ArtifactLocatorError, TypeError, ValueError):
return None
return open_contained_descriptor(pptx_source_dir, parts)
def _read_part(archive: zipfile.ZipFile, name: str) -> bytes | None:
"""Read one part, refusing a member that expands past the cap."""
try:
info = archive.getinfo(name)
except KeyError:
return None
if info.file_size > _MAX_PART_EXPANDED_BYTES:
return None
try:
with archive.open(info) as stream:
return stream.read(_MAX_PART_EXPANDED_BYTES + 1)
except (zipfile.BadZipFile, OSError, EOFError, ValueError):
return None
def read_deck_identity_facts(
pptx_path: object,
pptx_source_dir: object,
) -> DeckFactsReading:
"""Observe one catalog deck's identity facts, or report why it could not be.
Returns a reading whose `facts` always at least names the deck, so a caller
can hand it straight to `assess_pptx_talk_identity` regardless of outcome.
"""
path_text = _text(pptx_path)
if path_text is None:
return DeckFactsReading(
pptx_path="",
facts={"pptx_path": ""},
reason_code=DECK_FACTS_LOCATOR_INVALID,
)
facts: dict[str, Any] = {"pptx_path": path_text}
descriptor = _resolve_descriptor(path_text, pptx_source_dir)
if descriptor is None:
return DeckFactsReading(
pptx_path=path_text,
facts=facts,
reason_code=DECK_FACTS_UNREADABLE,
)
try:
with os.fdopen(descriptor, "rb", closefd=True) as handle:
# Copy the deck into a private spool ONCE, digesting as it goes, and
# do every later read against that copy. Nothing weaker holds:
#
# * digesting by path in a second open lets an A->B->A replacement
# hand generation A's digest to facts parsed from B;
# * bracketing the read with a fingerprint on each side is walked
# through by the same A->B->A shape, since before == after;
# * digest-then-seek-then-parse on one descriptor survives path
# replacement but NOT in-place mutation — a writer that truncates
# or overwrites the inode between the two reads still yields an
# identity describing different bytes from the facts.
#
# Only a copy the vault cannot reach is immune, because it removes
# the second read of the live file rather than racing it.
with tempfile.SpooledTemporaryFile(
max_size=_SPOOL_TO_DISK_BYTES, mode="w+b"
) as snapshot:
identity = _copy_and_digest(handle, snapshot)
snapshot.seek(0)
reading = _read_from_stream(snapshot, path_text, facts)
return replace(reading, source_identity=identity)
except (OSError, ValueError):
return DeckFactsReading(
pptx_path=path_text,
facts=facts,
reason_code=DECK_FACTS_UNREADABLE,
)
_DIGEST_CHUNK_BYTES = 1024 * 1024
# Decks run to tens of megabytes, so the snapshot spills to disk past this
# rather than being held whole in memory. Correctness does not depend on the
# threshold — a spooled file is equally private either side of it.
_SPOOL_TO_DISK_BYTES = 8 * 1024 * 1024
def _copy_and_digest(source: Any, destination: Any) -> dict[str, Any]:
"""Copy a deck into a private spool, returning the generation it copied.
One pass over the live file, which is the whole point: the digest describes
exactly the bytes that landed in the snapshot, and every later read is
against the snapshot, so no concurrent writer can put the two out of step.
"""
digest = hashlib.sha256()
size = 0
for chunk in iter(lambda: source.read(_DIGEST_CHUNK_BYTES), b""):
digest.update(chunk)
size += len(chunk)
destination.write(chunk)
return {"algorithm": "sha256", "digest": digest.hexdigest(), "size_bytes": size}
def _read_from_stream(
handle: Any,
pptx_path: str,
facts: dict[str, Any],
) -> DeckFactsReading:
try:
archive = zipfile.ZipFile(handle)
except (zipfile.BadZipFile, OSError, EOFError, ValueError):
return DeckFactsReading(
pptx_path=pptx_path,
facts=facts,
reason_code=DECK_FACTS_NOT_A_PACKAGE,
)
with archive:
try:
members = archive.namelist()
except (zipfile.BadZipFile, OSError, ValueError):
return DeckFactsReading(
pptx_path=pptx_path,
facts=facts,
reason_code=DECK_FACTS_NOT_A_PACKAGE,
)
if len(members) > _MAX_ARCHIVE_MEMBERS:
return DeckFactsReading(
pptx_path=pptx_path,
facts=facts,
reason_code=DECK_FACTS_PACKAGE_OVERSIZED,
)
return _read_parts(archive, pptx_path, facts)
def _read_parts(
archive: zipfile.ZipFile,
pptx_path: str,
facts: dict[str, Any],
) -> DeckFactsReading:
parts_read: list[str] = []
malformed = False
slide_count: int | None = None
hashtag_sources: list[str] = []
core_xml = _read_part(archive, _CORE_PART)
if core_xml is not None:
try:
root = ET.fromstring(core_xml)
except ET.ParseError:
malformed = True
else:
parts_read.append(_CORE_PART)
document_title = _text(root.findtext(f"{_DC_NS}title"))
if document_title is not None:
facts["document_title"] = document_title
created = _text(root.findtext(f"{_DCTERMS_NS}created"))
if created is not None and _YEAR_PREFIX_RE.match(created):
facts["document_created_year"] = created[:4]
app_xml = _read_part(archive, _APP_PART)
titles: list[str] = []
if app_xml is not None:
try:
titles = [
text
for text in (_text(item) for item in _slide_titles(app_xml))
if text
]
slide_count = _slide_count(app_xml)
except ET.ParseError:
malformed = True
else:
parts_read.append(_APP_PART)
hashtag_sources.extend(titles)
title_slide_part = _title_slide_part(archive)
slide_xml = (
None if title_slide_part is None else _read_part(archive, title_slide_part)
)
runs: list[str] = []
if slide_xml is not None:
try:
runs = _title_slide_runs(slide_xml)
except ET.ParseError:
malformed = True
else:
parts_read.append(str(title_slide_part))
hashtag_sources.extend(runs)
# The title slide's first run is this deck's own headline; app.xml's first
# slide title is the same string as PowerPoint recorded it. Either is the
# deck's title, and the slide is preferred because it keeps the subtitle
# runs that follow it in the same reading.
rendered_title = runs[0] if runs else (titles[0] if titles else None)
if rendered_title is not None:
facts["rendered_title"] = rendered_title
if len(runs) > 1:
facts["rendered_footers"] = runs[1 : 1 + _MAX_RENDERED_FOOTERS]
hashtags: list[str] = []
for text in hashtag_sources:
for tag in _HASHTAG_RE.findall(text):
if tag not in hashtags:
hashtags.append(tag)
if hashtags:
facts["hashtags"] = hashtags[:_MAX_HASHTAGS]
if malformed:
reason_code = DECK_FACTS_PART_UNREADABLE
elif not parts_read:
reason_code = DECK_FACTS_PARTS_ABSENT
else:
reason_code = DECK_FACTS_OK
return DeckFactsReading(
pptx_path=pptx_path,
facts=facts,
reason_code=reason_code,
slide_count=slide_count,
parts_read=tuple(parts_read),
)
__all__ = [
"DECK_FACTS_LOCATOR_INVALID",
"DECK_FACTS_NOT_A_PACKAGE",
"DECK_FACTS_OK",
"DECK_FACTS_PACKAGE_OVERSIZED",
"DECK_FACTS_PARTS_ABSENT",
"DECK_FACTS_PART_UNREADABLE",
"DECK_FACTS_REASON_CODES",
"DECK_FACTS_SCHEMA_VERSION",
"DECK_FACTS_UNREADABLE",
"DeckFactsReading",
"read_deck_identity_facts",
].tessl-plugin
rules
skills
illustrations
presentation-creator
references
patterns
build
deliver
prepare
scripts
shownotes-publisher
vault-clarification
vault-ingress
references
scripts
vault-profile