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.
- ssmdconvert/__init__.py +64 -0
- ssmdconvert/__main__.py +4 -0
- ssmdconvert/_epub.py +107 -0
- ssmdconvert/_version.py +24 -0
- ssmdconvert/adapters/__init__.py +34 -0
- ssmdconvert/adapters/base.py +14 -0
- ssmdconvert/adapters/common.py +52 -0
- ssmdconvert/adapters/docx.py +52 -0
- ssmdconvert/adapters/epub.py +40 -0
- ssmdconvert/adapters/html.py +158 -0
- ssmdconvert/adapters/markdown.py +50 -0
- ssmdconvert/adapters/pdf.py +41 -0
- ssmdconvert/adapters/ssmd.py +28 -0
- ssmdconvert/adapters/text.py +29 -0
- ssmdconvert/books.py +112 -0
- ssmdconvert/bundle.py +601 -0
- ssmdconvert/chapter_selection.py +57 -0
- ssmdconvert/cli.py +262 -0
- ssmdconvert/converter.py +63 -0
- ssmdconvert/errors.py +30 -0
- ssmdconvert/models.py +99 -0
- ssmdconvert/py.typed +0 -0
- ssmdconvert/render.py +51 -0
- ssmdconvert/speech/__init__.py +19 -0
- ssmdconvert/speech/audit.py +183 -0
- ssmdconvert/speech/escaping.py +24 -0
- ssmdconvert/speech/models.py +115 -0
- ssmdconvert/speech/prepare.py +511 -0
- ssmdconvert-0.1.0.dist-info/METADATA +114 -0
- ssmdconvert-0.1.0.dist-info/RECORD +34 -0
- ssmdconvert-0.1.0.dist-info/WHEEL +5 -0
- ssmdconvert-0.1.0.dist-info/entry_points.txt +2 -0
- ssmdconvert-0.1.0.dist-info/licenses/LICENSE +201 -0
- ssmdconvert-0.1.0.dist-info/top_level.txt +1 -0
ssmdconvert/__init__.py
ADDED
|
@@ -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
|
+
]
|
ssmdconvert/__main__.py
ADDED
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)
|
ssmdconvert/_version.py
ADDED
|
@@ -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
|
+
)
|