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.
- pptx2markdown/__init__.py +13 -0
- pptx2markdown/api.py +69 -0
- pptx2markdown/main_converter/__init__.py +0 -0
- pptx2markdown/main_converter/asset_utils.py +93 -0
- pptx2markdown/main_converter/converter_models.py +323 -0
- pptx2markdown/main_converter/embedded_attachments.py +275 -0
- pptx2markdown/main_converter/heading_rules.py +80 -0
- pptx2markdown/main_converter/package_inputs.py +408 -0
- pptx2markdown/main_converter/ppt_to_pptx.py +254 -0
- pptx2markdown/main_converter/run_pptx_to_markdown.py +1628 -0
- pptx2markdown/main_converter/slide_converter.py +1122 -0
- pptx2markdown/main_converter/structure_analysis_pipeline.py +148 -0
- pptx2markdown/main_converter/table_overlay.py +522 -0
- pptx2markdown/ooxml_security.py +219 -0
- pptx2markdown/pptx_inheritance/__init__.py +25 -0
- pptx2markdown/pptx_inheritance/resolver.py +994 -0
- pptx2markdown/presentation_document.schema.json +122 -0
- pptx2markdown/structure_analyzer/__init__.py +1 -0
- pptx2markdown/structure_analyzer/check_native_table_support.py +335 -0
- pptx2markdown/structure_analyzer/constants.py +23 -0
- pptx2markdown/structure_analyzer/extract_structure_analysis.py +121 -0
- pptx2markdown/structure_analyzer/extractor.py +520 -0
- pptx2markdown/structure_analyzer/pipeline.py +521 -0
- pptx2markdown/structure_analyzer/structure.py +435 -0
- pptx2markdown/structure_analyzer/text_rules.py +60 -0
- pptx2markdown/structure_analyzer/xml_primitives.py +160 -0
- pptx2markdown/table_pipeline/__init__.py +7 -0
- pptx2markdown/table_pipeline/parse.py +193 -0
- pptx2markdown/table_pipeline/render.py +144 -0
- pptx2markdown/table_pipeline/run.py +298 -0
- pptx2markdown/workspace_paths.py +92 -0
- pptx2markdown-0.1.0.dist-info/METADATA +265 -0
- pptx2markdown-0.1.0.dist-info/RECORD +37 -0
- pptx2markdown-0.1.0.dist-info/WHEEL +4 -0
- pptx2markdown-0.1.0.dist-info/entry_points.txt +2 -0
- pptx2markdown-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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"
|