461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
831 lines
31 KiB
Python
831 lines
31 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import csv
|
||
import io
|
||
import json
|
||
import re
|
||
import zlib
|
||
from copy import deepcopy
|
||
from datetime import datetime
|
||
from typing import Any, Iterable, Optional, Sequence, cast
|
||
|
||
from unstructured.documents.coordinates import PixelSpace
|
||
from unstructured.documents.elements import (
|
||
TYPE_TO_TEXT_ELEMENT_MAP,
|
||
CheckBox,
|
||
Element,
|
||
ElementMetadata,
|
||
Formula,
|
||
Image,
|
||
Table,
|
||
Title,
|
||
)
|
||
from unstructured.errors import DecompressedSizeExceededError
|
||
from unstructured.file_utils.ndjson import dumps as ndjson_dumps
|
||
from unstructured.partition.common.common import exactly_one
|
||
from unstructured.utils import Point, dependency_exists, requires_dependencies
|
||
|
||
if dependency_exists("pandas"):
|
||
import pandas as pd
|
||
|
||
# ================================================================================================
|
||
# SERIALIZATION/DESERIALIZATION (SERDE) RELATED FUNCTIONS
|
||
# ================================================================================================
|
||
# These serde functions will likely relocate to `unstructured.documents.elements` since they are
|
||
# so closely related to elements and this staging "brick" is deprecated.
|
||
# ================================================================================================
|
||
|
||
# == DESERIALIZERS ===============================
|
||
|
||
MAX_DECOMPRESSED_SIZE = 200 * 1024 * 1024 # 200MB
|
||
|
||
FORMULA_MARKDOWN_AUTO = "auto"
|
||
FORMULA_MARKDOWN_DISPLAY_MATH = "display_math"
|
||
FORMULA_MARKDOWN_PLAIN = "plain"
|
||
_FORMULA_MARKDOWN_STYLES = frozenset(
|
||
{FORMULA_MARKDOWN_AUTO, FORMULA_MARKDOWN_DISPLAY_MATH, FORMULA_MARKDOWN_PLAIN},
|
||
)
|
||
|
||
# Long OCR-heavy captions often masquerade as Formula; require strong LaTeX-like signals to wrap.
|
||
_FORMULA_PROSE_HINT = re.compile(
|
||
r"\b(was|were|using|calculated|where|respectively|determined|following)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _normalize_formula_for_markdown(text: str) -> str:
|
||
"""Normalize common Unicode math glyphs to LaTeX-friendly tokens.
|
||
|
||
This is intentionally conservative and only handles symbols that are very likely to be
|
||
interpreted as math operators/relations. The Unicode square root sign (``√``) is not
|
||
rewritten: mapping it to ``\\sqrt{}`` would require reparsing the radicand and could
|
||
corrupt expressions like ``√2`` or ``√(x+1)``.
|
||
"""
|
||
# Use `{}` after each LaTeX command so the next character cannot fuse into the name
|
||
# (e.g. x∈S -> x\in{}S, not x\inS).
|
||
substitutions = {
|
||
"−": "-", # Unicode minus -> ASCII hyphen-minus
|
||
"×": r"\times{}",
|
||
"÷": r"\div{}",
|
||
"∞": r"\infty{}",
|
||
"∈": r"\in{}",
|
||
"∉": r"\notin{}",
|
||
"≤": r"\leq{}",
|
||
"≥": r"\geq{}",
|
||
"≈": r"\approx{}",
|
||
"≠": r"\neq{}",
|
||
}
|
||
normalized = text
|
||
for source, target in substitutions.items():
|
||
normalized = normalized.replace(source, target)
|
||
return normalized
|
||
|
||
|
||
def _formula_has_unsafe_markdown_delimiters(text: str) -> bool:
|
||
"""True if wrapping in ``$$`` could break Markdown structure or confuse math renderers."""
|
||
return "$" in text
|
||
|
||
|
||
def _formula_math_signal_score(text: str) -> int:
|
||
"""Rough score of how much the string looks like notation (not prose OCR)."""
|
||
score = 0
|
||
if re.search(r"\\[a-zA-Z]+", text):
|
||
score += 3
|
||
if "^" in text:
|
||
score += 1
|
||
if re.search(r"_(\{|[0-9A-Za-z])", text):
|
||
score += 1
|
||
rel_sym_count = len(re.findall(r"[∈∉≤≥≠≈×÷∞∑∫√∂∇]", text))
|
||
score += min(rel_sym_count * 2, 6)
|
||
if re.search(r"[¼½¾]", text):
|
||
score += 1
|
||
equals_like = len(
|
||
re.findall(
|
||
r"(?<=[A-Za-z0-9\)\]])\s*=\s*(?=[A-Za-z0-9\(\\])",
|
||
text,
|
||
),
|
||
)
|
||
score += min(equals_like, 2)
|
||
# e.g. FFN(x) = max(...); require "(" to be function-like, not "(0) =" from OCR tables
|
||
if re.search(r"(?<=[A-Za-z])\([^)]*\)\s*=\s*", text):
|
||
score += 2
|
||
return score
|
||
|
||
|
||
def _formula_looks_like_prose_sentence(text: str) -> bool:
|
||
return len(text) >= 80 and _FORMULA_PROSE_HINT.search(text) is not None
|
||
|
||
|
||
def _formula_auto_use_display_math(text: str) -> bool:
|
||
if _formula_looks_like_prose_sentence(text):
|
||
return _formula_math_signal_score(text) >= 3
|
||
return _formula_math_signal_score(text) >= 2
|
||
|
||
|
||
def _emit_formula_markdown(
|
||
raw_text: str,
|
||
*,
|
||
normalize_formula: bool,
|
||
formula_markdown_style: str,
|
||
) -> str:
|
||
"""Serialize Formula text for Markdown.
|
||
|
||
Heuristic scoring for ``auto`` runs on **raw** stripped text so Unicode symbols
|
||
are not replaced by ``\\command`` before the score is computed. Normalization
|
||
applies only to text emitted inside ``$$`` blocks. ``plain`` never normalizes.
|
||
"""
|
||
raw = raw_text.strip()
|
||
if not raw:
|
||
return raw
|
||
|
||
style = formula_markdown_style.strip().lower()
|
||
if style not in _FORMULA_MARKDOWN_STYLES:
|
||
raise ValueError(
|
||
"formula_markdown_style must be one of "
|
||
f"{sorted(_FORMULA_MARKDOWN_STYLES)!r}, got {formula_markdown_style!r}",
|
||
)
|
||
if style == FORMULA_MARKDOWN_PLAIN:
|
||
return raw
|
||
|
||
if _formula_has_unsafe_markdown_delimiters(raw):
|
||
return raw
|
||
|
||
use_display_math = False
|
||
if style == FORMULA_MARKDOWN_DISPLAY_MATH:
|
||
use_display_math = True
|
||
elif style == FORMULA_MARKDOWN_AUTO:
|
||
use_display_math = _formula_auto_use_display_math(raw)
|
||
|
||
if not use_display_math:
|
||
return raw
|
||
|
||
body = _normalize_formula_for_markdown(raw) if normalize_formula else raw
|
||
return f"$$\n{body}\n$$"
|
||
|
||
|
||
def elements_from_base64_gzipped_json(b64_encoded_elements: str) -> list[Element]:
|
||
"""Restore Base64-encoded gzipped JSON elements to element objects.
|
||
|
||
This is used to when deserializing `ElementMetadata.orig_elements` from its compressed form in
|
||
JSON and dict forms and perhaps for other purposes.
|
||
"""
|
||
# -- Base64 str -> gzip-encoded (JSON) bytes --
|
||
decoded_b64_bytes = base64.b64decode(b64_encoded_elements)
|
||
# -- undo gzip compression --
|
||
dobj = zlib.decompressobj()
|
||
elements_json_bytes = dobj.decompress(decoded_b64_bytes, max_length=MAX_DECOMPRESSED_SIZE)
|
||
# -- Check if decompression completed successfully --
|
||
if not dobj.eof:
|
||
# Check if we hit the size limit or if data is actually incomplete
|
||
if len(elements_json_bytes) >= MAX_DECOMPRESSED_SIZE:
|
||
raise DecompressedSizeExceededError(
|
||
max_size=MAX_DECOMPRESSED_SIZE,
|
||
)
|
||
else:
|
||
raise zlib.error("Incomplete or corrupted compressed data")
|
||
# -- JSON (bytes) to JSON (str) --
|
||
elements_json_str = elements_json_bytes.decode("utf-8")
|
||
# -- JSON (str) -> dicts --
|
||
element_dicts = json.loads(elements_json_str)
|
||
# -- dicts -> elements --
|
||
return elements_from_dicts(element_dicts)
|
||
|
||
|
||
def elements_from_dicts(element_dicts: Iterable[dict[str, Any]]) -> list[Element]:
|
||
"""Convert a list of element-dicts to a list of elements."""
|
||
elements: list[Element] = []
|
||
|
||
for item in element_dicts:
|
||
element_id: str = item.get("element_id", None)
|
||
metadata = (
|
||
ElementMetadata()
|
||
if item.get("metadata") is None
|
||
else ElementMetadata.from_dict(item["metadata"])
|
||
)
|
||
|
||
if item.get("type") in TYPE_TO_TEXT_ELEMENT_MAP:
|
||
ElementCls = TYPE_TO_TEXT_ELEMENT_MAP[item["type"]]
|
||
elements.append(ElementCls(text=item["text"], element_id=element_id, metadata=metadata))
|
||
elif item.get("type") == "CheckBox":
|
||
elements.append(
|
||
CheckBox(checked=item["checked"], element_id=element_id, metadata=metadata)
|
||
)
|
||
|
||
return elements
|
||
|
||
|
||
# -- legacy aliases for elements_from_dicts() --
|
||
isd_to_elements = elements_from_dicts
|
||
dict_to_elements = elements_from_dicts
|
||
|
||
|
||
def elements_from_json(
|
||
filename: str = "", text: str = "", encoding: str = "utf-8"
|
||
) -> list[Element]:
|
||
"""Loads a list of elements from a JSON file or a string."""
|
||
exactly_one(filename=filename, text=text)
|
||
|
||
if filename:
|
||
with open(filename, encoding=encoding) as f:
|
||
element_dicts = json.load(f)
|
||
else:
|
||
element_dicts = json.loads(text)
|
||
|
||
return elements_from_dicts(element_dicts)
|
||
|
||
|
||
# == SERIALIZERS =================================
|
||
|
||
|
||
def elements_to_base64_gzipped_json(elements: Iterable[Element]) -> str:
|
||
"""Convert `elements` to Base64-encoded gzipped JSON.
|
||
|
||
This is used to when serializing `ElementMetadata.orig_elements` to make it as compact as
|
||
possible when transported as JSON, for example in an HTTP response. This compressed form is also
|
||
present when elements are in dict form ("element_dicts"). This function is not coupled to that
|
||
purpose however and could have other uses.
|
||
"""
|
||
# -- adjust floating-point precision of coordinates down for a more compact str value --
|
||
precision_adjusted_elements = _fix_metadata_field_precision(elements)
|
||
# -- serialize elements as dicts --
|
||
element_dicts = elements_to_dicts(precision_adjusted_elements)
|
||
# -- serialize the dicts to JSON (bytes) --
|
||
json_bytes = json.dumps(element_dicts, sort_keys=True).encode("utf-8")
|
||
# -- compress the JSON bytes with gzip compression --
|
||
deflated_bytes = zlib.compress(json_bytes)
|
||
# -- base64-encode those bytes so they can be serialized as a JSON string value --
|
||
b64_deflated_bytes = base64.b64encode(deflated_bytes)
|
||
# -- convert to a string suitable for serializing in JSON --
|
||
return b64_deflated_bytes.decode("utf-8")
|
||
|
||
|
||
def elements_to_dicts(elements: Iterable[Element]) -> list[dict[str, Any]]:
|
||
"""Convert document elements to element-dicts."""
|
||
return [e.to_dict() for e in elements]
|
||
|
||
|
||
# -- legacy aliases for elements_to_dicts() --
|
||
convert_to_isd = elements_to_dicts
|
||
convert_to_dict = elements_to_dicts
|
||
|
||
|
||
def element_to_md(
|
||
element: Element,
|
||
exclude_binary_image_data: bool = False,
|
||
normalize_formula: bool = True,
|
||
*,
|
||
formula_markdown_style: str = FORMULA_MARKDOWN_AUTO,
|
||
) -> str:
|
||
match element:
|
||
case Title(text=text):
|
||
return f"# {text}"
|
||
case Formula(text=text):
|
||
return _emit_formula_markdown(
|
||
text,
|
||
normalize_formula=normalize_formula,
|
||
formula_markdown_style=formula_markdown_style,
|
||
)
|
||
case Table(metadata=metadata, text=text) if metadata.text_as_html is not None:
|
||
return metadata.text_as_html
|
||
case Image(metadata=metadata, text=text) if (
|
||
metadata.image_base64 is not None
|
||
and metadata.image_mime_type is None
|
||
and not exclude_binary_image_data
|
||
):
|
||
return f""
|
||
case Image(metadata=metadata, text=text) if (
|
||
metadata.image_base64 is not None and not exclude_binary_image_data
|
||
):
|
||
return f""
|
||
case Image(metadata=metadata, text=text) if metadata.image_url is not None:
|
||
return f""
|
||
case _:
|
||
return element.text
|
||
|
||
|
||
def elements_to_md(
|
||
elements: Iterable[Element],
|
||
filename: Optional[str] = None,
|
||
exclude_binary_image_data: bool = False,
|
||
encoding: str = "utf-8",
|
||
normalize_formula: bool = True,
|
||
*,
|
||
formula_markdown_style: str = FORMULA_MARKDOWN_AUTO,
|
||
) -> str:
|
||
"""Convert elements to markdown format.
|
||
|
||
Args:
|
||
elements: Iterable of elements to convert
|
||
filename: Optional file path to write the markdown to
|
||
exclude_binary_image_data: If True, exclude base64 image data from output
|
||
encoding: File encoding when writing to file
|
||
normalize_formula: If True, map common Unicode math symbols to LaTeX-like tokens
|
||
for `Formula` elements before wrapping with `$$ ... $$`. Placed after ``encoding``
|
||
so legacy positional calls ``(..., filename, exclude_binary, encoding)`` remain valid.
|
||
formula_markdown_style: How to serialize ``Formula`` elements: ``"auto"`` (default)
|
||
uses display math only when content looks like notation and has no ``$`` delimiters;
|
||
``"display_math"`` always uses ``$$`` when safe; ``"plain"`` emits text only.
|
||
Keyword-only so positional encoding arguments stay backward compatible.
|
||
|
||
Returns:
|
||
The markdown content as a string
|
||
"""
|
||
markdown_content = "\n".join(
|
||
[
|
||
element_to_md(
|
||
el,
|
||
exclude_binary_image_data=exclude_binary_image_data,
|
||
normalize_formula=normalize_formula,
|
||
formula_markdown_style=formula_markdown_style,
|
||
)
|
||
for el in elements
|
||
]
|
||
)
|
||
|
||
if filename is not None:
|
||
with open(filename, "w", encoding=encoding) as f:
|
||
f.write(markdown_content)
|
||
|
||
return markdown_content
|
||
|
||
|
||
def create_file_from_elements(
|
||
elements: Iterable[Element],
|
||
output_format: str = "markdown",
|
||
filename: Optional[str] = None,
|
||
encoding: str = "utf-8",
|
||
exclude_binary_image_data: bool = False,
|
||
no_group_by_page: bool = True,
|
||
normalize_formula: bool = True,
|
||
*,
|
||
formula_markdown_style: str = FORMULA_MARKDOWN_AUTO,
|
||
) -> str:
|
||
"""Re-create a document file from a list of elements (reverse of partition).
|
||
|
||
Use this after partitioning a document, optionally modifying elements (e.g. replacing
|
||
Image elements with NarrativeText using alt text), then writing back to a file.
|
||
|
||
Supported formats: "markdown", "html", "text".
|
||
|
||
Args:
|
||
elements: Iterable of elements to convert (e.g. from partition_* or after editing).
|
||
output_format: Output format: "markdown", "html", or "text".
|
||
filename: Optional path to write the document to.
|
||
encoding: File encoding when writing to file (all formats).
|
||
exclude_binary_image_data: If True, omit base64 image data. Applies only to
|
||
**markdown** and **html**; ignored for text.
|
||
no_group_by_page: If True (default), include all elements in output. If False,
|
||
group **html** by page (elements without metadata.page_number are skipped).
|
||
Applies only to **html**; ignored for markdown and text. Placed before
|
||
``normalize_formula`` so legacy positional calls through ``exclude_binary_image_data``
|
||
and ``no_group_by_page`` remain valid.
|
||
normalize_formula: If True, map common Unicode math symbols to LaTeX-like tokens
|
||
for `Formula` elements in **markdown** output. Ignored for html/text.
|
||
formula_markdown_style: Passed to ``elements_to_md`` for **markdown** only.
|
||
Keyword-only; see ``elements_to_md``.
|
||
|
||
Returns:
|
||
The document content as a string.
|
||
|
||
Example:
|
||
>>> from unstructured.partition.md import partition_md
|
||
>>> from unstructured.staging.base import create_file_from_elements
|
||
>>> elements = partition_md("README.md")
|
||
>>> # ... modify elements (e.g. replace Image with NarrativeText) ...
|
||
>>> create_file_from_elements(elements, output_format="markdown", filename="out.md")
|
||
"""
|
||
format_lower = output_format.strip().lower()
|
||
if format_lower not in ("markdown", "html", "text"):
|
||
raise ValueError(
|
||
f"Unsupported format: {output_format!r}. Supported formats: 'markdown', 'html', 'text'."
|
||
)
|
||
|
||
if format_lower == "markdown":
|
||
content = elements_to_md(
|
||
elements,
|
||
filename=filename,
|
||
exclude_binary_image_data=exclude_binary_image_data,
|
||
normalize_formula=normalize_formula,
|
||
encoding=encoding,
|
||
formula_markdown_style=formula_markdown_style,
|
||
)
|
||
return content
|
||
elif format_lower == "html":
|
||
from unstructured.partition.html.convert import elements_to_html
|
||
|
||
content = elements_to_html(
|
||
list(elements),
|
||
exclude_binary_image_data=exclude_binary_image_data,
|
||
no_group_by_page=no_group_by_page,
|
||
)
|
||
if filename is not None:
|
||
with open(filename, "w", encoding=encoding) as f:
|
||
f.write(content)
|
||
return content
|
||
else:
|
||
# text: delegate write to elements_to_text when filename is set
|
||
content = convert_to_text(elements)
|
||
if filename is not None:
|
||
elements_to_text(elements, filename=filename, encoding=encoding)
|
||
return content
|
||
|
||
|
||
def elements_to_json(
|
||
elements: Iterable[Element],
|
||
filename: Optional[str] = None,
|
||
indent: int = 4,
|
||
encoding: str = "utf-8",
|
||
) -> str:
|
||
"""Serialize `elements` to a JSON array.
|
||
|
||
Also writes the JSON to `filename` if it is provided, encoded using `encoding`.
|
||
|
||
The JSON is returned as a string.
|
||
"""
|
||
# -- serialize `elements` as a JSON array (str) --
|
||
precision_adjusted_elements = _fix_metadata_field_precision(elements)
|
||
element_dicts = elements_to_dicts(precision_adjusted_elements)
|
||
json_str = json.dumps(element_dicts, indent=indent, sort_keys=True)
|
||
|
||
if filename is not None:
|
||
with open(filename, "w", encoding=encoding) as f:
|
||
f.write(json_str)
|
||
|
||
return json_str
|
||
|
||
|
||
def elements_to_ndjson(
|
||
elements: Iterable[Element],
|
||
filename: Optional[str] = None,
|
||
encoding: str = "utf-8",
|
||
) -> str:
|
||
"""Serialize `elements` to a JSON array.
|
||
|
||
Also writes the JSON to `filename` if it is provided, encoded using `encoding`.
|
||
|
||
The JSON is returned as a string.
|
||
"""
|
||
# -- serialize `elements` as a JSON array (str) --
|
||
precision_adjusted_elements = _fix_metadata_field_precision(elements)
|
||
element_dicts = elements_to_dicts(precision_adjusted_elements)
|
||
ndjson_str = ndjson_dumps(element_dicts, sort_keys=True)
|
||
|
||
if filename is not None:
|
||
with open(filename, "w", encoding=encoding) as f:
|
||
f.write(ndjson_str)
|
||
|
||
return ndjson_str
|
||
|
||
|
||
def _fix_metadata_field_precision(elements: Iterable[Element]) -> list[Element]:
|
||
out_elements: list[Element] = []
|
||
for element in elements:
|
||
el = deepcopy(element)
|
||
if el.metadata.coordinates:
|
||
precision = 1 if isinstance(el.metadata.coordinates.system, PixelSpace) else 2
|
||
points = el.metadata.coordinates.points
|
||
assert points is not None
|
||
rounded_points: list[Point] = []
|
||
for point in points:
|
||
x, y = point
|
||
rounded_point = (round(x, precision), round(y, precision))
|
||
rounded_points.append(rounded_point)
|
||
el.metadata.coordinates.points = tuple(rounded_points)
|
||
|
||
if el.metadata.detection_class_prob:
|
||
el.metadata.detection_class_prob = round(el.metadata.detection_class_prob, 5)
|
||
|
||
out_elements.append(el)
|
||
|
||
return out_elements
|
||
|
||
|
||
# ================================================================================================
|
||
|
||
|
||
def _get_metadata_table_fieldnames() -> list[str]:
|
||
metadata_fields = list(ElementMetadata.__annotations__.keys())
|
||
metadata_fields.remove("coordinates")
|
||
metadata_fields.extend(
|
||
[
|
||
"sender",
|
||
"coordinates_points",
|
||
"coordinates_system",
|
||
"coordinates_layout_width",
|
||
"coordinates_layout_height",
|
||
],
|
||
)
|
||
return metadata_fields
|
||
|
||
|
||
TABLE_FIELDNAMES: list[str] = [
|
||
"type",
|
||
"text",
|
||
"element_id",
|
||
] + _get_metadata_table_fieldnames()
|
||
|
||
|
||
def convert_to_text(elements: Iterable[Element]) -> str:
|
||
"""Convert elements into clean, concatenated text."""
|
||
return "\n".join([e.text for e in elements if hasattr(e, "text") and e.text])
|
||
|
||
|
||
def elements_to_text(
|
||
elements: Iterable[Element], filename: Optional[str] = None, encoding: str = "utf-8"
|
||
) -> Optional[str]:
|
||
"""Convert text from each of `elements` into clean, concatenated text.
|
||
|
||
Saves to a txt file if filename is specified. Otherwise, return the text of the elements as a
|
||
string.
|
||
"""
|
||
element_cct = convert_to_text(elements)
|
||
if filename is not None:
|
||
with open(filename, "w", encoding=encoding) as f:
|
||
f.write(element_cct)
|
||
return None
|
||
else:
|
||
return element_cct
|
||
|
||
|
||
def flatten_dict(
|
||
dictionary: dict[str, Any],
|
||
parent_key: str = "",
|
||
separator: str = "_",
|
||
flatten_lists: bool = False,
|
||
remove_none: bool = False,
|
||
keys_to_omit: Optional[Sequence[str]] = None,
|
||
) -> dict[str, Any]:
|
||
"""Flattens a nested dictionary into a single level dictionary.
|
||
|
||
keys_to_omit is a list of keys that don't get flattened. If omitting a nested key, format as
|
||
{parent_key}{separator}{key}. If flatten_lists is True, then lists and tuples are flattened as
|
||
well. If remove_none is True, then None keys/values are removed from the flattened
|
||
dictionary.
|
||
"""
|
||
keys_to_omit = keys_to_omit if keys_to_omit else []
|
||
flattened_dict: dict[str, Any] = {}
|
||
for key, value in dictionary.items():
|
||
new_key = f"{parent_key}{separator}{key}" if parent_key else key
|
||
if new_key in keys_to_omit:
|
||
flattened_dict[new_key] = value
|
||
elif value is None and remove_none:
|
||
continue
|
||
elif isinstance(value, dict):
|
||
value = cast("dict[str, Any]", value)
|
||
flattened_dict.update(
|
||
flatten_dict(
|
||
value, new_key, separator, flatten_lists, remove_none, keys_to_omit=keys_to_omit
|
||
),
|
||
)
|
||
elif isinstance(value, (list, tuple)) and flatten_lists:
|
||
value = cast("list[Any] | tuple[Any]", value)
|
||
for index, item in enumerate(value):
|
||
flattened_dict.update(
|
||
flatten_dict(
|
||
{f"{new_key}{separator}{index}": item},
|
||
"",
|
||
separator,
|
||
flatten_lists,
|
||
remove_none,
|
||
keys_to_omit=keys_to_omit,
|
||
)
|
||
)
|
||
else:
|
||
flattened_dict[new_key] = value
|
||
|
||
return flattened_dict
|
||
|
||
|
||
def _get_table_fieldnames(rows: list[dict[str, Any]]):
|
||
return list(TABLE_FIELDNAMES)
|
||
|
||
|
||
def convert_to_csv(elements: Iterable[Element]) -> str:
|
||
"""Convert `elements` to CSV format."""
|
||
rows: list[dict[str, Any]] = elements_to_dicts(elements)
|
||
table_fieldnames = _get_table_fieldnames(rows)
|
||
# NOTE(robinson) - flatten metadata and add it to the table
|
||
for row in rows:
|
||
metadata = row.pop("metadata")
|
||
for key, value in flatten_dict(metadata).items():
|
||
if key in table_fieldnames:
|
||
row[key] = value
|
||
|
||
if row.get("sent_from"):
|
||
row["sender"] = row.get("sent_from")
|
||
if isinstance(row["sender"], list):
|
||
row["sender"] = row["sender"][0]
|
||
|
||
with io.StringIO() as buffer:
|
||
csv_writer = csv.DictWriter(buffer, fieldnames=table_fieldnames)
|
||
csv_writer.writeheader()
|
||
csv_writer.writerows(rows)
|
||
return buffer.getvalue()
|
||
|
||
|
||
# -- legacy alias for convert_to_csv --
|
||
convert_to_isd_csv = convert_to_csv
|
||
|
||
|
||
@requires_dependencies(["pandas"])
|
||
def get_default_pandas_dtypes() -> dict[str, Any]:
|
||
return {
|
||
"text": pd.StringDtype(), # type: ignore
|
||
"type": pd.StringDtype(), # type: ignore
|
||
"element_id": pd.StringDtype(), # type: ignore
|
||
"filename": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"filetype": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"file_directory": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"last_modified": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"attached_to_filename": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"parent_id": pd.StringDtype(), # Optional[str], # type: ignore
|
||
"category_depth": "Int64", # Optional[int]
|
||
"image_path": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"languages": object, # Optional[list[str]]
|
||
"page_number": "Int64", # Optional[int]
|
||
"page_name": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"url": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"link_urls": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"link_texts": object, # Optional[list[str]]
|
||
"links": object,
|
||
"sent_from": object, # Optional[list[str]],
|
||
"sent_to": object, # Optional[list[str]]
|
||
"subject": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"section": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"header_footer_type": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"emphasized_text_contents": object, # Optional[list[str]]
|
||
"emphasized_text_tags": object, # Optional[list[str]]
|
||
"text_as_html": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"max_characters": "Int64", # Optional[int]
|
||
"is_continuation": "boolean", # Optional[bool]
|
||
"num_carried_over_header_rows": "Int64", # Optional[int]
|
||
"detection_class_prob": float, # Optional[float],
|
||
"sender": pd.StringDtype(), # type: ignore
|
||
"coordinates_points": object,
|
||
"coordinates_system": pd.StringDtype(), # type: ignore
|
||
"coordinates_layout_width": float,
|
||
"coordinates_layout_height": float,
|
||
"data_source_url": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"data_source_version": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"data_source_record_locator": object,
|
||
"data_source_date_created": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"data_source_date_modified": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"data_source_date_processed": pd.StringDtype(), # Optional[str] # type: ignore
|
||
"data_source_permissions_data": object,
|
||
"embeddings": object,
|
||
}
|
||
|
||
|
||
@requires_dependencies(["pandas"])
|
||
def convert_to_dataframe(
|
||
elements: Iterable[Element], drop_empty_cols: bool = True, set_dtypes: bool = False
|
||
) -> "pd.DataFrame":
|
||
"""Convert `elements` to a pandas DataFrame.
|
||
|
||
The dataframe contains the following columns:
|
||
text: the element text
|
||
type: the text type (NarrativeText, Title, etc)
|
||
|
||
Output is pd.DataFrame
|
||
"""
|
||
element_dicts = elements_to_dicts(elements)
|
||
for d in element_dicts:
|
||
if metadata := d.pop("metadata", None):
|
||
d.update(flatten_dict(metadata, keys_to_omit=["data_source_record_locator"]))
|
||
df = pd.DataFrame.from_dict(element_dicts) # type: ignore
|
||
if set_dtypes:
|
||
dt = {k: v for k, v in get_default_pandas_dtypes().items() if k in df.columns}
|
||
df = df.astype(dt) # type: ignore
|
||
if drop_empty_cols:
|
||
df.dropna(axis=1, how="all", inplace=True) # type: ignore
|
||
return df
|
||
|
||
|
||
def filter_element_types(
|
||
elements: Iterable[Element],
|
||
include_element_types: Optional[Sequence[type[Element]]] = None,
|
||
exclude_element_types: Optional[Sequence[type[Element]]] = None,
|
||
) -> list[Element]:
|
||
"""Filters document elements by element type"""
|
||
exactly_one(
|
||
include_element_types=include_element_types,
|
||
exclude_element_types=exclude_element_types,
|
||
)
|
||
|
||
filtered_elements: list[Element] = []
|
||
if include_element_types:
|
||
for element in elements:
|
||
if type(element) in include_element_types:
|
||
filtered_elements.append(element)
|
||
|
||
return filtered_elements
|
||
|
||
elif exclude_element_types:
|
||
for element in elements:
|
||
if type(element) not in exclude_element_types:
|
||
filtered_elements.append(element)
|
||
|
||
return filtered_elements
|
||
|
||
return list(elements)
|
||
|
||
|
||
def convert_to_coco(
|
||
elements: Iterable[Element],
|
||
dataset_description: Optional[str] = None,
|
||
dataset_version: str = "1.0",
|
||
contributors: tuple[str] = ("Unstructured Developers",),
|
||
) -> dict[str, Any]:
|
||
from unstructured.documents.elements import TYPE_TO_TEXT_ELEMENT_MAP
|
||
|
||
coco_dataset: dict[str, Any] = {}
|
||
# Handle Info
|
||
coco_dataset["info"] = {
|
||
"description": (
|
||
dataset_description
|
||
if dataset_description
|
||
else f"Unstructured COCO Dataset {datetime.now().strftime('%Y-%m-%d')}"
|
||
),
|
||
"version": dataset_version,
|
||
"year": datetime.now().year,
|
||
"contributors": ",".join(contributors),
|
||
"date_created": datetime.now().date().isoformat(),
|
||
}
|
||
element_dicts = elements_to_dicts(elements)
|
||
# Handle Images
|
||
images = [
|
||
{
|
||
"width": (
|
||
el["metadata"]["coordinates"]["layout_width"]
|
||
if el["metadata"].get("coordinates")
|
||
else None
|
||
),
|
||
"height": (
|
||
el["metadata"]["coordinates"]["layout_height"]
|
||
if el["metadata"].get("coordinates")
|
||
else None
|
||
),
|
||
"file_directory": el["metadata"].get("file_directory", ""),
|
||
"file_name": el["metadata"].get("filename", ""),
|
||
"page_number": el["metadata"].get("page_number", ""),
|
||
}
|
||
for el in element_dicts
|
||
]
|
||
images = list({tuple(sorted(d.items())): d for d in images}.values())
|
||
for index, d in enumerate(images):
|
||
d["id"] = index + 1
|
||
coco_dataset["images"] = images
|
||
# Handle Categories
|
||
categories = sorted(set(TYPE_TO_TEXT_ELEMENT_MAP.keys()))
|
||
categories = [{"id": i + 1, "name": cat} for i, cat in enumerate(categories)]
|
||
coco_dataset["categories"] = categories
|
||
# Handle Annotations
|
||
annotations = [
|
||
{
|
||
"id": el["element_id"],
|
||
"category_id": [x["id"] for x in categories if x["name"] == el["type"]][0],
|
||
"bbox": (
|
||
[
|
||
float(el["metadata"].get("coordinates")["points"][0][0]),
|
||
float(el["metadata"].get("coordinates")["points"][0][1]),
|
||
float(
|
||
abs(
|
||
el["metadata"].get("coordinates")["points"][0][0]
|
||
- el["metadata"].get("coordinates")["points"][2][0]
|
||
)
|
||
),
|
||
float(
|
||
abs(
|
||
el["metadata"].get("coordinates")["points"][0][1]
|
||
- el["metadata"].get("coordinates")["points"][1][1]
|
||
)
|
||
),
|
||
]
|
||
if el["metadata"].get("coordinates")
|
||
else []
|
||
),
|
||
"area": (
|
||
(
|
||
float(
|
||
abs(
|
||
el["metadata"].get("coordinates")["points"][0][0]
|
||
- el["metadata"].get("coordinates")["points"][2][0]
|
||
)
|
||
)
|
||
* float(
|
||
abs(
|
||
el["metadata"].get("coordinates")["points"][0][1]
|
||
- el["metadata"].get("coordinates")["points"][1][1]
|
||
)
|
||
)
|
||
)
|
||
if el["metadata"].get("coordinates")
|
||
else None
|
||
),
|
||
}
|
||
for el in element_dicts
|
||
]
|
||
coco_dataset["annotations"] = annotations
|
||
return coco_dataset
|