ssmdconvert 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.
@@ -0,0 +1,64 @@
1
+ """Source-neutral conversion into Speech Synthesis Markdown (SSMD)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .books import convert_book, inspect_book
6
+ from .bundle import load_book_bundle, validate_book_bundle, write_book_bundle
7
+ from .converter import Converter, convert
8
+ from .errors import (
9
+ BookBundleError,
10
+ BookBundleValidationError,
11
+ BookError,
12
+ ChapterSelectionError,
13
+ MissingDependencyError,
14
+ SSMDConvertError,
15
+ UnsupportedBookSourceError,
16
+ UnsupportedInputError,
17
+ )
18
+ from .models import (
19
+ Book,
20
+ BookChapter,
21
+ BookInspection,
22
+ BookInspectionChapter,
23
+ ConversionResult,
24
+ Document,
25
+ Section,
26
+ SourceInfo,
27
+ )
28
+
29
+ try:
30
+ from ._version import version as __version__
31
+ except ImportError: # generated by setuptools-scm in builds/editable installs
32
+ try:
33
+ from importlib.metadata import version
34
+
35
+ __version__ = version("ssmdconvert")
36
+ except Exception: # pragma: no cover - bare source tree
37
+ __version__ = "0+unknown"
38
+
39
+ __all__ = [
40
+ "Book",
41
+ "BookBundleError",
42
+ "BookBundleValidationError",
43
+ "BookChapter",
44
+ "BookError",
45
+ "BookInspection",
46
+ "BookInspectionChapter",
47
+ "ChapterSelectionError",
48
+ "ConversionResult",
49
+ "Converter",
50
+ "Document",
51
+ "MissingDependencyError",
52
+ "SSMDConvertError",
53
+ "Section",
54
+ "SourceInfo",
55
+ "UnsupportedBookSourceError",
56
+ "UnsupportedInputError",
57
+ "__version__",
58
+ "convert",
59
+ "convert_book",
60
+ "inspect_book",
61
+ "load_book_bundle",
62
+ "validate_book_bundle",
63
+ "write_book_bundle",
64
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
ssmdconvert/_epub.py ADDED
@@ -0,0 +1,107 @@
1
+ """Canonical private EPUB extraction backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from epub2text import ChapterDocument, ChapterMarkdownOptions, EPUBParser, Metadata
10
+
11
+ from .errors import BookError, UnsupportedBookSourceError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ExtractedChapter:
16
+ id: str
17
+ source_number: int
18
+ title: str
19
+ markdown: str
20
+ source_id: str | None
21
+ source_parent_id: str | None
22
+ parent_id: str | None
23
+ href: str | None
24
+ level: int
25
+ char_count: int
26
+ diagnostics: tuple[dict[str, Any], ...]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ExtractedEpub:
31
+ source: Path
32
+ metadata: dict[str, Any]
33
+ chapters: tuple[ExtractedChapter, ...]
34
+
35
+
36
+ def _chapter_markdown_options() -> ChapterMarkdownOptions:
37
+ return ChapterMarkdownOptions(
38
+ include_title=False,
39
+ minimum_body_heading_level=2,
40
+ preserve_emphasis=True,
41
+ preserve_strong=True,
42
+ link_mode="unwrap",
43
+ code_mode="preserve",
44
+ resolve_css_emphasis=True,
45
+ preserve_scene_breaks=True,
46
+ )
47
+
48
+
49
+ def _source_metadata(value: Metadata, source: Path) -> dict[str, Any]:
50
+ metadata: dict[str, Any] = {"title": value.title or source.stem}
51
+ if value.authors:
52
+ metadata["authors"] = list(value.authors)
53
+ for key in ("language", "publisher", "identifier"):
54
+ item = getattr(value, key)
55
+ if item:
56
+ metadata[key] = item
57
+ return metadata
58
+
59
+
60
+ def _extract_chapter(number: int, document: ChapterDocument) -> ExtractedChapter:
61
+ return ExtractedChapter(
62
+ id=f"chapter-{number:04d}",
63
+ source_number=number,
64
+ title=str(document.title or f"Chapter {number}"),
65
+ markdown=document.to_markdown(include_title=True, title_level=1),
66
+ source_id=str(document.id) if document.id is not None else None,
67
+ source_parent_id=(str(document.parent_id) if document.parent_id is not None else None),
68
+ parent_id=None,
69
+ href=document.href,
70
+ level=int(document.level),
71
+ char_count=int(document.char_count),
72
+ diagnostics=tuple(asdict(item) for item in document.diagnostics),
73
+ )
74
+
75
+
76
+ def load_epub(source: str | Path) -> ExtractedEpub:
77
+ """Extract EPUB metadata and ordered chapter documents once for all projections."""
78
+ path = Path(source).expanduser().resolve()
79
+ if not path.is_file():
80
+ raise BookError(f"book source is not a regular file: {path}")
81
+ if path.suffix.lower() != ".epub":
82
+ raise UnsupportedBookSourceError(
83
+ f"unsupported book source format: {path.suffix or '<none>'}"
84
+ )
85
+
86
+ try:
87
+ parser = EPUBParser(str(path))
88
+ documents = parser.get_chapter_documents(options=_chapter_markdown_options())
89
+ metadata = _source_metadata(parser.get_metadata(), path)
90
+ except (OSError, ValueError) as exc:
91
+ raise BookError(f"could not inspect EPUB {path.name}: {exc}") from exc
92
+ if not documents:
93
+ raise BookError(f"EPUB did not yield any readable chapters: {path.name}")
94
+
95
+ extracted_chapters = tuple(
96
+ _extract_chapter(number, document) for number, document in enumerate(documents, start=1)
97
+ )
98
+ source_ids = {
99
+ chapter.source_id: chapter.id
100
+ for chapter in extracted_chapters
101
+ if chapter.source_id is not None
102
+ }
103
+ chapters = tuple(
104
+ replace(chapter, parent_id=source_ids.get(chapter.source_parent_id))
105
+ for chapter in extracted_chapters
106
+ )
107
+ return ExtractedEpub(source=path, metadata=metadata, chapters=chapters)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g3b6cdd1d6'
@@ -0,0 +1,34 @@
1
+ from .base import InputAdapter
2
+ from .docx import DocxAdapter
3
+ from .epub import EpubAdapter
4
+ from .html import HtmlAdapter
5
+ from .markdown import MarkdownAdapter
6
+ from .pdf import PdfAdapter
7
+ from .ssmd import SsmdAdapter
8
+ from .text import TextAdapter
9
+
10
+
11
+ def default_adapters() -> list[InputAdapter]:
12
+ # Specific formats first; TXT is deliberately last.
13
+ return [
14
+ SsmdAdapter(),
15
+ EpubAdapter(),
16
+ MarkdownAdapter(),
17
+ HtmlAdapter(),
18
+ PdfAdapter(),
19
+ DocxAdapter(),
20
+ TextAdapter(),
21
+ ]
22
+
23
+
24
+ __all__ = [
25
+ "DocxAdapter",
26
+ "EpubAdapter",
27
+ "HtmlAdapter",
28
+ "InputAdapter",
29
+ "MarkdownAdapter",
30
+ "PdfAdapter",
31
+ "SsmdAdapter",
32
+ "TextAdapter",
33
+ "default_adapters",
34
+ ]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from ..models import Document
7
+
8
+
9
+ class InputAdapter(Protocol):
10
+ name: str
11
+
12
+ def supports(self, source: Path) -> bool: ...
13
+
14
+ def load(self, source: Path) -> Document: ...
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ _CHAPTER_RE = re.compile(
7
+ r"^(?P<title>(?:chapter|part|book)\s+(?:[0-9ivxlcdm]+|[\w'’-]+)(?:\s*[:.—-]\s*.*)?|prologue|epilogue)\s*$",
8
+ re.IGNORECASE,
9
+ )
10
+
11
+
12
+ def is_ssmd_path(path: Path) -> bool:
13
+ name = path.name.lower()
14
+ return name.endswith((".ssmd", ".ssmd.md"))
15
+
16
+
17
+ def read_text_file(path: Path) -> str:
18
+ data = path.read_bytes()
19
+ for encoding in ("utf-8-sig", "utf-8", "cp1252"):
20
+ try:
21
+ return data.decode(encoding)
22
+ except UnicodeDecodeError:
23
+ continue
24
+ return data.decode("utf-8", errors="replace")
25
+
26
+
27
+ def split_plain_text(text: str) -> list[tuple[str | None, str]]:
28
+ """Split common prose chapter headings while preserving all text."""
29
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
30
+ lines = text.splitlines()
31
+ starts: list[int] = []
32
+ titles: dict[int, str] = {}
33
+ for idx, line in enumerate(lines):
34
+ match = _CHAPTER_RE.match(line.strip())
35
+ if match:
36
+ starts.append(idx)
37
+ titles[idx] = match.group("title").strip()
38
+ if not starts:
39
+ return [(None, text.strip())]
40
+
41
+ sections: list[tuple[str | None, str]] = []
42
+ if starts[0] > 0 and any(line.strip() for line in lines[: starts[0]]):
43
+ sections.append((None, "\n".join(lines[: starts[0]]).strip()))
44
+ for pos, start in enumerate(starts):
45
+ end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
46
+ title = titles[start]
47
+ body = "\n".join(lines[start + 1 : end]).strip()
48
+ markdown = f"# {title}"
49
+ if body:
50
+ markdown += f"\n\n{body}"
51
+ sections.append((title, markdown))
52
+ return sections
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ..errors import MissingDependencyError
6
+ from ..models import Document, Section, SourceInfo
7
+
8
+
9
+ class DocxAdapter:
10
+ name = "docx"
11
+
12
+ def supports(self, source: Path) -> bool:
13
+ return source.suffix.lower() == ".docx"
14
+
15
+ def load(self, source: Path) -> Document:
16
+ try:
17
+ from docx import Document as DocxDocument
18
+ except ImportError as exc: # pragma: no cover - environment dependent
19
+ raise MissingDependencyError(
20
+ "DOCX input requires: pip install 'ssmdconvert[docx]'"
21
+ ) from exc
22
+ docx = DocxDocument(str(source))
23
+ lines: list[str] = []
24
+ for paragraph in docx.paragraphs:
25
+ text = paragraph.text.strip()
26
+ if not text:
27
+ if lines and lines[-1] != "":
28
+ lines.append("")
29
+ continue
30
+ style = (paragraph.style.name if paragraph.style else "").lower()
31
+ if style.startswith("heading"):
32
+ try:
33
+ level = int(style.rsplit(" ", 1)[-1])
34
+ except ValueError:
35
+ level = 1
36
+ lines.extend(["#" * max(1, min(level, 6)) + " " + text, ""])
37
+ else:
38
+ lines.extend([text, ""])
39
+ props = docx.core_properties
40
+ metadata = {"title": props.title or source.stem}
41
+ if props.author:
42
+ metadata["author"] = props.author
43
+ return Document(
44
+ source=SourceInfo(
45
+ format="docx",
46
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
47
+ path=source,
48
+ name=source.name,
49
+ ),
50
+ sections=[Section("section-0001", "\n".join(lines).strip())],
51
+ metadata=metadata,
52
+ )
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .._epub import load_epub
6
+ from ..models import Document, Section, SourceInfo
7
+
8
+
9
+ class EpubAdapter:
10
+ name = "epub"
11
+
12
+ def supports(self, source: Path) -> bool:
13
+ return source.suffix.lower() == ".epub"
14
+
15
+ def load(self, source: Path) -> Document:
16
+ extracted = load_epub(source)
17
+ metadata = dict(extracted.metadata)
18
+ authors = metadata.pop("authors", [])
19
+ if authors:
20
+ metadata["author"] = authors[0]
21
+ sections = [
22
+ Section(
23
+ id=chapter.id,
24
+ markdown=chapter.markdown,
25
+ title=chapter.title,
26
+ level=chapter.level,
27
+ source_ref=chapter.href,
28
+ )
29
+ for chapter in extracted.chapters
30
+ ]
31
+ return Document(
32
+ source=SourceInfo(
33
+ format="epub",
34
+ media_type="application/epub+zip",
35
+ path=extracted.source,
36
+ name=extracted.source.name,
37
+ ),
38
+ sections=sections,
39
+ metadata=metadata,
40
+ )
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from html.parser import HTMLParser
5
+ from pathlib import Path
6
+
7
+ from ..models import Document, Section, SourceInfo
8
+ from .common import read_text_file
9
+
10
+ _BLOCKS = {
11
+ "p",
12
+ "div",
13
+ "section",
14
+ "article",
15
+ "header",
16
+ "footer",
17
+ "aside",
18
+ "nav",
19
+ "ul",
20
+ "ol",
21
+ "blockquote",
22
+ }
23
+
24
+
25
+ class _HTMLToMarkdown(HTMLParser):
26
+ def __init__(self) -> None:
27
+ super().__init__(convert_charrefs=True)
28
+ self.parts: list[str] = []
29
+ self.ignore_depth = 0
30
+ self.title_parts: list[str] = []
31
+ self.in_title = False
32
+ self.first_heading: str | None = None
33
+ self._heading_text: list[str] | None = None
34
+
35
+ def _newline(self, count: int = 1) -> None:
36
+ if not self.parts:
37
+ return
38
+ current = "".join(self.parts)
39
+ missing = count - (len(current) - len(current.rstrip("\n")))
40
+ if missing > 0:
41
+ self.parts.append("\n" * missing)
42
+
43
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
44
+ tag = tag.lower()
45
+ if tag in {"script", "style", "svg", "math"}:
46
+ self.ignore_depth += 1
47
+ return
48
+ if self.ignore_depth:
49
+ return
50
+ if tag == "title":
51
+ self.in_title = True
52
+ elif re.fullmatch(r"h[1-6]", tag):
53
+ self._newline(2)
54
+ level = int(tag[1])
55
+ self.parts.append("#" * level + " ")
56
+ self._heading_text = []
57
+ elif tag in _BLOCKS:
58
+ self._newline(2)
59
+ if tag == "blockquote":
60
+ self.parts.append("> ")
61
+ elif tag == "br":
62
+ self._newline(1)
63
+ elif tag == "li":
64
+ self._newline(1)
65
+ self.parts.append("- ")
66
+ elif tag in {"em", "i"}:
67
+ self.parts.append("*")
68
+ elif tag in {"strong", "b"}:
69
+ self.parts.append("**")
70
+ elif tag == "code":
71
+ self.parts.append("`")
72
+ elif tag == "img":
73
+ alt = dict(attrs).get("alt")
74
+ if alt:
75
+ self.parts.append(str(alt))
76
+
77
+ def handle_endtag(self, tag: str) -> None:
78
+ tag = tag.lower()
79
+ if tag in {"script", "style", "svg", "math"}:
80
+ if self.ignore_depth:
81
+ self.ignore_depth -= 1
82
+ return
83
+ if self.ignore_depth:
84
+ return
85
+ if tag == "title":
86
+ self.in_title = False
87
+ elif re.fullmatch(r"h[1-6]", tag):
88
+ if self.first_heading is None and self._heading_text:
89
+ self.first_heading = "".join(self._heading_text).strip()
90
+ self._heading_text = None
91
+ self._newline(2)
92
+ elif tag in _BLOCKS:
93
+ self._newline(2)
94
+ elif tag in {"em", "i"}:
95
+ self.parts.append("*")
96
+ elif tag in {"strong", "b"}:
97
+ self.parts.append("**")
98
+ elif tag == "code":
99
+ self.parts.append("`")
100
+
101
+ def handle_data(self, data: str) -> None:
102
+ if self.ignore_depth:
103
+ return
104
+ if self.in_title:
105
+ self.title_parts.append(data)
106
+ return
107
+ if self._heading_text is not None:
108
+ self._heading_text.append(data)
109
+ if not data:
110
+ return
111
+ collapsed = re.sub(r"\s+", " ", data)
112
+ core = collapsed.strip()
113
+ if not core:
114
+ if self.parts and not self.parts[-1].endswith((" ", "\n")):
115
+ self.parts.append(" ")
116
+ return
117
+ if collapsed.startswith(" ") and self.parts and not self.parts[-1].endswith((" ", "\n")):
118
+ self.parts.append(" ")
119
+ elif (
120
+ self.parts
121
+ and not self.parts[-1].endswith(("\n", " ", "*", "`", "> ", "- "))
122
+ and not core.startswith((".", ",", ";", ":", "!", "?", "”", "’"))
123
+ ):
124
+ self.parts.append(" ")
125
+ self.parts.append(core)
126
+ if collapsed.endswith(" "):
127
+ self.parts.append(" ")
128
+
129
+ def result(self) -> tuple[str, str | None, str | None]:
130
+ text = "".join(self.parts)
131
+ text = re.sub(r"[ \t]+\n", "\n", text)
132
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
133
+ title = re.sub(r"\s+", " ", "".join(self.title_parts)).strip() or None
134
+ return text, title, self.first_heading
135
+
136
+
137
+ def html_to_markdown(html: str) -> tuple[str, str | None, str | None]:
138
+ parser = _HTMLToMarkdown()
139
+ parser.feed(html)
140
+ parser.close()
141
+ return parser.result()
142
+
143
+
144
+ class HtmlAdapter:
145
+ name = "html"
146
+ suffixes = {".html", ".htm", ".xhtml"}
147
+
148
+ def supports(self, source: Path) -> bool:
149
+ return source.suffix.lower() in self.suffixes
150
+
151
+ def load(self, source: Path) -> Document:
152
+ markdown, title, first_heading = html_to_markdown(read_text_file(source))
153
+ metadata = {"title": title or first_heading or source.stem}
154
+ return Document(
155
+ source=SourceInfo(format="html", media_type="text/html", path=source, name=source.name),
156
+ sections=[Section("section-0001", markdown, first_heading)],
157
+ metadata=metadata,
158
+ )
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from ..models import Document, Section, SourceInfo
7
+ from .common import is_ssmd_path, read_text_file
8
+
9
+ _H1_RE = re.compile(r"^#\s+(.+?)\s*$")
10
+
11
+
12
+ def split_markdown(text: str) -> list[tuple[str | None, str]]:
13
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
14
+ starts = [
15
+ (idx, match.group(1).strip())
16
+ for idx, line in enumerate(lines)
17
+ if (match := _H1_RE.match(line))
18
+ ]
19
+ if not starts:
20
+ return [(None, "\n".join(lines).strip())]
21
+ result: list[tuple[str | None, str]] = []
22
+ if starts[0][0] > 0 and any(line.strip() for line in lines[: starts[0][0]]):
23
+ result.append((None, "\n".join(lines[: starts[0][0]]).strip()))
24
+ for pos, (start, title) in enumerate(starts):
25
+ end = starts[pos + 1][0] if pos + 1 < len(starts) else len(lines)
26
+ result.append((title, "\n".join(lines[start:end]).strip()))
27
+ return result
28
+
29
+
30
+ class MarkdownAdapter:
31
+ name = "markdown"
32
+ suffixes = {".md", ".markdown", ".mdown", ".mkd"}
33
+
34
+ def supports(self, source: Path) -> bool:
35
+ return source.suffix.lower() in self.suffixes and not is_ssmd_path(source)
36
+
37
+ def load(self, source: Path) -> Document:
38
+ text = read_text_file(source)
39
+ sections = [
40
+ Section(id=f"section-{index:04d}", title=title, markdown=body)
41
+ for index, (title, body) in enumerate(split_markdown(text), start=1)
42
+ if body
43
+ ]
44
+ return Document(
45
+ source=SourceInfo(
46
+ format="markdown", media_type="text/markdown", path=source, name=source.name
47
+ ),
48
+ sections=sections or [Section("section-0001", "")],
49
+ metadata={"title": source.stem},
50
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ..errors import MissingDependencyError
6
+ from ..models import Document, Section, SourceInfo
7
+
8
+
9
+ class PdfAdapter:
10
+ name = "pdf"
11
+
12
+ def supports(self, source: Path) -> bool:
13
+ return source.suffix.lower() == ".pdf"
14
+
15
+ def load(self, source: Path) -> Document:
16
+ try:
17
+ from pypdf import PdfReader
18
+ except ImportError as exc: # pragma: no cover - environment dependent
19
+ raise MissingDependencyError(
20
+ "PDF input requires: pip install 'ssmdconvert[pdf]'"
21
+ ) from exc
22
+ reader = PdfReader(str(source))
23
+ parts: list[str] = []
24
+ for page in reader.pages:
25
+ text = (page.extract_text() or "").strip()
26
+ if text:
27
+ parts.append(text)
28
+ metadata: dict[str, str] = {"title": source.stem}
29
+ if reader.metadata:
30
+ if reader.metadata.title:
31
+ metadata["title"] = str(reader.metadata.title)
32
+ if reader.metadata.author:
33
+ metadata["author"] = str(reader.metadata.author)
34
+ text = "\n\n".join(parts)
35
+ return Document(
36
+ source=SourceInfo(
37
+ format="pdf", media_type="application/pdf", path=source, name=source.name
38
+ ),
39
+ sections=[Section("section-0001", text)],
40
+ metadata=metadata,
41
+ )
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ssmd import parse_front_matter
6
+
7
+ from ..models import Document, Section, SourceInfo
8
+ from .common import is_ssmd_path, read_text_file
9
+
10
+
11
+ class SsmdAdapter:
12
+ name = "ssmd"
13
+
14
+ def supports(self, source: Path) -> bool:
15
+ return is_ssmd_path(source)
16
+
17
+ def load(self, source: Path) -> Document:
18
+ raw = read_text_file(source)
19
+ parsed = parse_front_matter(raw)
20
+ metadata = dict(parsed.data) if parsed.present else {}
21
+ metadata.setdefault("title", source.stem)
22
+ return Document(
23
+ source=SourceInfo(
24
+ format="ssmd", media_type="text/markdown", path=source, name=source.name
25
+ ),
26
+ sections=[Section("section-0001", parsed.body if parsed.present else raw)],
27
+ metadata=metadata,
28
+ )