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 +48 -0
- pptx_md/api.py +130 -0
- pptx_md/assembler.py +267 -0
- pptx_md/classifier.py +284 -0
- pptx_md/describer.py +79 -0
- pptx_md/description_pipeline.py +162 -0
- pptx_md/errors.py +24 -0
- pptx_md/image_pipeline.py +84 -0
- pptx_md/ir.py +164 -0
- pptx_md/masking.py +96 -0
- pptx_md/mermaid.py +163 -0
- pptx_md/parser.py +362 -0
- pptx_md/providers/__init__.py +81 -0
- pptx_md/providers/anthropic.py +172 -0
- pptx_md/providers/openai.py +171 -0
- pptx_md/py.typed +0 -0
- pptx_md/validator.py +173 -0
- pptx_md/vector.py +141 -0
- pptx_md-0.1.0.dist-info/METADATA +220 -0
- pptx_md-0.1.0.dist-info/RECORD +22 -0
- pptx_md-0.1.0.dist-info/WHEEL +4 -0
- pptx_md-0.1.0.dist-info/licenses/LICENSE +21 -0
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 -> 
|
|
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""
|
|
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
|