pptx-md 0.1.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.
pptx_md/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """pptx-md — Convert PPTX files into LLM-friendly Markdown.
2
+
3
+ Public API (FR-16, ADR-603):
4
+
5
+ from pptx_md import convert, ConvertOptions
6
+
7
+ md = convert("deck.pptx")
8
+
9
+ # With options:
10
+ from pptx_md import ConvertOptions, MaskingOptions, get_describer
11
+ opts = ConvertOptions(
12
+ describer=get_describer("anthropic", api_key="..."),
13
+ masking=MaskingOptions(enabled=True),
14
+ validate=True,
15
+ )
16
+ md = convert("deck.pptx", options=opts)
17
+
18
+ Internal submodules (parse_presentation, enrich_images, etc.) are NOT part
19
+ of the public API and may change without notice. Access via
20
+ ``from pptx_md.parser import parse_presentation`` is possible but outside
21
+ SemVer guarantees (ADR-603).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pptx_md.api import ConvertOptions, convert
27
+ from pptx_md.describer import ImageDescriber
28
+ from pptx_md.errors import DescribeError, InstallationError, ParseError, PptxMdError
29
+ from pptx_md.masking import MASK_TOKEN, MaskingOptions
30
+ from pptx_md.providers import get_describer
31
+ from pptx_md.validator import ValidationResult, validate_markdown
32
+
33
+ __version__: str = "0.1.0"
34
+
35
+ __all__ = [
36
+ "convert",
37
+ "ConvertOptions",
38
+ "MaskingOptions",
39
+ "MASK_TOKEN",
40
+ "validate_markdown",
41
+ "ValidationResult",
42
+ "ImageDescriber",
43
+ "get_describer",
44
+ "PptxMdError",
45
+ "ParseError",
46
+ "DescribeError",
47
+ "InstallationError",
48
+ ]
pptx_md/api.py ADDED
@@ -0,0 +1,130 @@
1
+ """Public API orchestrator — convert() + ConvertOptions (FR-16, ADR-601/602).
2
+
3
+ This module is the single entry point for the pptx-md conversion pipeline.
4
+ It assembles the M2–M5 pipeline stages into one function and exposes the
5
+ ConvertOptions value object that aggregates all per-stage parameters.
6
+
7
+ Design constraints:
8
+ - ADR-601: Orchestrator lives here; __init__.py re-exports only.
9
+ - ADR-602: ConvertOptions is a frozen dataclass (immutable, thread-safe).
10
+ - ADR-604: validate=True is logging-only; return type is always str.
11
+ - NFR-08: This module MUST NOT import VLM SDKs directly.
12
+ describer is injected by the caller via ConvertOptions.
13
+ - NFR-06: Log metadata only — never raw text or PII fragments.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ # Internal pipeline imports — these are all stdlib+internal modules,
25
+ # no VLM SDK is imported here (NFR-08, ADR-601).
26
+ from pptx_md.assembler import assemble_document
27
+ from pptx_md.description_pipeline import enrich_descriptions
28
+ from pptx_md.image_pipeline import enrich_images
29
+ from pptx_md.parser import parse_presentation
30
+ from pptx_md.validator import validate_markdown
31
+
32
+ if TYPE_CHECKING:
33
+ from pptx_md.describer import ImageDescriber
34
+ from pptx_md.masking import MaskingOptions
35
+
36
+ _logger = logging.getLogger("pptx_md.api")
37
+
38
+ __all__ = ["ConvertOptions", "convert"]
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # ConvertOptions — frozen dataclass (ADR-602)
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ConvertOptions:
48
+ """Aggregated options for convert() (FR-16, ADR-602).
49
+
50
+ Collects the per-stage parameters of the M2–M5 pipeline into one
51
+ immutable value object so callers configure the whole conversion in a
52
+ single place.
53
+
54
+ Attributes:
55
+ describer: VLM image-description provider (ImageDescriber Protocol).
56
+ None (default) = skip VLM enrichment (NFR-08).
57
+ masking: PII masking options (MaskingOptions).
58
+ None (default) = no masking (FR-15 opt-in).
59
+ validate: When True, validate_markdown() is called after assembly
60
+ and results are emitted as log warnings. Return type
61
+ is always str regardless of this flag (ADR-604).
62
+ """
63
+
64
+ describer: ImageDescriber | None = None
65
+ masking: MaskingOptions | None = None
66
+ validate: bool = False
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # convert() — 5-stage pipeline orchestrator (ADR-601)
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def convert(
75
+ source: str | os.PathLike[str],
76
+ *,
77
+ options: ConvertOptions | None = None,
78
+ ) -> str:
79
+ """Convert a PPTX file into a single Markdown document (FR-16).
80
+
81
+ Orchestrates the M2–M5 pipeline:
82
+ parse -> enrich_images -> enrich_descriptions -> assemble (-> validate?).
83
+
84
+ Args:
85
+ source: Path to a .pptx file (str or pathlib.Path).
86
+ options: Optional ConvertOptions; None means defaults
87
+ (no VLM, no masking, no validation logging).
88
+
89
+ Returns:
90
+ The assembled Markdown document as a str.
91
+
92
+ Raises:
93
+ ParseError: If the file is missing or not a valid PPTX (file-level
94
+ fail-fast — M2 ADR-205). Per-shape/per-image failures
95
+ are isolated and never raise (M3/M4/M5 graceful policy).
96
+ """
97
+ opts = options if options is not None else ConvertOptions()
98
+
99
+ source_path = Path(source)
100
+ _logger.debug("convert: start source=%s", source_path.name)
101
+
102
+ # --- Stage 1: parse (ParseError propagates on file-level failure) ---
103
+ pres = parse_presentation(source_path)
104
+ _logger.debug("convert: parsed slides=%d", len(pres.slides))
105
+
106
+ # --- Stage 2: enrich images (classification, always runs) ---
107
+ enrich_images(pres)
108
+
109
+ # --- Stage 3: enrich descriptions (opt-in via describer, NFR-08) ---
110
+ enrich_descriptions(pres, opts.describer)
111
+
112
+ # --- Stage 4: assemble Markdown (masking opt-in) ---
113
+ md = assemble_document(pres, masking=opts.masking)
114
+
115
+ # --- Stage 5: validate (opt-in, logging-only — ADR-604) ---
116
+ if opts.validate:
117
+ result = validate_markdown(md)
118
+ if not result.valid:
119
+ _logger.warning(
120
+ "validate_markdown: invalid document (warnings=%d)",
121
+ len(result.warnings),
122
+ )
123
+ elif result.warnings:
124
+ _logger.info(
125
+ "validate_markdown: %d warning(s)",
126
+ len(result.warnings),
127
+ )
128
+
129
+ _logger.debug("convert: done md_len=%d", len(md))
130
+ return md
pptx_md/assembler.py ADDED
@@ -0,0 +1,267 @@
1
+ """Markdown assembler — converts a SlideIR / PresentationIR into Markdown (FR-11/12).
2
+
3
+ Public interface:
4
+ assemble_slide(slide: SlideIR, *, masking: MaskingOptions | None = None) -> str
5
+ assemble_document(
6
+ presentation: PresentationIR, *, masking: MaskingOptions | None = None
7
+ ) -> str
8
+
9
+ Design constraints (ADR-218):
10
+ - Deterministic: same IR -> same Markdown (no randomness, no set/dict iteration).
11
+ - IR is read-only: assembler never mutates input objects.
12
+ - Partial-failure isolation: one shape failure does not abort the slide/document.
13
+ - No VLM/python-pptx/Pillow imports (NFR-08).
14
+ - Uses mermaid.is_complex_table() for FR-13 Mermaid fallback.
15
+ - masking=None means no masking (opt-in, FR-15).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+
22
+ from pptx_md.ir import (
23
+ GroupShapeIR,
24
+ ImageShapeIR,
25
+ OtherShapeIR,
26
+ PresentationIR,
27
+ ShapeIR,
28
+ SlideIR,
29
+ TableShapeIR,
30
+ TextShapeIR,
31
+ )
32
+ from pptx_md.masking import MaskingOptions, mask_text
33
+ from pptx_md.mermaid import is_complex_table, table_to_mermaid
34
+
35
+ __all__ = ["assemble_slide", "assemble_document"]
36
+
37
+ _logger = logging.getLogger("pptx_md.assembler")
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Module-level named constants (ADR-218 — determinism, §3.2 heading rule)
41
+ # ---------------------------------------------------------------------------
42
+ _SLIDE_HEADING_PREFIX: str = "##"
43
+ _INDENT_UNIT: str = " " # two spaces per indent level
44
+ _BULLET_MARKER: str = "- "
45
+ _SLIDE_SEPARATOR: str = "\n\n---\n\n"
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Public functions
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def assemble_slide(
54
+ slide: SlideIR,
55
+ *,
56
+ masking: MaskingOptions | None = None,
57
+ ) -> str:
58
+ """Render one SlideIR into a deterministic Markdown block (FR-11).
59
+
60
+ Read-only: does not mutate the IR. Same IR -> same Markdown (ADR-218).
61
+ masking=None means no masking (opt-in, FR-15).
62
+
63
+ Args:
64
+ slide: A SlideIR instance (read-only).
65
+ masking: Optional MaskingOptions instance (FR-15).
66
+
67
+ Returns:
68
+ A Markdown string representing this slide. Empty/blank slides return
69
+ an empty string or heading-only string without raising (FR-11 AC7).
70
+ """
71
+ return _render_slide(slide, masking)
72
+
73
+
74
+ def assemble_document(
75
+ presentation: PresentationIR,
76
+ *,
77
+ masking: MaskingOptions | None = None,
78
+ ) -> str:
79
+ """Map-Reduce assembly of the whole presentation into one document (FR-12).
80
+
81
+ Map: assemble_slide per slide (sequential, ADR-220).
82
+ Reduce: join with a slide separator, in IR list order.
83
+ Deterministic and order-preserving (ADR-220).
84
+
85
+ Args:
86
+ presentation: A PresentationIR instance (read-only).
87
+ masking: Optional MaskingOptions instance (FR-15).
88
+
89
+ Returns:
90
+ A single Markdown document string.
91
+ """
92
+ # AC2: sort slides by index (ascending) regardless of IR list order (ADR-220)
93
+ sorted_slides = sorted(presentation.slides, key=lambda s: s.index)
94
+
95
+ slide_blocks: list[str] = []
96
+ for slide in sorted_slides:
97
+ try:
98
+ block = _render_slide(slide, masking)
99
+ except Exception as exc: # noqa: BLE001 — slide-level isolation
100
+ _logger.warning(
101
+ "assemble_document: slide index=%d failed, skipping. "
102
+ "shape_count=%d error_type=%s",
103
+ slide.index,
104
+ len(slide.shapes),
105
+ type(exc).__name__,
106
+ )
107
+ block = f"{_SLIDE_HEADING_PREFIX} Slide {slide.index + 1}"
108
+ slide_blocks.append(block)
109
+
110
+ return _SLIDE_SEPARATOR.join(slide_blocks)
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Private render functions
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def _render_slide(slide: SlideIR, masking: MaskingOptions | None) -> str:
119
+ """Render a single SlideIR to Markdown."""
120
+ parts: list[str] = []
121
+
122
+ # Heading (AC1: first line is heading)
123
+ if slide.title:
124
+ parts.append(f"{_SLIDE_HEADING_PREFIX} {slide.title}")
125
+ else:
126
+ # Blank title: emit heading with slide index+1 (1-based display)
127
+ parts.append(f"{_SLIDE_HEADING_PREFIX} Slide {slide.index + 1}")
128
+
129
+ # Shapes in IR order (AC1: shapes serialised in IR appearance order)
130
+ for shape in slide.shapes:
131
+ rendered = _render_shape(shape, masking)
132
+ if rendered:
133
+ parts.append(rendered)
134
+
135
+ return "\n\n".join(parts)
136
+
137
+
138
+ def _render_shape(shape: ShapeIR, masking: MaskingOptions | None) -> str:
139
+ """Dispatch to the appropriate renderer; isolate per-shape failures (ADR-220)."""
140
+ try:
141
+ if isinstance(shape, TextShapeIR):
142
+ return _render_text(shape, masking)
143
+ if isinstance(shape, TableShapeIR):
144
+ return _render_table(shape)
145
+ if isinstance(shape, ImageShapeIR):
146
+ return _render_image(shape, masking)
147
+ if isinstance(shape, GroupShapeIR):
148
+ return _render_group(shape, masking)
149
+ if isinstance(shape, OtherShapeIR):
150
+ return _render_other(shape, masking)
151
+ # Unknown subclass — best-effort skip
152
+ _logger.warning(
153
+ "_render_shape: unknown shape kind=%s shape_id=%d — skipping",
154
+ getattr(shape, "kind", "?"),
155
+ shape.shape_id,
156
+ )
157
+ return ""
158
+ except Exception as exc: # noqa: BLE001 — shape-level isolation
159
+ _logger.warning(
160
+ "_render_shape: shape_id=%d kind=%s render failed error_type=%s — skipping",
161
+ shape.shape_id,
162
+ getattr(shape, "kind", "?"),
163
+ type(exc).__name__,
164
+ )
165
+ return ""
166
+
167
+
168
+ def _render_text(shape: TextShapeIR, masking: MaskingOptions | None) -> str:
169
+ """Render a TextShapeIR to Markdown paragraphs / indented bullets (AC1, AC2)."""
170
+ lines: list[str] = []
171
+ for para in shape.paragraphs:
172
+ text = para.text
173
+ # Apply masking if provided (FR-15)
174
+ if masking is not None:
175
+ text = mask_text(text, masking)
176
+
177
+ if para.level > 0:
178
+ # Indented bullet item (AC2: level=1 deeper than level=0)
179
+ indent = _INDENT_UNIT * para.level
180
+ lines.append(f"{indent}{_BULLET_MARKER}{text}")
181
+ else:
182
+ # Top-level paragraph
183
+ lines.append(text)
184
+
185
+ return "\n".join(lines)
186
+
187
+
188
+ def _render_table(shape: TableShapeIR) -> str:
189
+ """Render a TableShapeIR as GFM table or Mermaid fallback (AC3, FR-13)."""
190
+ if is_complex_table(shape):
191
+ # FR-13 Mermaid fallback
192
+ return table_to_mermaid(shape)
193
+
194
+ # GFM pipe table (AC3: header separator included)
195
+ rows = shape.rows
196
+ if not rows:
197
+ return ""
198
+
199
+ header = rows[0]
200
+ table_lines: list[str] = []
201
+
202
+ # Header row
203
+ table_lines.append("| " + " | ".join(header) + " |")
204
+ # Separator row (one --- per column)
205
+ table_lines.append("| " + " | ".join("---" for _ in header) + " |")
206
+ # Data rows
207
+ for row in rows[1:]:
208
+ # Pad/truncate row to match header column count
209
+ padded = list(row) + [""] * (len(header) - len(row))
210
+ padded = padded[: len(header)]
211
+ table_lines.append("| " + " | ".join(padded) + " |")
212
+
213
+ return "\n".join(table_lines)
214
+
215
+
216
+ def _render_image(shape: ImageShapeIR, masking: MaskingOptions | None) -> str:
217
+ """Render an ImageShapeIR according to M3/M4 slot priority (AC4, AC5, §3.4).
218
+
219
+ Priority (first match):
220
+ 1. description (M4) present -> body paragraph (+ classification label)
221
+ 2. description absent, alt_text present -> ![alt_text](...)
222
+ 3. both absent, classification (M3) present -> _[image: {classification}]_
223
+ 4. all absent -> _[image]_
224
+ """
225
+ description = shape.description
226
+ alt_text = shape.alt_text
227
+ classification = shape.classification
228
+
229
+ if description is not None:
230
+ # AC4: description present -> description text block (no placeholder)
231
+ text = description
232
+ if masking is not None:
233
+ text = mask_text(text, masking)
234
+ if classification is not None:
235
+ return f"{text}\n\n_[{classification} image]_"
236
+ return text
237
+
238
+ # AC5: description=None -> alt_text image syntax
239
+ if alt_text:
240
+ masked_alt = mask_text(alt_text, masking) if masking is not None else alt_text
241
+ return f"![{masked_alt}](...)"
242
+
243
+ if classification is not None:
244
+ return f"_[image: {classification}]_"
245
+
246
+ return "_[image]_"
247
+
248
+
249
+ def _render_group(shape: GroupShapeIR, masking: MaskingOptions | None) -> str:
250
+ """Render a GroupShapeIR by recursively rendering children in order (AC6)."""
251
+ child_parts: list[str] = []
252
+ for child in shape.children:
253
+ rendered = _render_shape(child, masking)
254
+ if rendered:
255
+ child_parts.append(rendered)
256
+ return "\n\n".join(child_parts)
257
+
258
+
259
+ def _render_other(shape: OtherShapeIR, masking: MaskingOptions | None) -> str:
260
+ """Render an OtherShapeIR: non-empty fallback_text -> paragraph, else skip (AC8)."""
261
+ if not shape.fallback_text:
262
+ # AC8: empty fallback_text -> silently omit
263
+ return ""
264
+ text = shape.fallback_text
265
+ if masking is not None:
266
+ text = mask_text(text, masking)
267
+ return text