Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

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