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
537 lines
21 KiB
Python
537 lines
21 KiB
Python
import os
|
|
import re
|
|
import tempfile
|
|
import zlib
|
|
from typing import BinaryIO, List, Mapping, Optional, Tuple, Union
|
|
|
|
from pdfminer import settings as pdfminer_settings
|
|
from pdfminer.cmapdb import CMap, CMapDB
|
|
from pdfminer.converter import PDFPageAggregator
|
|
from pdfminer.layout import LAParams, LTChar, LTContainer, LTImage, LTItem, LTTextLine
|
|
from pdfminer.pdffont import PDFCIDFont, PDFFontError
|
|
from pdfminer.pdfinterp import LITERAL_FONT, PDFPageInterpreter, PDFResourceManager
|
|
from pdfminer.pdfpage import PDFPage
|
|
from pdfminer.pdftypes import (
|
|
LITERALS_ASCII85_DECODE,
|
|
LITERALS_ASCIIHEX_DECODE,
|
|
LITERALS_FLATE_DECODE,
|
|
PDFStream,
|
|
resolve1,
|
|
)
|
|
from pdfminer.psexceptions import PSSyntaxError
|
|
from pdfminer.psparser import literal_name
|
|
from pydantic import BaseModel
|
|
|
|
from unstructured.logger import logger
|
|
from unstructured.partition.utils.config import env_config
|
|
from unstructured.utils import requires_dependencies
|
|
|
|
_RE_CIDRANGE_BLOCK = re.compile(rb"begincidrange\s+(.*?)\s+endcidrange", re.DOTALL)
|
|
_RE_CIDRANGE_ENTRY = re.compile(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s+(\d+)")
|
|
_RE_CIDCHAR_BLOCK = re.compile(rb"begincidchar\s+(.*?)\s+endcidchar", re.DOTALL)
|
|
_RE_CIDCHAR_ENTRY = re.compile(rb"<([0-9A-Fa-f]+)>\s+(\d+)")
|
|
_RE_WMODE = re.compile(rb"/WMode\s+(\d+)")
|
|
|
|
# Cap on decompressed CMap stream size to bound regex/parse cost before any mapping
|
|
# cap kicks in. Real-world embedded CMaps are typically a few hundred bytes; even
|
|
# large CJK CMaps are well under 100 KB.
|
|
_MAX_CMAP_STREAM_BYTES = 1_000_000
|
|
|
|
# Cap on total code-to-CID mappings to prevent malicious PDFs from causing excessive
|
|
# memory/CPU usage via huge begincidrange spans. 131072 covers all real-world fonts
|
|
# (single-byte: max 256, double-byte CJK: typically ~20-30K glyphs).
|
|
_MAX_CODE2CID_MAPPINGS = 131072
|
|
|
|
|
|
def _parse_embedded_cmap_stream(data: bytes) -> CMap:
|
|
"""Parse an embedded CMap stream into a CMap with a populated code2cid mapping.
|
|
|
|
pdfminer.six does not parse embedded Encoding CMap streams for CIDFonts — it only
|
|
looks up the CMap name in its predefined database. When a PDF (e.g. produced by
|
|
Prince XML) embeds a custom CMap as a stream, pdfminer silently falls back to an
|
|
empty CMap and all text using that font is lost.
|
|
|
|
This function parses the begincidrange/begincidchar sections from the raw CMap
|
|
stream and builds the code2cid dict that CMap.decode() uses. It also extracts
|
|
WMode (writing mode: 0=horizontal, 1=vertical) when present.
|
|
"""
|
|
if len(data) > _MAX_CMAP_STREAM_BYTES:
|
|
logger.warning(
|
|
"Embedded CMap stream too large (%d bytes, limit %d), skipping",
|
|
len(data),
|
|
_MAX_CMAP_STREAM_BYTES,
|
|
)
|
|
return CMap()
|
|
|
|
code2cid: dict[int, object] = {}
|
|
total_mappings = 0
|
|
|
|
for match in _RE_CIDRANGE_BLOCK.finditer(data):
|
|
entries = _RE_CIDRANGE_ENTRY.findall(match.group(1))
|
|
for start_hex, end_hex, cid_str in entries:
|
|
start_bytes = bytes.fromhex(start_hex.decode("ascii"))
|
|
end_bytes = bytes.fromhex(end_hex.decode("ascii"))
|
|
start_cid = int(cid_str)
|
|
code_len = len(start_bytes)
|
|
start_val = int.from_bytes(start_bytes, "big")
|
|
end_val = int.from_bytes(end_bytes, "big")
|
|
|
|
if end_val < start_val:
|
|
continue
|
|
range_size = end_val - start_val + 1
|
|
if total_mappings + range_size > _MAX_CODE2CID_MAPPINGS:
|
|
logger.warning(
|
|
"Embedded CMap would exceed %d mappings; discarding partial CMap",
|
|
_MAX_CODE2CID_MAPPINGS,
|
|
)
|
|
return CMap()
|
|
for i in range(range_size):
|
|
code_val = start_val + i
|
|
cid = start_cid + i
|
|
if code_len == 1:
|
|
code2cid[code_val] = cid
|
|
else:
|
|
code_bytes = code_val.to_bytes(code_len, "big")
|
|
d = code2cid
|
|
for b in code_bytes[:-1]:
|
|
if b not in d:
|
|
d[b] = {}
|
|
d = d[b] # type: ignore[assignment]
|
|
d[code_bytes[-1]] = cid
|
|
total_mappings += range_size
|
|
|
|
for match in _RE_CIDCHAR_BLOCK.finditer(data):
|
|
entries = _RE_CIDCHAR_ENTRY.findall(match.group(1))
|
|
for code_hex, cid_str in entries:
|
|
if total_mappings >= _MAX_CODE2CID_MAPPINGS:
|
|
logger.warning(
|
|
"Embedded CMap exceeded %d mappings; discarding partial CMap",
|
|
_MAX_CODE2CID_MAPPINGS,
|
|
)
|
|
return CMap()
|
|
code_bytes = bytes.fromhex(code_hex.decode("ascii"))
|
|
cid = int(cid_str)
|
|
if len(code_bytes) == 1:
|
|
code2cid[code_bytes[0]] = cid
|
|
else:
|
|
d = code2cid
|
|
for b in code_bytes[:-1]:
|
|
if b not in d:
|
|
d[b] = {}
|
|
d = d[b] # type: ignore[assignment]
|
|
d[code_bytes[-1]] = cid
|
|
total_mappings += 1
|
|
|
|
cmap = CMap()
|
|
cmap.code2cid = code2cid
|
|
|
|
wmode_match = _RE_WMODE.search(data)
|
|
if wmode_match and int(wmode_match.group(1)) != 0:
|
|
cmap.attrs["WMode"] = int(wmode_match.group(1))
|
|
|
|
return cmap
|
|
|
|
|
|
def _flate_decode_with_limit(data: bytes, limit: int) -> Optional[bytes]:
|
|
"""Decompress Flate data with a hard output size limit.
|
|
|
|
Returns None if the decompressed output would exceed `limit` bytes,
|
|
without fully materializing the result.
|
|
"""
|
|
decomp = zlib.decompressobj()
|
|
out = bytearray()
|
|
try:
|
|
out.extend(decomp.decompress(data, limit + 1))
|
|
if len(out) > limit or decomp.unconsumed_tail:
|
|
return None
|
|
out.extend(decomp.flush(limit + 1 - len(out)))
|
|
if len(out) > limit:
|
|
return None
|
|
except zlib.error:
|
|
return None
|
|
return bytes(out)
|
|
|
|
|
|
def _decode_pdfstream_with_limit(stream: PDFStream, max_decoded_bytes: int) -> Optional[bytes]:
|
|
"""Decode a PDFStream with a hard output size limit, without mutating the stream.
|
|
|
|
Unlike PDFStream.get_data(), this never assigns to stream.data or stream.rawdata.
|
|
Returns None if the decoded output exceeds the limit or uses an unsupported filter.
|
|
"""
|
|
raw = stream.get_rawdata()
|
|
if raw is None:
|
|
# Stream was already decoded elsewhere; check the cached result.
|
|
data = stream.data
|
|
if data is None or len(data) > max_decoded_bytes:
|
|
return None
|
|
return data
|
|
|
|
data = raw
|
|
|
|
if stream.decipher:
|
|
if stream.objid is None or stream.genno is None:
|
|
logger.debug("Encrypted CMap stream missing objid/genno; skipping")
|
|
return None
|
|
data = stream.decipher(stream.objid, stream.genno, data, stream.attrs)
|
|
|
|
for filt, params in stream.get_filters():
|
|
if filt in LITERALS_FLATE_DECODE:
|
|
result = _flate_decode_with_limit(data, max_decoded_bytes)
|
|
if result is None:
|
|
logger.warning(
|
|
"Embedded CMap stream exceeded %d bytes during decompression; skipping",
|
|
max_decoded_bytes,
|
|
)
|
|
return None
|
|
data = result
|
|
elif filt in LITERALS_ASCII85_DECODE:
|
|
from pdfminer.ascii85 import ascii85decode
|
|
|
|
data = ascii85decode(data)
|
|
elif filt in LITERALS_ASCIIHEX_DECODE:
|
|
from pdfminer.ascii85 import asciihexdecode
|
|
|
|
data = asciihexdecode(data)
|
|
else:
|
|
logger.debug("Unsupported embedded CMap filter %r; skipping", filt)
|
|
return None
|
|
|
|
if len(data) > max_decoded_bytes:
|
|
logger.warning(
|
|
"Embedded CMap stream exceeded %d bytes after filter; skipping",
|
|
max_decoded_bytes,
|
|
)
|
|
return None
|
|
|
|
return data
|
|
|
|
|
|
class CustomPDFCIDFont(PDFCIDFont):
|
|
"""A CIDFont subclass that handles embedded Encoding CMap streams at construction time.
|
|
|
|
pdfminer.six's PDFCIDFont.get_cmap_from_spec only looks up CMap names in its
|
|
predefined database. When a PDF embeds a custom CMap as a stream, pdfminer falls
|
|
back to an empty CMap and all text is lost.
|
|
|
|
This subclass overrides get_cmap_from_spec to parse the embedded stream when the
|
|
name lookup fails. Because this runs during __init__, all constructor-time state
|
|
(vertical mode, widths, displacements) is derived from the correct CMap.
|
|
"""
|
|
|
|
def get_cmap_from_spec(self, spec: Mapping, strict: bool):
|
|
cmap_name = self._get_cmap_name(spec, strict)
|
|
try:
|
|
return CMapDB.get_cmap(cmap_name)
|
|
except CMapDB.CMapNotFound as e:
|
|
encoding = resolve1(spec.get("Encoding"))
|
|
if isinstance(encoding, PDFStream):
|
|
try:
|
|
data = _decode_pdfstream_with_limit(encoding, _MAX_CMAP_STREAM_BYTES)
|
|
if data is not None:
|
|
cmap = _parse_embedded_cmap_stream(data)
|
|
if cmap.code2cid:
|
|
logger.debug(
|
|
"Parsed embedded CMap stream %r (%d code mappings)",
|
|
cmap_name,
|
|
len(cmap.code2cid),
|
|
)
|
|
return cmap
|
|
except (
|
|
ValueError,
|
|
KeyError,
|
|
TypeError,
|
|
UnicodeDecodeError,
|
|
zlib.error,
|
|
PSSyntaxError,
|
|
) as exc:
|
|
logger.warning(
|
|
"Failed to parse embedded CMap stream %r: %s",
|
|
cmap_name,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
if strict:
|
|
raise PDFFontError(e) from e
|
|
return CMap()
|
|
|
|
|
|
class CustomPDFResourceManager(PDFResourceManager):
|
|
"""A resource manager that uses CustomPDFCIDFont for CID font construction.
|
|
|
|
This ensures embedded CMap streams are resolved during font construction,
|
|
not after, so all constructor-time state (WMode, widths) is correct.
|
|
"""
|
|
|
|
def get_font(self, objid, spec):
|
|
subtype = literal_name(spec["Subtype"]) if "Subtype" in spec else "Type1"
|
|
|
|
if subtype in ("CIDFontType0", "CIDFontType2"):
|
|
if objid and objid in self._cached_fonts:
|
|
return self._cached_fonts[objid]
|
|
if pdfminer_settings.STRICT and spec.get("Type") is not LITERAL_FONT:
|
|
raise PDFFontError("Type is not /Font")
|
|
font = CustomPDFCIDFont(self, spec)
|
|
if objid and self.caching:
|
|
self._cached_fonts[objid] = font
|
|
return font
|
|
|
|
return super().get_font(objid, spec)
|
|
|
|
|
|
class CustomPDFPageInterpreter(PDFPageInterpreter):
|
|
"""a custom pdfminer page interpreter that adds character render mode information to LTChar
|
|
object as `rendermode` attribute. This is intended to be used to detect invisible text."""
|
|
|
|
def _patch_current_chars_with_render_mode(self, start: int):
|
|
"""Add render_mode to LTChar objects added since index `start`."""
|
|
cur_item = getattr(self.device, "cur_item", None)
|
|
if not cur_item:
|
|
return
|
|
render_mode = self.textstate.render
|
|
for obj in getattr(cur_item, "_objs", ())[start:]:
|
|
if isinstance(obj, LTChar):
|
|
obj.rendermode = render_mode
|
|
|
|
def do_TJ(self, seq):
|
|
start = len(getattr(getattr(self.device, "cur_item", None), "_objs", ()))
|
|
super().do_TJ(seq)
|
|
self._patch_current_chars_with_render_mode(start)
|
|
|
|
|
|
class PDFMinerConfig(BaseModel):
|
|
line_overlap: Optional[float] = None
|
|
word_margin: Optional[float] = None
|
|
line_margin: Optional[float] = None
|
|
char_margin: Optional[float] = None
|
|
detect_vertical: Optional[bool] = None
|
|
|
|
|
|
def init_pdfminer(pdfminer_config: Optional[PDFMinerConfig] = None):
|
|
rsrcmgr = CustomPDFResourceManager()
|
|
|
|
laparams_kwargs = pdfminer_config.model_dump(exclude_none=True) if pdfminer_config else {}
|
|
laparams = LAParams(**laparams_kwargs)
|
|
|
|
device = PDFPageAggregator(rsrcmgr, laparams=laparams)
|
|
interpreter = CustomPDFPageInterpreter(rsrcmgr, device)
|
|
|
|
return device, interpreter
|
|
|
|
|
|
def extract_image_objects(parent_object: LTItem) -> List[LTImage]:
|
|
"""Recursively extracts image objects from a given parent object in a PDF document."""
|
|
objects = []
|
|
|
|
if isinstance(parent_object, LTImage):
|
|
objects.append(parent_object)
|
|
elif isinstance(parent_object, LTContainer):
|
|
for child in parent_object:
|
|
objects.extend(extract_image_objects(child))
|
|
|
|
return objects
|
|
|
|
|
|
def extract_text_objects(parent_object: LTItem) -> List[LTTextLine]:
|
|
"""Recursively extracts text objects from a given parent object in a PDF document."""
|
|
objects = []
|
|
|
|
if isinstance(parent_object, LTTextLine):
|
|
objects.append(parent_object)
|
|
elif isinstance(parent_object, LTContainer):
|
|
for child in parent_object:
|
|
objects.extend(extract_text_objects(child))
|
|
|
|
return objects
|
|
|
|
|
|
def rect_to_bbox(
|
|
rect: Tuple[float, float, float, float],
|
|
height: float,
|
|
) -> Tuple[float, float, float, float]:
|
|
"""
|
|
Converts a PDF rectangle coordinates (x1, y1, x2, y2) to a bounding box in the specified
|
|
coordinate system where the vertical axis is measured from the top of the page.
|
|
|
|
Args:
|
|
rect (Tuple[float, float, float, float]): A tuple representing a PDF rectangle
|
|
coordinates (x1, y1, x2, y2).
|
|
height (float): The height of the page in the specified coordinate system.
|
|
|
|
Returns:
|
|
Tuple[float, float, float, float]: A tuple representing the bounding box coordinates
|
|
(x1, y1, x2, y2) with the y-coordinates adjusted to be measured from the top of the page.
|
|
"""
|
|
x1, y2, x2, y1 = rect
|
|
y1 = height - y1
|
|
y2 = height - y2
|
|
return (x1, y1, x2, y2)
|
|
|
|
|
|
def _is_duplicate_char(char1: LTChar, char2: LTChar, threshold: float) -> bool:
|
|
"""Detect if two characters are duplicates caused by fake bold rendering.
|
|
|
|
Some PDF generators create bold text by rendering the same character twice at slightly
|
|
offset positions. This function detects such duplicates by checking if two characters
|
|
have the same text content and overlapping bounding boxes.
|
|
|
|
Key insight: Fake-bold duplicates OVERLAP significantly, while legitimate consecutive
|
|
identical letters (like "ll" in "skills") are ADJACENT with minimal/no overlap.
|
|
|
|
Args:
|
|
char1: First LTChar object.
|
|
char2: Second LTChar object.
|
|
threshold: Maximum pixel distance to consider as duplicate.
|
|
|
|
Returns:
|
|
True if char2 appears to be a duplicate of char1.
|
|
"""
|
|
# Must be the same character
|
|
if char1.get_text() != char2.get_text():
|
|
return False
|
|
|
|
# Calculate horizontal and vertical distances between character origins
|
|
x_diff = abs(char1.x0 - char2.x0)
|
|
y_diff = abs(char1.y0 - char2.y0)
|
|
|
|
# Characters must be very close in position
|
|
if x_diff >= threshold or y_diff >= threshold:
|
|
return False
|
|
|
|
# Additional check: Calculate bounding box overlap to distinguish
|
|
# fake-bold (high overlap) from legitimate doubles (low/no overlap)
|
|
|
|
# Get character widths and heights
|
|
char1_width = char1.x1 - char1.x0
|
|
char2_width = char2.x1 - char2.x0
|
|
|
|
# Calculate horizontal overlap
|
|
overlap_x_start = max(char1.x0, char2.x0)
|
|
overlap_x_end = min(char1.x1, char2.x1)
|
|
horizontal_overlap = max(0, overlap_x_end - overlap_x_start)
|
|
|
|
# Calculate overlap percentage relative to character width
|
|
avg_width = (char1_width + char2_width) / 2
|
|
overlap_ratio = horizontal_overlap / avg_width if avg_width > 0 else 0
|
|
|
|
# Fake-bold duplicates typically have >70% overlap
|
|
# Legitimate consecutive letters have <30% overlap (or none)
|
|
# Use configurable threshold (default 50%) to be conservative
|
|
overlap_ratio_threshold = env_config.PDF_CHAR_OVERLAP_RATIO_THRESHOLD
|
|
return overlap_ratio > overlap_ratio_threshold
|
|
|
|
|
|
def deduplicate_chars_in_text_line(text_line: LTTextLine, threshold: float) -> str:
|
|
"""Extract text from an LTTextLine with duplicate characters removed.
|
|
|
|
Some PDFs create bold text by rendering each character twice at slightly offset
|
|
positions. This function removes such duplicates by keeping only the first instance
|
|
when two identical characters appear at nearly the same position.
|
|
|
|
Args:
|
|
text_line: An LTTextLine object containing characters to extract.
|
|
threshold: Maximum pixel distance to consider characters as duplicates.
|
|
Set to 0 to disable deduplication.
|
|
|
|
Returns:
|
|
The extracted text with duplicate characters removed.
|
|
"""
|
|
if threshold <= 0:
|
|
return text_line.get_text()
|
|
|
|
# Build deduplicated text while preserving non-LTChar items (like LTAnno for spaces)
|
|
result_parts: List[str] = []
|
|
last_ltchar: Optional[LTChar] = None
|
|
|
|
for item in text_line:
|
|
if isinstance(item, LTChar):
|
|
# Check if this is a duplicate of the last LTChar
|
|
if last_ltchar is not None and _is_duplicate_char(last_ltchar, item, threshold):
|
|
# Skip this duplicate character
|
|
continue
|
|
last_ltchar = item
|
|
result_parts.append(item.get_text())
|
|
else:
|
|
# Non-LTChar items (e.g., LTAnno for spaces) - keep as-is
|
|
if hasattr(item, "get_text"):
|
|
result_parts.append(item.get_text())
|
|
|
|
return "".join(result_parts)
|
|
|
|
|
|
def get_text_with_deduplication(
|
|
text_obj: Union[LTTextLine, LTContainer, LTItem],
|
|
threshold: float,
|
|
) -> str:
|
|
"""Get text from a text object with optional character deduplication.
|
|
|
|
This is the main entry point for extracting text with fake-bold deduplication.
|
|
It handles LTTextLine objects and recursively processes containers.
|
|
|
|
Args:
|
|
text_obj: An LTTextLine, LTContainer, or other LTItem object.
|
|
threshold: Maximum pixel distance to consider characters as duplicates.
|
|
Set to 0 to disable deduplication.
|
|
|
|
Returns:
|
|
The extracted text with duplicate characters removed.
|
|
"""
|
|
if isinstance(text_obj, LTTextLine):
|
|
return deduplicate_chars_in_text_line(text_obj, threshold)
|
|
elif isinstance(text_obj, LTContainer):
|
|
parts: List[str] = []
|
|
for child in text_obj:
|
|
if isinstance(child, LTTextLine):
|
|
parts.append(deduplicate_chars_in_text_line(child, threshold))
|
|
elif hasattr(child, "get_text"):
|
|
parts.append(child.get_text())
|
|
return "".join(parts)
|
|
elif hasattr(text_obj, "get_text"):
|
|
return text_obj.get_text()
|
|
return ""
|
|
|
|
|
|
@requires_dependencies(["pikepdf", "pypdf"])
|
|
def open_pdfminer_pages_generator(
|
|
fp: BinaryIO, password: Optional[str] = None, pdfminer_config: Optional[PDFMinerConfig] = None
|
|
):
|
|
"""Open PDF pages using PDFMiner, handling and repairing invalid dictionary constructs."""
|
|
|
|
import pikepdf
|
|
|
|
from unstructured.partition.pdf_image.pypdf_utils import get_page_data
|
|
|
|
device, interpreter = init_pdfminer(pdfminer_config=pdfminer_config)
|
|
with tempfile.TemporaryDirectory() as tmp_dir_path:
|
|
tmp_file_path = os.path.join(tmp_dir_path, "tmp_file")
|
|
try:
|
|
pages = PDFPage.get_pages(fp, password=password or "")
|
|
# Detect invalid dictionary construct for entire PDF
|
|
for i, page in enumerate(pages):
|
|
try:
|
|
# Detect invalid dictionary construct for one page
|
|
interpreter.process_page(page)
|
|
page_layout = device.get_result()
|
|
except PSSyntaxError:
|
|
logger.info("Detected invalid dictionary construct for PDFminer")
|
|
logger.info(f"Repairing the PDF page {i + 1} ...")
|
|
# find the error page from binary data fp
|
|
error_page_data = get_page_data(fp, page_number=i)
|
|
# repair the error page with pikepdf
|
|
with pikepdf.Pdf.open(error_page_data) as pdf:
|
|
pdf.save(tmp_file_path)
|
|
page = next(PDFPage.get_pages(open(tmp_file_path, "rb"))) # noqa: SIM115
|
|
interpreter.process_page(page)
|
|
page_layout = device.get_result()
|
|
yield page, page_layout
|
|
except PSSyntaxError:
|
|
logger.info("Detected invalid dictionary construct for PDFminer")
|
|
logger.info("Repairing the PDF document ...")
|
|
# repair the entire doc with pikepdf
|
|
with pikepdf.Pdf.open(fp) as pdf:
|
|
pdf.save(tmp_file_path)
|
|
pages = PDFPage.get_pages(open(tmp_file_path, "rb")) # noqa: SIM115
|
|
for page in pages:
|
|
interpreter.process_page(page)
|
|
page_layout = device.get_result()
|
|
yield page, page_layout
|