brewdoc 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brewdoc/pptx.py ADDED
@@ -0,0 +1,362 @@
1
+ """PPTX adapter: declared slides, DrawingML text, tables, notes and image facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from brewdoc.common import (BrewdocError, Rendered, Route, _assemble, _local_name,
10
+ _relationship_part, _resolve_ooxml_part, _xml_part, new_tally,
11
+ reading, sanitise)
12
+
13
+ TRANSITIONAL_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
14
+ STRICT_REL = "http://purl.oclc.org/ooxml/officeDocument/relationships"
15
+ OFFICE_DOCUMENT_TYPES = {TRANSITIONAL_REL + "/officeDocument",
16
+ STRICT_REL + "/officeDocument"}
17
+ SLIDE_TYPES = {TRANSITIONAL_REL + "/slide", STRICT_REL + "/slide"}
18
+ NOTES_TYPES = {TRANSITIONAL_REL + "/notesSlide", STRICT_REL + "/notesSlide"}
19
+ IMAGE_TYPES = {TRANSITIONAL_REL + "/image", STRICT_REL + "/image"}
20
+ RELATIONSHIP_NAMESPACES = {TRANSITIONAL_REL, STRICT_REL}
21
+
22
+ TRUE_VALUES = {"1", "on", "true"}
23
+ TITLE_PLACEHOLDERS = {"title", "ctrTitle"}
24
+ SHAPE_NAMES = {"sp", "grpSp", "graphicFrame", "pic"}
25
+
26
+
27
+ def _child(node: ET.Element, name: str) -> ET.Element | None:
28
+ return next((child for child in node if _local_name(child.tag) == name), None)
29
+
30
+
31
+ def _required_xml(package: zipfile.ZipFile, part: str) -> ET.Element:
32
+ """Read one required XML part and reject ambiguous duplicate archive members."""
33
+ matches = [item for item in package.infolist() if item.filename == part]
34
+ if not matches:
35
+ raise BrewdocError("OOXML part is missing: %s" % part)
36
+ if len(matches) > 1:
37
+ raise BrewdocError("OOXML part occurs more than once: %s" % part)
38
+ return _xml_part(package, part)
39
+
40
+
41
+ def _required_bytes(package: zipfile.ZipFile, part: str) -> bytes:
42
+ """Read one required binary part and reject ambiguous duplicate archive members."""
43
+ matches = [item for item in package.infolist() if item.filename == part]
44
+ if not matches:
45
+ raise BrewdocError("OOXML part is missing: %s" % part)
46
+ if len(matches) > 1:
47
+ raise BrewdocError("OOXML part occurs more than once: %s" % part)
48
+ return package.read(matches[0])
49
+
50
+
51
+ def _required_relationships(package: zipfile.ZipFile, part: str) -> tuple[ET.Element, ...]:
52
+ """Read a required relationship part without collapsing duplicate relationship ids."""
53
+ root = _required_xml(package, part)
54
+ if _local_name(root.tag) != "Relationships":
55
+ raise BrewdocError("OOXML relationship part has unexpected root: %s" % part)
56
+ return tuple(element for element in root if _local_name(element.tag) == "Relationship")
57
+
58
+
59
+ def _optional_relationships(package: zipfile.ZipFile, part: str) -> tuple[ET.Element, ...]:
60
+ """Read a relationship part when present; malformed or duplicate parts still refuse."""
61
+ if not any(item.filename == part for item in package.infolist()):
62
+ return ()
63
+ return _required_relationships(package, part)
64
+
65
+
66
+ def _relationship_attribute(element: ET.Element, name: str) -> str | None:
67
+ """Read an office relationship attribute without confusing unqualified ids."""
68
+ for key, value in element.attrib.items():
69
+ if not key.startswith("{") or _local_name(key) != name:
70
+ continue
71
+ namespace = key[1:].split("}", 1)[0]
72
+ if namespace in RELATIONSHIP_NAMESPACES:
73
+ return value
74
+ return None
75
+
76
+
77
+ def _presentation_part(package: zipfile.ZipFile) -> str:
78
+ """Resolve the single internal presentation through the package office relationship."""
79
+ relationships = _required_relationships(package, "_rels/.rels")
80
+ candidates = [item for item in relationships
81
+ if item.attrib.get("Type") in OFFICE_DOCUMENT_TYPES]
82
+ if not candidates:
83
+ raise BrewdocError("OOXML office document relationship is missing")
84
+ if len(candidates) > 1:
85
+ raise BrewdocError("multiple OOXML office document relationships")
86
+ relationship = candidates[0]
87
+ relation_id = relationship.attrib.get("Id")
88
+ if not relation_id:
89
+ raise BrewdocError("OOXML office document relationship id is missing")
90
+ if sum(item.attrib.get("Id") == relation_id for item in relationships) > 1:
91
+ raise BrewdocError("OOXML office document relationship %s occurs more than once"
92
+ % relation_id)
93
+ if relationship.attrib.get("TargetMode") == "External":
94
+ raise BrewdocError("OOXML office document relationship is external")
95
+ return _resolve_ooxml_part("", relationship.attrib.get("Target", ""))
96
+
97
+
98
+ def _related_part(relationships: tuple[ET.Element, ...], relation_id: str,
99
+ allowed_types: set[str], source_part: str, noun: str,
100
+ *, external: bool = False) -> tuple[str, ET.Element]:
101
+ """Resolve one exact typed relationship after rejecting ambiguity and unsafe targets."""
102
+ matches = [item for item in relationships if item.attrib.get("Id") == relation_id]
103
+ if not matches:
104
+ raise BrewdocError("%s %s is missing in %s" % (noun, relation_id, source_part))
105
+ if len(matches) > 1:
106
+ raise BrewdocError("%s %s occurs more than once in %s"
107
+ % (noun, relation_id, source_part))
108
+ relationship = matches[0]
109
+ if relationship.attrib.get("Type") not in allowed_types:
110
+ raise BrewdocError("%s %s has the wrong type in %s"
111
+ % (noun, relation_id, source_part))
112
+ if relationship.attrib.get("TargetMode") == "External" and not external:
113
+ raise BrewdocError("%s %s is external in %s" % (noun, relation_id, source_part))
114
+ target = relationship.attrib.get("Target", "")
115
+ if not target:
116
+ raise BrewdocError("OOXML relationship target is missing")
117
+ if relationship.attrib.get("TargetMode") == "External":
118
+ return target, relationship
119
+ return _resolve_ooxml_part(source_part, target), relationship
120
+
121
+
122
+ def _shape_tree(root: ET.Element, noun: str, part: str) -> ET.Element:
123
+ """Return a required common-slide shape tree from its named part."""
124
+ common_slide = _child(root, "cSld")
125
+ tree = _child(common_slide, "spTree") if common_slide is not None else None
126
+ if tree is None:
127
+ raise BrewdocError("%s shape tree is missing in %s" % (noun, part))
128
+ return tree
129
+
130
+
131
+ def _shape_nodes(tree: ET.Element):
132
+ """Yield renderable shape-tree children in XML order, descending into groups in place."""
133
+ for child in tree:
134
+ name = _local_name(child.tag)
135
+ if name == "grpSp":
136
+ yield from _shape_nodes(child)
137
+ elif name == "AlternateContent":
138
+ fallback = _child(child, "Fallback")
139
+ if fallback is not None:
140
+ yield from _shape_nodes(fallback)
141
+ elif name in SHAPE_NAMES:
142
+ yield child
143
+
144
+
145
+ def _text_body(node: ET.Element) -> ET.Element | None:
146
+ """Return a node's direct text body without inheriting layout or nested content."""
147
+ return next((child for child in node if _local_name(child.tag) == "txBody"), None)
148
+
149
+
150
+ def _paragraph_lines(paragraph: ET.Element, tally: dict) -> list[str]:
151
+ """Preserve run order while turning DrawingML tabs and breaks into plain lines."""
152
+ lines, current = [], []
153
+ for element in paragraph.iter():
154
+ name = _local_name(element.tag)
155
+ if name == "t":
156
+ current.append(element.text or "")
157
+ elif name == "tab":
158
+ current.append(" ")
159
+ elif name == "br":
160
+ lines.append("".join(current))
161
+ current = []
162
+ lines.append("".join(current))
163
+ return [text for text in (sanitise(line, tally).strip() for line in lines) if text]
164
+
165
+
166
+ def _shape_lines(shape: ET.Element, tally: dict) -> list[str]:
167
+ """Read direct text-body paragraphs through the shared ASCII path."""
168
+ body = _text_body(shape)
169
+ if body is None:
170
+ return []
171
+ lines = []
172
+ for paragraph in body:
173
+ if _local_name(paragraph.tag) == "p":
174
+ lines.extend(_paragraph_lines(paragraph, tally))
175
+ return lines
176
+
177
+
178
+ def _placeholder_type(shape: ET.Element) -> str | None:
179
+ """Return a shape's declared placeholder type without layout inheritance."""
180
+ non_visual = _child(shape, "nvSpPr")
181
+ properties = _child(non_visual, "nvPr") if non_visual is not None else None
182
+ placeholder = _child(properties, "ph") if properties is not None else None
183
+ return placeholder.attrib.get("type") if placeholder is not None else None
184
+
185
+
186
+ def _title_shape(tree: ET.Element, tally: dict) -> tuple[ET.Element | None, str]:
187
+ """Select the first non-empty title placeholder and its sanitized label."""
188
+ for shape in _shape_nodes(tree):
189
+ if _local_name(shape.tag) != "sp" or _placeholder_type(shape) not in TITLE_PLACEHOLDERS:
190
+ continue
191
+ lines = _shape_lines(shape, tally)
192
+ if lines:
193
+ return shape, " ".join(lines)
194
+ return None, "Untitled"
195
+
196
+
197
+ def _table_rows(table: ET.Element, tally: dict) -> list[list[str]]:
198
+ """Keep DrawingML row and cell order while blanking merge continuations."""
199
+ rows = []
200
+ for row in table:
201
+ if _local_name(row.tag) != "tr":
202
+ continue
203
+ cells = []
204
+ for cell in row:
205
+ if _local_name(cell.tag) != "tc":
206
+ continue
207
+ if (cell.attrib.get("hMerge", "").lower() in TRUE_VALUES
208
+ or cell.attrib.get("vMerge", "").lower() in TRUE_VALUES):
209
+ cells.append("")
210
+ continue
211
+ lines = _shape_lines(cell, tally)
212
+ cells.append(" ".join(lines))
213
+ rows.append(cells)
214
+ return rows
215
+
216
+
217
+ def _clean_attribute(value: str | None, tally: dict, default: str) -> str:
218
+ """Normalize emitted XML metadata to one ASCII line."""
219
+ text = default if value is None or value == "" else value
220
+ return sanitise(text, tally).replace("\n", " ").strip()
221
+
222
+
223
+ def _picture_line(picture: ET.Element, package: zipfile.ZipFile,
224
+ slide_part: str, relationships: tuple[ET.Element, ...], tally: dict) -> str:
225
+ """Describe one picture from OOXML metadata without decoding or fetching pixels."""
226
+ properties = next((item for item in picture.iter() if _local_name(item.tag) == "cNvPr"), None)
227
+ blip = next((item for item in picture.iter() if _local_name(item.tag) == "blip"), None)
228
+ relation_id = None
229
+ if blip is not None:
230
+ relation_id = (_relationship_attribute(blip, "embed")
231
+ or _relationship_attribute(blip, "link"))
232
+ if not relation_id:
233
+ raise BrewdocError("picture relationship is missing in %s" % slide_part)
234
+ target, relationship = _related_part(relationships, relation_id, IMAGE_TYPES, slide_part,
235
+ "picture relationship", external=True)
236
+ if relationship.attrib.get("TargetMode") == "External":
237
+ source, byte_count = "external", "unknown"
238
+ else:
239
+ source, byte_count = "embedded", str(len(_required_bytes(package, target)))
240
+
241
+ shape_properties = _child(picture, "spPr")
242
+ transform = _child(shape_properties, "xfrm") if shape_properties is not None else None
243
+ extent = _child(transform, "ext") if transform is not None else None
244
+ cx = _clean_attribute(extent.attrib.get("cx") if extent is not None else None,
245
+ tally, "unknown")
246
+ cy = _clean_attribute(extent.attrib.get("cy") if extent is not None else None,
247
+ tally, "unknown")
248
+ name = _clean_attribute(properties.attrib.get("name") if properties is not None else None,
249
+ tally, "none")
250
+ alt = _clean_attribute(properties.attrib.get("descr") if properties is not None else None,
251
+ tally, "none")
252
+ caption = _clean_attribute(properties.attrib.get("title") if properties is not None else None,
253
+ tally, "none")
254
+ return ('Image: name="%s"; size=%sx%s EMU; alt="%s"; caption="%s"; '
255
+ 'source=%s; bytes=%s' % (name, cx, cy, alt, caption, source, byte_count))
256
+
257
+
258
+ def _notes_block(package: zipfile.ZipFile, slide_part: str,
259
+ relationships: tuple[ET.Element, ...], tally: dict) -> tuple | None:
260
+ """Read one optional notes part and retain only body-placeholder text."""
261
+ notes = [item for item in relationships if item.attrib.get("Type") in NOTES_TYPES]
262
+ if not notes:
263
+ return None
264
+ if len(notes) > 1:
265
+ raise BrewdocError("multiple notes relationships in %s" % slide_part)
266
+ relation_id = notes[0].attrib.get("Id")
267
+ if not relation_id:
268
+ raise BrewdocError("notes relationship id is missing in %s" % slide_part)
269
+ notes_part, _relationship = _related_part(
270
+ relationships, relation_id, NOTES_TYPES, slide_part, "notes relationship")
271
+ root = _required_xml(package, notes_part)
272
+ if _local_name(root.tag) != "notes":
273
+ raise BrewdocError("OOXML notes part has unexpected root: %s" % notes_part)
274
+ tree = _shape_tree(root, "notes", notes_part)
275
+ lines = []
276
+ for shape in _shape_nodes(tree):
277
+ if _local_name(shape.tag) == "sp" and _placeholder_type(shape) == "body":
278
+ lines.extend(_shape_lines(shape, tally))
279
+ if not lines:
280
+ return None
281
+ tally["text_regions"] += 1
282
+ return "text", ["Speaker notes:", *lines]
283
+
284
+
285
+ def _slide_blocks(tree: ET.Element, title_shape: ET.Element | None,
286
+ package: zipfile.ZipFile, slide_part: str,
287
+ relationships: tuple[ET.Element, ...], tally: dict) -> list[tuple]:
288
+ """Render supported shape-tree children in XML order, then append speaker notes."""
289
+ blocks = []
290
+ for shape in _shape_nodes(tree):
291
+ if shape is title_shape:
292
+ continue
293
+ name = _local_name(shape.tag)
294
+ if name == "sp":
295
+ lines = _shape_lines(shape, tally)
296
+ if lines:
297
+ tally["text_regions"] += 1
298
+ blocks.append(("text", lines))
299
+ elif name == "pic":
300
+ tally["text_regions"] += 1
301
+ blocks.append(("text", [_picture_line(
302
+ shape, package, slide_part, relationships, tally)]))
303
+ elif name == "graphicFrame":
304
+ table = next((item for item in shape.iter() if _local_name(item.tag) == "tbl"), None)
305
+ if table is not None:
306
+ tally["tables"] += 1
307
+ blocks.append(("table", _table_rows(table, tally)))
308
+ notes = _notes_block(package, slide_part, relationships, tally)
309
+ if notes is not None:
310
+ blocks.append(notes)
311
+ return blocks
312
+
313
+
314
+ def _render_presentation(path: Path, _sheets=None) -> Rendered:
315
+ """Render every declared slide after validating each required OPC boundary."""
316
+ tally = new_tally()
317
+ units = []
318
+ with reading(path, "presentation"), zipfile.ZipFile(path) as package:
319
+ presentation_part = _presentation_part(package)
320
+ presentation = _required_xml(package, presentation_part)
321
+ if _local_name(presentation.tag) != "presentation":
322
+ raise BrewdocError("OOXML presentation part has unexpected root: %s"
323
+ % presentation_part)
324
+ relationships = _required_relationships(package, _relationship_part(presentation_part))
325
+ slide_list = _child(presentation, "sldIdLst")
326
+ declarations = [] if slide_list is None else [
327
+ item for item in slide_list if _local_name(item.tag) == "sldId"]
328
+ used_relationships = set()
329
+ for ordinal, declaration in enumerate(declarations, 1):
330
+ relation_id = _relationship_attribute(declaration, "id")
331
+ if not relation_id:
332
+ raise BrewdocError("slide relationship id is missing for slide %d" % ordinal)
333
+ if relation_id in used_relationships:
334
+ raise BrewdocError("slide relationship %s is declared more than once"
335
+ % relation_id)
336
+ used_relationships.add(relation_id)
337
+ slide_part, _relationship = _related_part(
338
+ relationships, relation_id, SLIDE_TYPES, presentation_part,
339
+ "slide relationship")
340
+ slide = _required_xml(package, slide_part)
341
+ if _local_name(slide.tag) != "sld":
342
+ raise BrewdocError("OOXML slide part has unexpected root: %s" % slide_part)
343
+ slide_relationships = _optional_relationships(
344
+ package, _relationship_part(slide_part))
345
+ tree = _shape_tree(slide, "slide", slide_part)
346
+ title_shape, title = _title_shape(tree, tally)
347
+ blocks = _slide_blocks(tree, title_shape, package, slide_part,
348
+ slide_relationships, tally)
349
+ units.append((ordinal, title, blocks))
350
+ return _assemble(path, ROUTE.name, ROUTE.unit_kind, len(declarations), units, tally,
351
+ not_carried=ROUTE.not_carried)
352
+
353
+
354
+ ROUTE = Route("presentation", "slide", (".pptx",), _render_presentation,
355
+ ("charts, SmartArt and embedded objects",
356
+ "image pixels, audio and video",
357
+ "animations, transitions, layout and master text, styling and visual positioning"))
358
+
359
+
360
+ def render_presentation(path) -> tuple[str, dict]:
361
+ """Render declared slides in presentation order with stable slide keys."""
362
+ return _render_presentation(Path(path))[:2]