pptx2markdown 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.
Files changed (37) hide show
  1. pptx2markdown/__init__.py +13 -0
  2. pptx2markdown/api.py +69 -0
  3. pptx2markdown/main_converter/__init__.py +0 -0
  4. pptx2markdown/main_converter/asset_utils.py +93 -0
  5. pptx2markdown/main_converter/converter_models.py +323 -0
  6. pptx2markdown/main_converter/embedded_attachments.py +275 -0
  7. pptx2markdown/main_converter/heading_rules.py +80 -0
  8. pptx2markdown/main_converter/package_inputs.py +408 -0
  9. pptx2markdown/main_converter/ppt_to_pptx.py +254 -0
  10. pptx2markdown/main_converter/run_pptx_to_markdown.py +1628 -0
  11. pptx2markdown/main_converter/slide_converter.py +1122 -0
  12. pptx2markdown/main_converter/structure_analysis_pipeline.py +148 -0
  13. pptx2markdown/main_converter/table_overlay.py +522 -0
  14. pptx2markdown/ooxml_security.py +219 -0
  15. pptx2markdown/pptx_inheritance/__init__.py +25 -0
  16. pptx2markdown/pptx_inheritance/resolver.py +994 -0
  17. pptx2markdown/presentation_document.schema.json +122 -0
  18. pptx2markdown/structure_analyzer/__init__.py +1 -0
  19. pptx2markdown/structure_analyzer/check_native_table_support.py +335 -0
  20. pptx2markdown/structure_analyzer/constants.py +23 -0
  21. pptx2markdown/structure_analyzer/extract_structure_analysis.py +121 -0
  22. pptx2markdown/structure_analyzer/extractor.py +520 -0
  23. pptx2markdown/structure_analyzer/pipeline.py +521 -0
  24. pptx2markdown/structure_analyzer/structure.py +435 -0
  25. pptx2markdown/structure_analyzer/text_rules.py +60 -0
  26. pptx2markdown/structure_analyzer/xml_primitives.py +160 -0
  27. pptx2markdown/table_pipeline/__init__.py +7 -0
  28. pptx2markdown/table_pipeline/parse.py +193 -0
  29. pptx2markdown/table_pipeline/render.py +144 -0
  30. pptx2markdown/table_pipeline/run.py +298 -0
  31. pptx2markdown/workspace_paths.py +92 -0
  32. pptx2markdown-0.1.0.dist-info/METADATA +265 -0
  33. pptx2markdown-0.1.0.dist-info/RECORD +37 -0
  34. pptx2markdown-0.1.0.dist-info/WHEEL +4 -0
  35. pptx2markdown-0.1.0.dist-info/entry_points.txt +2 -0
  36. pptx2markdown-0.1.0.dist-info/licenses/LICENSE +202 -0
  37. pptx2markdown-0.1.0.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,13 @@
