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.
Files changed (53) hide show
  1. markitdown/GUIDE.md +93 -0
  2. markitdown/__about__.py +4 -0
  3. markitdown/__init__.py +34 -0
  4. markitdown/__main__.py +439 -0
  5. markitdown/_base_converter.py +109 -0
  6. markitdown/_exceptions.py +82 -0
  7. markitdown/_hwp_media.py +132 -0
  8. markitdown/_markitdown.py +861 -0
  9. markitdown/_media.py +67 -0
  10. markitdown/_stream_info.py +32 -0
  11. markitdown/_uri_utils.py +75 -0
  12. markitdown/converter_utils/__init__.py +0 -0
  13. markitdown/converter_utils/_image.py +19 -0
  14. markitdown/converter_utils/_xlsx_images.py +114 -0
  15. markitdown/converter_utils/docx/__init__.py +0 -0
  16. markitdown/converter_utils/docx/_images.py +118 -0
  17. markitdown/converter_utils/docx/math/__init__.py +0 -0
  18. markitdown/converter_utils/docx/math/latex_dict.py +291 -0
  19. markitdown/converter_utils/docx/math/omml.py +417 -0
  20. markitdown/converter_utils/docx/pre_process.py +290 -0
  21. markitdown/converters/__init__.py +56 -0
  22. markitdown/converters/_audio_converter.py +106 -0
  23. markitdown/converters/_bing_serp_converter.py +120 -0
  24. markitdown/converters/_csv_converter.py +133 -0
  25. markitdown/converters/_cu_converter.py +570 -0
  26. markitdown/converters/_doc_intel_converter.py +258 -0
  27. markitdown/converters/_docx_converter.py +163 -0
  28. markitdown/converters/_epub_converter.py +185 -0
  29. markitdown/converters/_exiftool.py +57 -0
  30. markitdown/converters/_html_converter.py +112 -0
  31. markitdown/converters/_hwp_converter.py +105 -0
  32. markitdown/converters/_image_converter.py +139 -0
  33. markitdown/converters/_ipynb_converter.py +103 -0
  34. markitdown/converters/_llm_caption.py +50 -0
  35. markitdown/converters/_markdownify.py +178 -0
  36. markitdown/converters/_outlook_msg_converter.py +349 -0
  37. markitdown/converters/_pdf_converter.py +593 -0
  38. markitdown/converters/_plain_text_converter.py +66 -0
  39. markitdown/converters/_pptx_converter.py +387 -0
  40. markitdown/converters/_rss_converter.py +488 -0
  41. markitdown/converters/_transcribe_audio.py +60 -0
  42. markitdown/converters/_wikipedia_converter.py +99 -0
  43. markitdown/converters/_xlsx_converter.py +279 -0
  44. markitdown/converters/_youtube_converter.py +256 -0
  45. markitdown/converters/_zip_converter.py +130 -0
  46. markitdown/ocr_triage.py +943 -0
  47. markitdown/py.typed +0 -0
  48. markitdown_gc-0.2.0.dist-info/METADATA +137 -0
  49. markitdown_gc-0.2.0.dist-info/RECORD +53 -0
  50. markitdown_gc-0.2.0.dist-info/WHEEL +4 -0
  51. markitdown_gc-0.2.0.dist-info/entry_points.txt +2 -0
  52. markitdown_gc-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. 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)
@@ -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