markitdown-gc 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.
- markitdown/GUIDE.md +93 -0
- markitdown/__about__.py +4 -0
- markitdown/__init__.py +34 -0
- markitdown/__main__.py +439 -0
- markitdown/_base_converter.py +109 -0
- markitdown/_exceptions.py +82 -0
- markitdown/_hwp_media.py +132 -0
- markitdown/_markitdown.py +861 -0
- markitdown/_media.py +67 -0
- markitdown/_stream_info.py +32 -0
- markitdown/_uri_utils.py +75 -0
- markitdown/converter_utils/__init__.py +0 -0
- markitdown/converter_utils/_image.py +19 -0
- markitdown/converter_utils/_xlsx_images.py +114 -0
- markitdown/converter_utils/docx/__init__.py +0 -0
- markitdown/converter_utils/docx/_images.py +118 -0
- markitdown/converter_utils/docx/math/__init__.py +0 -0
- markitdown/converter_utils/docx/math/latex_dict.py +291 -0
- markitdown/converter_utils/docx/math/omml.py +417 -0
- markitdown/converter_utils/docx/pre_process.py +290 -0
- markitdown/converters/__init__.py +56 -0
- markitdown/converters/_audio_converter.py +106 -0
- markitdown/converters/_bing_serp_converter.py +120 -0
- markitdown/converters/_csv_converter.py +133 -0
- markitdown/converters/_cu_converter.py +570 -0
- markitdown/converters/_doc_intel_converter.py +258 -0
- markitdown/converters/_docx_converter.py +163 -0
- markitdown/converters/_epub_converter.py +185 -0
- markitdown/converters/_exiftool.py +57 -0
- markitdown/converters/_html_converter.py +112 -0
- markitdown/converters/_hwp_converter.py +105 -0
- markitdown/converters/_image_converter.py +139 -0
- markitdown/converters/_ipynb_converter.py +103 -0
- markitdown/converters/_llm_caption.py +50 -0
- markitdown/converters/_markdownify.py +178 -0
- markitdown/converters/_outlook_msg_converter.py +349 -0
- markitdown/converters/_pdf_converter.py +593 -0
- markitdown/converters/_plain_text_converter.py +66 -0
- markitdown/converters/_pptx_converter.py +387 -0
- markitdown/converters/_rss_converter.py +488 -0
- markitdown/converters/_transcribe_audio.py +60 -0
- markitdown/converters/_wikipedia_converter.py +99 -0
- markitdown/converters/_xlsx_converter.py +279 -0
- markitdown/converters/_youtube_converter.py +256 -0
- markitdown/converters/_zip_converter.py +130 -0
- markitdown/ocr_triage.py +943 -0
- markitdown/py.typed +0 -0
- markitdown_gc-0.2.0.dist-info/METADATA +137 -0
- markitdown_gc-0.2.0.dist-info/RECORD +53 -0
- markitdown_gc-0.2.0.dist-info/WHEEL +4 -0
- markitdown_gc-0.2.0.dist-info/entry_points.txt +2 -0
- markitdown_gc-0.2.0.dist-info/licenses/LICENSE +21 -0
- markitdown_gc-0.2.0.dist-info/licenses/ThirdPartyNotices.md +232 -0
markitdown/_media.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Original image extraction. Names and diagnostics never use document text."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import html
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_media_options(options):
|
|
13
|
+
if options.get("extract_media") and options.get("keep_data_uris"):
|
|
14
|
+
raise ValueError("extract_media and keep_data_uris are mutually exclusive")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_media(data, ext, **options):
|
|
18
|
+
check_media_options(options)
|
|
19
|
+
ext = ext.lower().lstrip(".")
|
|
20
|
+
if not re.fullmatch(r"[a-z0-9]{1,12}", ext):
|
|
21
|
+
raise ValueError("Unsafe media extension")
|
|
22
|
+
directory = Path(options["extract_media"]).absolute()
|
|
23
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
name = hashlib.sha256(data).hexdigest()[:12] + "." + ext
|
|
25
|
+
prefix = name.split(".", 1)[0]
|
|
26
|
+
# Equal content may have extension aliases (jpg/jpeg). Keep one evidence file.
|
|
27
|
+
existing_files = sorted(directory.glob(prefix + ".*"))
|
|
28
|
+
for existing in existing_files:
|
|
29
|
+
if (
|
|
30
|
+
existing.is_symlink()
|
|
31
|
+
or not existing.is_file()
|
|
32
|
+
or existing.read_bytes() != data
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("Media hash collision or unsafe destination")
|
|
35
|
+
if existing_files:
|
|
36
|
+
name = existing_files[0].name
|
|
37
|
+
target = directory / name
|
|
38
|
+
if target.is_symlink() or target.resolve().parent != directory.resolve():
|
|
39
|
+
raise ValueError("Unsafe media destination")
|
|
40
|
+
try:
|
|
41
|
+
with target.open("xb") as output:
|
|
42
|
+
output.write(data)
|
|
43
|
+
except FileExistsError:
|
|
44
|
+
if target.read_bytes() != data:
|
|
45
|
+
raise ValueError("Media hash collision")
|
|
46
|
+
base = options.get("media_base_dir") or os.getcwd()
|
|
47
|
+
return quote(os.path.relpath(target, base).replace(os.sep, "/"), safe="/.-_")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def image_html(stream, info, **options):
|
|
51
|
+
ext = info.extension or mimetypes.guess_extension(info.mimetype or "") or ".bin"
|
|
52
|
+
path = save_media(stream.read(), ext, **options)
|
|
53
|
+
return '<img src="' + html.escape(path, quote=True) + '" alt="Image">'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_members(archive):
|
|
57
|
+
seen = set()
|
|
58
|
+
for name in archive.namelist():
|
|
59
|
+
parts = name.replace("\\", "/").split("/")
|
|
60
|
+
if (
|
|
61
|
+
name.startswith(("/", "\\"))
|
|
62
|
+
or ".." in parts
|
|
63
|
+
or ":" in name
|
|
64
|
+
or name.casefold() in seen
|
|
65
|
+
):
|
|
66
|
+
raise ValueError("Unsafe or colliding package member")
|
|
67
|
+
seen.add(name.casefold())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(kw_only=True, frozen=True)
|
|
6
|
+
class StreamInfo:
|
|
7
|
+
"""The StreamInfo class is used to store information about a file stream.
|
|
8
|
+
All fields can be None, and will depend on how the stream was opened.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
mimetype: Optional[str] = None
|
|
12
|
+
extension: Optional[str] = None
|
|
13
|
+
charset: Optional[str] = None
|
|
14
|
+
filename: Optional[str] = (
|
|
15
|
+
None # From local path, url, or Content-Disposition header
|
|
16
|
+
)
|
|
17
|
+
local_path: Optional[str] = None # If read from disk
|
|
18
|
+
url: Optional[str] = None # If read from url
|
|
19
|
+
|
|
20
|
+
def copy_and_update(self, *args, **kwargs):
|
|
21
|
+
"""Copy the StreamInfo object and update it with the given StreamInfo
|
|
22
|
+
instance and/or other keyword arguments."""
|
|
23
|
+
new_info = asdict(self)
|
|
24
|
+
|
|
25
|
+
for si in args:
|
|
26
|
+
assert isinstance(si, StreamInfo)
|
|
27
|
+
new_info.update({k: v for k, v in asdict(si).items() if v is not None})
|
|
28
|
+
|
|
29
|
+
if len(kwargs) > 0:
|
|
30
|
+
new_info.update(kwargs)
|
|
31
|
+
|
|
32
|
+
return StreamInfo(**new_info)
|
markitdown/_uri_utils.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import ntpath
|
|
3
|
+
import os
|
|
4
|
+
from typing import Tuple, Dict
|
|
5
|
+
from urllib.request import url2pathname
|
|
6
|
+
from urllib.parse import urlparse, unquote_to_bytes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_unc_or_device_path(path: str) -> bool:
|
|
10
|
+
"""Recognize Windows UNC and device namespace prefixes on any platform."""
|
|
11
|
+
drive, _ = ntpath.splitdrive(path)
|
|
12
|
+
return drive.replace("\\", "/").startswith("//")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def file_uri_to_path(file_uri: str) -> Tuple[str | None, str]:
|
|
16
|
+
"""Convert a file URI to a path, rejecting UNC and Windows device paths."""
|
|
17
|
+
parsed = urlparse(file_uri)
|
|
18
|
+
if parsed.scheme != "file":
|
|
19
|
+
raise ValueError(f"Not a file URL: {file_uri}")
|
|
20
|
+
|
|
21
|
+
decoded_path = unquote_to_bytes(parsed.path).replace(b"\\", b"/")
|
|
22
|
+
if decoded_path.startswith(b"//"):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Unsupported file URI: {file_uri}. "
|
|
25
|
+
"UNC and Windows device paths are not supported."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
netloc = parsed.netloc if parsed.netloc else None
|
|
29
|
+
path = url2pathname(parsed.path)
|
|
30
|
+
if os.name == "nt" and path[:1] in "/\\" and path[2:3] == ":":
|
|
31
|
+
path = path[1:]
|
|
32
|
+
path = os.path.abspath(path)
|
|
33
|
+
|
|
34
|
+
if _is_unc_or_device_path(path):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unsupported file URI: {file_uri}. "
|
|
37
|
+
"UNC and Windows device paths are not supported."
|
|
38
|
+
)
|
|
39
|
+
return netloc, path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_data_uri(uri: str) -> Tuple[str | None, Dict[str, str], bytes]:
|
|
43
|
+
if uri[:5].lower() != "data:":
|
|
44
|
+
raise ValueError("Not a data URI")
|
|
45
|
+
|
|
46
|
+
header, _, data = uri.partition(",")
|
|
47
|
+
if not _:
|
|
48
|
+
raise ValueError("Malformed data URI, missing ',' separator")
|
|
49
|
+
|
|
50
|
+
meta = header[5:] # Strip 'data:'
|
|
51
|
+
parts = meta.split(";")
|
|
52
|
+
|
|
53
|
+
is_base64 = False
|
|
54
|
+
# Ends with base64?
|
|
55
|
+
if parts[-1].lower() == "base64":
|
|
56
|
+
parts.pop()
|
|
57
|
+
is_base64 = True
|
|
58
|
+
|
|
59
|
+
mime_type = None # Normally this would default to text/plain but we won't assume
|
|
60
|
+
if len(parts) and len(parts[0]) > 0:
|
|
61
|
+
# First part is the mime type
|
|
62
|
+
mime_type = parts.pop(0)
|
|
63
|
+
|
|
64
|
+
attributes: Dict[str, str] = {}
|
|
65
|
+
for part in parts:
|
|
66
|
+
# Handle key=value pairs in the middle
|
|
67
|
+
if "=" in part:
|
|
68
|
+
key, value = part.split("=", 1)
|
|
69
|
+
attributes[key.lower()] = value
|
|
70
|
+
elif len(part) > 0:
|
|
71
|
+
attributes[part.lower()] = ""
|
|
72
|
+
|
|
73
|
+
content = base64.b64decode(data) if is_base64 else unquote_to_bytes(data)
|
|
74
|
+
|
|
75
|
+
return mime_type, attributes, content
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared validation for Office image-rendering hooks."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup, Doctype
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _parse_image_html(fragment: Optional[str]) -> Optional[BeautifulSoup]:
|
|
9
|
+
if fragment is not None and not isinstance(fragment, str):
|
|
10
|
+
raise TypeError("_image_to_html must return an HTML string or None")
|
|
11
|
+
if fragment is None or not fragment.strip():
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
soup = BeautifulSoup(fragment, "html.parser")
|
|
15
|
+
if soup.find(["html", "head", "body"]) or any(
|
|
16
|
+
isinstance(node, Doctype) for node in soup.descendants
|
|
17
|
+
):
|
|
18
|
+
raise ValueError("_image_to_html must return a fragment, not a document")
|
|
19
|
+
return soup
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Read spreadsheet drawing images without reloading or resaving the workbook."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import mimetypes
|
|
5
|
+
import posixpath
|
|
6
|
+
import zipfile
|
|
7
|
+
from typing import Any, BinaryIO, Callable, Optional
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
from defusedxml import ElementTree as ET
|
|
11
|
+
|
|
12
|
+
from .._stream_info import StreamInfo
|
|
13
|
+
from ._image import _parse_image_html
|
|
14
|
+
|
|
15
|
+
_NS = {
|
|
16
|
+
"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
|
17
|
+
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
18
|
+
"xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
|
|
19
|
+
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
20
|
+
}
|
|
21
|
+
_REL_ID = "{" + _NS["r"] + "}id"
|
|
22
|
+
_EMBED = "{" + _NS["r"] + "}embed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _relationships(
|
|
26
|
+
archive: zipfile.ZipFile, part: str, kind: Optional[str] = None
|
|
27
|
+
) -> dict[str, str]:
|
|
28
|
+
directory, filename = posixpath.split(part)
|
|
29
|
+
rels = posixpath.join(directory, "_rels", filename + ".rels")
|
|
30
|
+
if rels not in archive.namelist():
|
|
31
|
+
return {}
|
|
32
|
+
root = ET.fromstring(archive.read(rels))
|
|
33
|
+
return {
|
|
34
|
+
rel.attrib["Id"]: posixpath.normpath(
|
|
35
|
+
posixpath.join(directory, unquote(rel.attrib["Target"]))
|
|
36
|
+
).lstrip("/")
|
|
37
|
+
for rel in root
|
|
38
|
+
if rel.get("TargetMode") != "External"
|
|
39
|
+
and (kind is None or rel.attrib["Type"].endswith("/" + kind))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _XlsxImages:
|
|
44
|
+
def __init__(self, file_stream: BinaryIO):
|
|
45
|
+
self._sheets: dict[str, list[tuple[bytes, StreamInfo]]] = {}
|
|
46
|
+
with zipfile.ZipFile(file_stream) as archive:
|
|
47
|
+
workbook = _relationships(archive, "", "officeDocument")
|
|
48
|
+
workbook_part = next(iter(workbook.values()))
|
|
49
|
+
sheets = ET.fromstring(archive.read(workbook_part))
|
|
50
|
+
sheet_parts = _relationships(archive, workbook_part)
|
|
51
|
+
content_types = ET.fromstring(archive.read("[Content_Types].xml"))
|
|
52
|
+
defaults = {
|
|
53
|
+
item.attrib["Extension"].lower(): item.attrib["ContentType"]
|
|
54
|
+
for item in content_types
|
|
55
|
+
if "Extension" in item.attrib
|
|
56
|
+
}
|
|
57
|
+
overrides = {
|
|
58
|
+
unquote(item.attrib["PartName"]).lstrip("/"): item.attrib["ContentType"]
|
|
59
|
+
for item in content_types
|
|
60
|
+
if "PartName" in item.attrib
|
|
61
|
+
}
|
|
62
|
+
for sheet in sheets.findall("s:sheets/s:sheet", _NS):
|
|
63
|
+
sheet_part = sheet_parts[sheet.attrib[_REL_ID]]
|
|
64
|
+
drawings = ET.fromstring(archive.read(sheet_part)).findall(
|
|
65
|
+
"s:drawing", _NS
|
|
66
|
+
)
|
|
67
|
+
if not drawings:
|
|
68
|
+
continue
|
|
69
|
+
drawing_parts = _relationships(archive, sheet_part, "drawing")
|
|
70
|
+
images = self._sheets.setdefault(sheet.attrib["name"], [])
|
|
71
|
+
for drawing in drawings:
|
|
72
|
+
drawing_part = drawing_parts[drawing.attrib[_REL_ID]]
|
|
73
|
+
image_parts = _relationships(archive, drawing_part, "image")
|
|
74
|
+
drawing_root = ET.fromstring(archive.read(drawing_part))
|
|
75
|
+
# Match openpyxl's image traversal, not its XML serialization order.
|
|
76
|
+
anchors = [
|
|
77
|
+
anchor
|
|
78
|
+
for kind in ("absoluteAnchor", "oneCellAnchor", "twoCellAnchor")
|
|
79
|
+
for anchor in drawing_root.findall(f"xdr:{kind}", _NS)
|
|
80
|
+
]
|
|
81
|
+
for anchor in anchors:
|
|
82
|
+
for blip in anchor.findall(".//a:blip", _NS):
|
|
83
|
+
relationship = blip.get(_EMBED)
|
|
84
|
+
if relationship is None:
|
|
85
|
+
# Linked images are not embedded package content.
|
|
86
|
+
continue
|
|
87
|
+
image_part = image_parts[relationship]
|
|
88
|
+
extension = posixpath.splitext(image_part)[1].lower()
|
|
89
|
+
info = StreamInfo(
|
|
90
|
+
mimetype=overrides.get(image_part)
|
|
91
|
+
or defaults.get(extension.lstrip("."))
|
|
92
|
+
or mimetypes.guess_type(image_part)[0],
|
|
93
|
+
extension=extension,
|
|
94
|
+
filename=posixpath.basename(image_part),
|
|
95
|
+
)
|
|
96
|
+
images.append((archive.read(image_part), info))
|
|
97
|
+
|
|
98
|
+
def to_html(
|
|
99
|
+
self,
|
|
100
|
+
sheet_name: str,
|
|
101
|
+
render: Callable[..., Optional[str]],
|
|
102
|
+
options: dict[str, Any],
|
|
103
|
+
) -> str:
|
|
104
|
+
fragments = []
|
|
105
|
+
for data, info in self._sheets.get(sheet_name, []):
|
|
106
|
+
with io.BytesIO(data) as image_stream:
|
|
107
|
+
fragment = render(image_stream, info, **options)
|
|
108
|
+
soup = _parse_image_html(fragment)
|
|
109
|
+
if soup is None:
|
|
110
|
+
continue
|
|
111
|
+
fragments.append(f"<div>{soup}</div>")
|
|
112
|
+
if not fragments:
|
|
113
|
+
return ""
|
|
114
|
+
return "<h3>Images in this sheet:</h3>" + "".join(fragments)
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Internal bridge from the DOCX image hook to document HTML."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
from bs4.builder import HTMLTreeBuilder
|
|
9
|
+
from mammoth import html, images
|
|
10
|
+
from mammoth.docx.files import InvalidFileReferenceError
|
|
11
|
+
|
|
12
|
+
from ..._stream_info import StreamInfo
|
|
13
|
+
from .._image import _parse_image_html
|
|
14
|
+
|
|
15
|
+
_BLOCK_ELEMENTS = HTMLTreeBuilder.DEFAULT_BLOCK_ELEMENTS | {
|
|
16
|
+
"details",
|
|
17
|
+
"dialog",
|
|
18
|
+
"hgroup",
|
|
19
|
+
"menu",
|
|
20
|
+
"search",
|
|
21
|
+
"summary",
|
|
22
|
+
}
|
|
23
|
+
_PHRASING_CONTAINERS = set(
|
|
24
|
+
"a abbr b bdi bdo cite code data del dfn em i ins kbd label mark q s samp "
|
|
25
|
+
"small span strong sub sup time u var p h1 h2 h3 h4 h5 h6 pre".split()
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _lift_out_of_parent(node: Tag, soup: BeautifulSoup) -> None:
|
|
30
|
+
"""Split a paragraph/inline wrapper around generated block content."""
|
|
31
|
+
parent = node.parent
|
|
32
|
+
assert isinstance(parent, Tag)
|
|
33
|
+
before = soup.new_tag(parent.name)
|
|
34
|
+
before.attrs.update(parent.attrs)
|
|
35
|
+
for sibling in list(parent.contents):
|
|
36
|
+
if sibling is node:
|
|
37
|
+
break
|
|
38
|
+
before.append(sibling.extract())
|
|
39
|
+
if before.contents:
|
|
40
|
+
parent.insert_before(before)
|
|
41
|
+
parent.attrs.pop("id", None)
|
|
42
|
+
parent.insert_before(node.extract())
|
|
43
|
+
if not parent.contents:
|
|
44
|
+
if parent.has_attr("id") and not node.has_attr("id"):
|
|
45
|
+
node["id"] = parent["id"]
|
|
46
|
+
parent.decompose()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _DocxImages:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
render: Callable[..., Optional[str]],
|
|
53
|
+
options: dict[str, Any],
|
|
54
|
+
):
|
|
55
|
+
self.omitted = False
|
|
56
|
+
self._render = render
|
|
57
|
+
self._options = options
|
|
58
|
+
self._attribute = "data-markitdown-image-" + uuid4().hex
|
|
59
|
+
self._fragments: dict[str, BeautifulSoup] = {}
|
|
60
|
+
|
|
61
|
+
def convert_image(self, image: Any) -> list[Any]:
|
|
62
|
+
stream_info = StreamInfo(
|
|
63
|
+
mimetype=image.content_type,
|
|
64
|
+
extension=(
|
|
65
|
+
mimetypes.guess_extension(image.content_type)
|
|
66
|
+
if image.content_type
|
|
67
|
+
else None
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
image_stream = image.open()
|
|
72
|
+
except (KeyError, InvalidFileReferenceError):
|
|
73
|
+
if not self._options.get("extract_media"):
|
|
74
|
+
raise
|
|
75
|
+
self.omitted = True
|
|
76
|
+
return []
|
|
77
|
+
with image_stream as image_stream:
|
|
78
|
+
try:
|
|
79
|
+
fragment = self._render(image_stream, stream_info, **self._options)
|
|
80
|
+
except InvalidFileReferenceError as exc:
|
|
81
|
+
# Mammoth swallows this exception for missing document images,
|
|
82
|
+
# but an error raised by the override must reach the dispatcher.
|
|
83
|
+
raise RuntimeError("_image_to_html failed") from exc
|
|
84
|
+
soup = _parse_image_html(fragment)
|
|
85
|
+
if soup is None:
|
|
86
|
+
return images.data_uri(image)
|
|
87
|
+
|
|
88
|
+
key = str(len(self._fragments))
|
|
89
|
+
self._fragments[key] = soup
|
|
90
|
+
return [html.element("img", {self._attribute: key})]
|
|
91
|
+
|
|
92
|
+
def replace_images(self, html_content: str) -> str:
|
|
93
|
+
if not self._fragments:
|
|
94
|
+
return html_content
|
|
95
|
+
|
|
96
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
97
|
+
for image in soup.find_all("img", attrs={self._attribute: True}):
|
|
98
|
+
key = image[self._attribute]
|
|
99
|
+
assert isinstance(key, str)
|
|
100
|
+
fragment = self._fragments[key]
|
|
101
|
+
blocks = fragment.find_all(_BLOCK_ELEMENTS)
|
|
102
|
+
links = fragment.find_all("a")
|
|
103
|
+
image.replace_with(*list(fragment.contents))
|
|
104
|
+
|
|
105
|
+
# Only generated content is lifted, never its table cell or list item.
|
|
106
|
+
for block in blocks:
|
|
107
|
+
while (
|
|
108
|
+
isinstance(block.parent, Tag)
|
|
109
|
+
and block.parent.name in _PHRASING_CONTAINERS
|
|
110
|
+
):
|
|
111
|
+
_lift_out_of_parent(block, soup)
|
|
112
|
+
for link in links:
|
|
113
|
+
outer_link = link.find_parent("a")
|
|
114
|
+
if outer_link is not None:
|
|
115
|
+
while link.parent is not outer_link:
|
|
116
|
+
_lift_out_of_parent(link, soup)
|
|
117
|
+
_lift_out_of_parent(link, soup)
|
|
118
|
+
return str(soup)
|
|
File without changes
|