1
+ """pptx2markdown — convert PPTX/PPT presentations to Markdown."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ __version__ = _pkg_version("pptx2markdown")
8
+ except PackageNotFoundError: # 개발 트리에서 미설치 상태로 import된 경우
9
+ __version__ = "0.0.0"
10
+
11
+ from pptx2markdown.api import convert
12
+
13
+ __all__ = ["convert", "__version__"]
pptx2markdown/api.py ADDED
@@ -0,0 +1,69 @@
1
+ """Programmatic API for pptx2markdown.
2
+
3
+ Example:
4
+ import pptx2markdown
5
+
6
+ pptx2markdown.convert("deck.pptx", output_dir="out")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from pathlib import Path
13
+ from typing import Optional, Sequence, Union
14
+
15
+ PathLike = Union[str, Path]
16
+
17
+
18
+ def convert(
19
+ inputs: Union[PathLike, Sequence[PathLike], None] = None,
20
+ *,
21
+ output_dir: Optional[PathLike] = None,
22
+ work_dir: Optional[PathLike] = None,
23
+ output_format: str = "markdown",
24
+ headings: str = "auto",
25
+ placeholder_inheritance: str = "style",
26
+ inherited_shapes: str = "visible",
27
+ ppt_converter: str = "auto",
28
+ verbose: bool = False,
29
+ ) -> int:
30
+ """Convert PPTX/PPT file(s) to Markdown or JSON.
31
+
32
+ Args:
33
+ inputs: A path or list of paths to ``.pptx``/``.ppt`` files. If omitted,
34
+ every presentation in the current directory is processed.
35
+ output_dir: Where converted output is written
36
+ (default: ``./output``).
37
+ work_dir: Where intermediate artifacts and caches live
38
+ (default: ``./.pptx2markdown``).
39
+ output_format: ``"markdown"`` (default) or ``"json"``.
40
+ Returns:
41
+ Process-style exit code: ``0`` on success, ``1`` if any slide failed.
42
+ """
43
+ from pptx2markdown.main_converter.run_pptx_to_markdown import (
44
+ _build_config,
45
+ _configure_logging,
46
+ run,
47
+ )
48
+
49
+ if inputs is None:
50
+ input_list: list[str] = []
51
+ elif isinstance(inputs, (str, Path)):
52
+ input_list = [str(inputs)]
53
+ else:
54
+ input_list = [str(p) for p in inputs]
55
+
56
+ args = argparse.Namespace(
57
+ inputs=input_list,
58
+ output_dir=str(output_dir) if output_dir else None,
59
+ work_dir=str(work_dir) if work_dir else None,
60
+ output_format=output_format,
61
+ headings=headings,
62
+ pptx_inheritance=placeholder_inheritance,
63
+ inherited_shapes=inherited_shapes,
64
+ ppt_converter=ppt_converter,
65
+ verbose=bool(verbose),
66
+ )
67
+ _configure_logging(verbose=bool(verbose))
68
+ config = _build_config(args)
69
+ return run(config)
File without changes
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import filecmp
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Dict, Optional
7
+
8
+
9
+ def _same_content(path_a: Path, path_b: Path) -> bool:
10
+ try:
11
+ return filecmp.cmp(path_a, path_b, shallow=False)
12
+ except OSError:
13
+ return False
14
+
15
+
16
+ def _find_existing_copy(src: Path, dest_dir: Path) -> Optional[Path]:
17
+ stem = src.stem
18
+ suffix = src.suffix
19
+ candidates = [dest_dir / src.name]
20
+ candidates.extend(sorted(dest_dir.glob(f"{stem}-*{suffix}")))
21
+ for candidate in candidates:
22
+ if candidate.is_file() and _same_content(src, candidate):
23
+ return candidate
24
+ return None
25
+
26
+
27
+ def _copy_asset_to_dir(
28
+ path: str,
29
+ dest_dir: Optional[Path],
30
+ copied_assets: Optional[Dict[str, Path]] = None,
31
+ ) -> Optional[str]:
32
+ if path.startswith("[unresolved-image") or dest_dir is None:
33
+ return None
34
+
35
+ src = Path(path)
36
+ if not src.exists() or not src.is_file():
37
+ return None
38
+
39
+ try:
40
+ src_key = str(src.resolve())
41
+ except Exception:
42
+ src_key = str(src)
43
+
44
+ if copied_assets is not None and src_key in copied_assets:
45
+ return str(copied_assets[src_key])
46
+
47
+ dest_dir.mkdir(parents=True, exist_ok=True)
48
+ existing_copy = _find_existing_copy(src, dest_dir)
49
+ if existing_copy is not None:
50
+ if copied_assets is not None:
51
+ copied_assets[src_key] = existing_copy
52
+ return str(existing_copy)
53
+
54
+ dest = dest_dir / src.name
55
+ if dest.exists():
56
+ try:
57
+ same_file = dest.resolve() == src.resolve()
58
+ except Exception:
59
+ same_file = False
60
+ if not same_file:
61
+ same_file = _same_content(src, dest)
62
+ if not same_file:
63
+ stem = src.stem
64
+ suffix = src.suffix
65
+ n = 2
66
+ while dest.exists():
67
+ candidate = dest_dir / f"{stem}-{n}{suffix}"
68
+ if candidate.is_file() and _same_content(src, candidate):
69
+ dest = candidate
70
+ same_file = True
71
+ break
72
+ dest = candidate
73
+ n += 1
74
+ if same_file:
75
+ if copied_assets is not None:
76
+ copied_assets[src_key] = dest
77
+ return str(dest)
78
+
79
+ shutil.copy2(src, dest)
80
+ if copied_assets is not None:
81
+ copied_assets[src_key] = dest
82
+ return str(dest)
83
+
84
+
85
+ def copy_media_asset(
86
+ path: str,
87
+ media_dir: Optional[Path],
88
+ copied_media: Optional[Dict[str, Path]] = None,
89
+ ) -> str:
90
+ copied = _copy_asset_to_dir(path, dest_dir=media_dir, copied_assets=copied_media)
91
+ if copied is None:
92
+ return path
93
+ return copied
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Dict, List, Literal, Optional
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+
11
+ def utc_now_z() -> str:
12
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ParagraphSegment:
17
+ kind: str
18
+ text: str
19
+ target: Optional[str] = None
20
+ font_pt: Optional[float] = None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ShapeBlock:
25
+ kind: str
26
+ segments: List[ParagraphSegment] = field(default_factory=list)
27
+ level: Optional[int] = None
28
+ list_explicit_none: bool = False
29
+
30
+ @property
31
+ def plain_text(self) -> str:
32
+ parts = [
33
+ segment.text
34
+ for segment in self.segments
35
+ if segment.kind != "break" and segment.text.strip()
36
+ ]
37
+ return " ".join(parts).strip()
38
+
39
+ @property
40
+ def markdown_text(self) -> str:
41
+ rendered: List[str] = []
42
+ for segment in self.segments:
43
+ if segment.kind == "break":
44
+ if rendered:
45
+ rendered.append("\n")
46
+ continue
47
+ text = segment.text.strip()
48
+ if not text:
49
+ continue
50
+ if segment.kind == "hyperlink" and segment.target:
51
+ label = text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
52
+ text = f"[{label}]({segment.target})"
53
+ if rendered and not rendered[-1].endswith("\n"):
54
+ rendered.append(" ")
55
+ rendered.append(text)
56
+ return "".join(rendered).strip()
57
+
58
+ @property
59
+ def has_math(self) -> bool:
60
+ return any(segment.kind in {"math_inline", "math_block"} for segment in self.segments)
61
+
62
+ @property
63
+ def is_math_only(self) -> bool:
64
+ non_empty = [segment for segment in self.segments if segment.text.strip()]
65
+ return bool(non_empty) and all(
66
+ segment.kind in {"math_inline", "math_block"} for segment in non_empty
67
+ )
68
+
69
+
70
+ class SourceDocument(BaseModel):
71
+ """Stable source identity without machine-specific absolute paths."""
72
+
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ name: str = Field(min_length=1)
76
+ format: Literal["pptx", "ppt"]
77
+
78
+ @model_validator(mode="after")
79
+ def validate_name_is_basename(self) -> "SourceDocument":
80
+ if Path(self.name).name != self.name:
81
+ raise ValueError("source name must be a basename")
82
+ return self
83
+
84
+
85
+ class BoundingBox(BaseModel):
86
+ """Shape bounds in PowerPoint English Metric Units (EMU)."""
87
+
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ x: int
91
+ y: int
92
+ width: int = Field(gt=0)
93
+ height: int = Field(gt=0)
94
+ unit: Literal["emu"] = "emu"
95
+
96
+ @classmethod
97
+ def from_corners(cls, corners: tuple[int, int, int, int]) -> "BoundingBox":
98
+ x1, y1, x2, y2 = corners
99
+ return cls(x=x1, y=y1, width=x2 - x1, height=y2 - y1)
100
+
101
+
102
+ class ContentBlock(BaseModel):
103
+ """A rendered content unit in presentation reading order."""
104
+
105
+ model_config = ConfigDict(extra="forbid")
106
+
107
+ kind: Literal[
108
+ "text",
109
+ "heading",
110
+ "list",
111
+ "math",
112
+ "image",
113
+ "chart",
114
+ "smartart",
115
+ "table",
116
+ "attachment",
117
+ "unsupported",
118
+ ]
119
+ content: str = Field(min_length=1)
120
+ shape_id: Optional[str] = None
121
+ heading_level: Optional[int] = Field(default=None, ge=1, le=6)
122
+ bbox: Optional[BoundingBox] = None
123
+ source_part: Literal["slide", "layout", "master"] = "slide"
124
+
125
+ @model_validator(mode="after")
126
+ def validate_heading_contract(self) -> "ContentBlock":
127
+ if not self.content.strip():
128
+ raise ValueError("content must not be blank")
129
+ if self.kind == "heading" and self.heading_level is None:
130
+ raise ValueError("heading blocks require heading_level")
131
+ if self.kind != "heading" and self.heading_level is not None:
132
+ raise ValueError("heading_level is only valid for heading blocks")
133
+ return self
134
+
135
+
136
+ class SlideDocument(BaseModel):
137
+ """JSON-serializable intermediate representation for one slide."""
138
+
139
+ model_config = ConfigDict(extra="forbid")
140
+
141
+ page: int = Field(ge=1)
142
+ hidden: bool = False
143
+ blocks: List[ContentBlock] = Field(default_factory=list)
144
+ notes: Optional[str] = None
145
+
146
+
147
+ class PresentationDocument(BaseModel):
148
+ """Canonical intermediate representation shared by all output formats."""
149
+
150
+ model_config = ConfigDict(extra="forbid")
151
+
152
+ schema_version: Literal["1.0"] = "1.0"
153
+ source: SourceDocument
154
+ slides: List[SlideDocument] = Field(default_factory=list)
155
+
156
+ @model_validator(mode="after")
157
+ def validate_slide_order(self) -> "PresentationDocument":
158
+ pages = [slide.page for slide in self.slides]
159
+ if pages != sorted(set(pages)):
160
+ raise ValueError("slide pages must be unique and strictly increasing")
161
+ return self
162
+
163
+
164
+ def render_slide_markdown(slide: SlideDocument) -> str:
165
+ parts = [f"[Page_{slide.page}]"]
166
+ if slide.hidden:
167
+ parts.append("<!-- hidden: true -->")
168
+ for block in slide.blocks:
169
+ content = block.content.strip()
170
+ if not content:
171
+ continue
172
+ if block.kind == "heading" and block.heading_level is not None:
173
+ content = f"{'#' * block.heading_level} {content}"
174
+ parts.append(content)
175
+ if slide.notes and slide.notes.strip():
176
+ parts.extend(["[Speaker_Notes]", slide.notes.strip()])
177
+ return "\n\n".join(parts).rstrip() + "\n"
178
+
179
+
180
+ def render_presentation_markdown(document: PresentationDocument) -> str:
181
+ rendered_slides = [render_slide_markdown(slide).rstrip() for slide in document.slides]
182
+ merged = "\n\n".join(rendered_slides).strip()
183
+ return f"{merged}\n" if merged else ""
184
+
185
+
186
+ class SlideStats(BaseModel):
187
+ model_config = ConfigDict(extra="forbid")
188
+
189
+ blocks_total: int = 0
190
+ text_blocks: int = 0
191
+ math_blocks: int = 0
192
+ inline_math_segments: int = 0
193
+ block_math_segments: int = 0
194
+ math_conversion_failures: int = 0
195
+ image_blocks: int = 0
196
+ chart_blocks: int = 0
197
+ smartart_blocks: int = 0
198
+ table_blocks: int = 0
199
+ attachment_blocks: int = 0
200
+ table_skipped_blocks: int = 0
201
+ unsupported_blocks: int = 0
202
+ skipped_blocks: int = 0
203
+ resolved_images: int = 0
204
+ unresolved_images: int = 0
205
+ warnings: List[str] = Field(default_factory=list)
206
+ rels_path: Optional[str] = None
207
+
208
+ def to_slide_row_fields(self) -> Dict[str, object]:
209
+ return {
210
+ "blocks_total": self.blocks_total,
211
+ "text_blocks": self.text_blocks,
212
+ "math_blocks": self.math_blocks,
213
+ "inline_math_segments": self.inline_math_segments,
214
+ "block_math_segments": self.block_math_segments,
215
+ "math_conversion_failures": self.math_conversion_failures,
216
+ "image_blocks": self.image_blocks,
217
+ "chart_blocks": self.chart_blocks,
218
+ "smartart_blocks": self.smartart_blocks,
219
+ "table_blocks": self.table_blocks,
220
+ "attachment_blocks": self.attachment_blocks,
221
+ "table_skipped_blocks": self.table_skipped_blocks,
222
+ "unsupported_blocks": self.unsupported_blocks,
223
+ "skipped_blocks": self.skipped_blocks,
224
+ "rels_path": self.rels_path,
225
+ "warnings": list(self.warnings),
226
+ }
227
+
228
+
229
+ class ManifestSummary(BaseModel):
230
+ model_config = ConfigDict(extra="forbid")
231
+
232
+ processed_packages: int = 0
233
+ processed_slides: int = 0
234
+ failed: int = 0
235
+ math_blocks: int = 0
236
+ inline_math_segments: int = 0
237
+ block_math_segments: int = 0
238
+ math_conversion_failures: int = 0
239
+ resolved_images: int = 0
240
+ unresolved_images: int = 0
241
+ chart_blocks: int = 0
242
+ smartart_blocks: int = 0
243
+ table_blocks: int = 0
244
+ attachment_blocks: int = 0
245
+ table_skipped_blocks: int = 0
246
+
247
+ def add_slide(self, stats: SlideStats) -> None:
248
+ self.processed_slides += 1
249
+ self.math_blocks += stats.math_blocks
250
+ self.inline_math_segments += stats.inline_math_segments
251
+ self.block_math_segments += stats.block_math_segments
252
+ self.math_conversion_failures += stats.math_conversion_failures
253
+ self.resolved_images += stats.resolved_images
254
+ self.unresolved_images += stats.unresolved_images
255
+ self.chart_blocks += stats.chart_blocks
256
+ self.smartart_blocks += stats.smartart_blocks
257
+ self.table_blocks += stats.table_blocks
258
+ self.attachment_blocks += stats.attachment_blocks
259
+ self.table_skipped_blocks += stats.table_skipped_blocks
260
+
261
+
262
+ class ConversionManifest(BaseModel):
263
+ model_config = ConfigDict(extra="forbid")
264
+
265
+ started_at: str = Field(default_factory=utc_now_z)
266
+ packages: List[Dict[str, object]] = Field(default_factory=list)
267
+ summary: ManifestSummary = Field(default_factory=ManifestSummary)
268
+ finished_at: Optional[str] = None
269
+
270
+ def mark_finished(self) -> None:
271
+ self.finished_at = utc_now_z()
272
+
273
+
274
+ class PreparedPackage(BaseModel):
275
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
276
+
277
+ package_dir: Path
278
+ source_pptx_path: Path
279
+
280
+ @property
281
+ def source_stem(self) -> str:
282
+ stem = self.source_pptx_path.stem.strip()
283
+ return stem or self.package_dir.name
284
+
285
+ @property
286
+ def package_dir_name(self) -> str:
287
+ return self.source_stem
288
+
289
+ @property
290
+ def name(self) -> str:
291
+ return self.package_dir.name
292
+
293
+ @property
294
+ def output_markdown_name(self) -> str:
295
+ return f"{self.source_stem}.md"
296
+
297
+ def output_markdown_path(self, output_dir: Path) -> Path:
298
+ return output_dir / self.name / self.output_markdown_name
299
+
300
+ @property
301
+ def output_json_name(self) -> str:
302
+ return f"{self.source_stem}.json"
303
+
304
+ def output_json_path(self, output_dir: Path) -> Path:
305
+ return output_dir / self.name / self.output_json_name
306
+
307
+ def with_package_dir(self, package_dir: Path) -> "PreparedPackage":
308
+ return self.model_copy(update={"package_dir": package_dir})
309
+
310
+
311
+ # 전체 변환 파이프라인의 정규화된 실행 설정이다.
312
+ class ConverterConfig(BaseModel):
313
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
314
+
315
+ cwd: Path
316
+ output_dir: Path
317
+ inputs: List[str] = Field(default_factory=list)
318
+ output_format: str = "markdown"
319
+ heading_mode: str = "auto"
320
+ strict: bool = False
321
+ pptx_inheritance: str = "style"
322
+ inherited_shapes: str = "visible"
323
+ ppt_converter: str = "auto"