使用Python提取PDF卡片
·
# 本文中的代码经过了小幅度的测试,并且可以正常使用。但是在使用时必须进行额外的检查,笔者仅分享提取的脚本,不做任何承诺。
PDF卡片
在材料研究中,PDF卡片常用于无机材料的物相鉴定。原始的PDF卡片格式包含丰富的物相信息和衍射数据,但这种格式无法直接被Pandas等Python库读取。虽然可以利用AI技术进行小批量数据提取,但这种方法存在命名标准不统一、难以实现大规模标准化数据结构等问题。为此,我们开发了一个专门用于读取PDF卡片的Python脚本工具。
Python脚本
我们的提取脚本具备以下优势特性:
-
精准逐行解析
采用逐行处理机制,有效规避正则表达式跨行匹配可能引发的数据异常 -
出色的格式兼容性
- 智能适配含/不含 n² 列的数据结构
- 支持 hkl 晶面指数后跟随 0 至 4 个附加数值列
- 全面兼容标准负号及各类 Unicode 负号变体
-
智能编码识别
自动检测 UTF-8、GB18030、UTF-16 等主流文本编码格式 -
高效的批量处理
支持单文件解析、多文件批处理及全目录扫描操作 -
灵活的容错机制
提供严格模式选项:可配置为遇到异常数据时抛出错误或仅输出警告
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Sequence
import re
import warnings
import pandas as pd
__all__ = [
"ParseIssue",
"PDFCardResult",
"PDFCardParser",
]
NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
HSPACE = r"[^\S\r\n]+"
OPT_HSPACE = r"[^\S\r\n]*"
SIGNED_INT = r"[+\-−–]?\d+"
@dataclass(slots=True)
class ParseIssue:
"""单条未解析数据行的诊断信息。"""
line_number: int
line: str
reason: str
@dataclass(slots=True)
class PDFCardResult:
"""单张 XRD PDF 卡片的结构化解析结果。"""
pdf: str
material: str
formula: str | None
data: pd.DataFrame
source: Path | None = None
metadata: dict[str, str] = field(default_factory=dict)
issues: list[ParseIssue] = field(default_factory=list)
@property
def is_valid(self) -> bool:
"""卡片是否至少成功解析出一条衍射数据且不存在异常行。"""
return not self.data.empty and not self.issues
@property
def peak_count(self) -> int:
"""衍射峰条目数量。"""
return len(self.data)
class PDFCardParser:
"""
通用 XRD PDF 卡片文本解析器。
特性
----
- 逐行解析,避免正则跨行吞数据;
- 支持有或没有 n^2 列;
- 支持 hkl 后 0~4 个附加数值列;
- 支持普通负号和常见 Unicode 负号;
- 支持 UTF-8、GB18030、UTF-16 等常见编码;
- 支持严格模式、异常行报告和批量解析。
"""
COLUMNS = [
"2theta",
"d(AA)",
"I(f)",
"(hkl)",
"theta",
"1/(2d)",
"2pi/d",
"n^2",
]
def __init__(
self,
cards_dir: str | Path | None = None,
encodings: Sequence[str] = (
"utf-8-sig",
"utf-8",
"gb18030",
"utf-16",
),
) -> None:
self.cards_dir = (
Path(cards_dir).expanduser().resolve()
if cards_dir is not None
else Path(__file__).resolve().parent / "PDFcards"
)
self.encodings = tuple(encodings)
self._row_pattern = re.compile(
rf"^{OPT_HSPACE}"
rf"(?P<two_theta>{NUMBER}){HSPACE}"
rf"(?P<d>{NUMBER}){HSPACE}"
rf"(?P<intensity>{NUMBER}){HSPACE}"
rf"\({OPT_HSPACE}"
rf"(?P<h>{SIGNED_INT}){HSPACE}"
rf"(?P<k>{SIGNED_INT}){HSPACE}"
rf"(?P<l>{SIGNED_INT})"
rf"{OPT_HSPACE}\)"
rf"(?P<tail>(?:{HSPACE}{NUMBER}){{0,4}})"
rf"{OPT_HSPACE}$"
)
# ------------------------------------------------------------------
# 文件发现与读取
# ------------------------------------------------------------------
def list_cards(
self,
directory: str | Path | None = None,
*,
pattern: str = "*.txt",
recursive: bool = True,
) -> list[Path]:
"""列出目录中匹配的卡片文件。"""
root = (
Path(directory).expanduser().resolve()
if directory is not None
else self.cards_dir
)
if not root.exists():
return []
iterator = root.rglob(pattern) if recursive else root.glob(pattern)
return sorted(
(
path.resolve()
for path in iterator
if path.is_file()
),
key=lambda path: path.name.casefold(),
)
def find_card(
self,
query: str,
directory: str | Path | None = None,
*,
exact: bool = True,
) -> Path:
"""
按文件名查找卡片。
Parameters
----------
query:
完整文件名或文件名中的关键词。
exact:
True 时精确匹配文件名;False 时采用包含匹配。
"""
files = self.list_cards(directory)
if exact:
matches = [path for path in files if path.name == query]
else:
key = query.casefold()
matches = [
path
for path in files
if key in path.name.casefold()
]
if not matches:
root = (
Path(directory).expanduser().resolve()
if directory is not None
else self.cards_dir
)
raise FileNotFoundError(
f"未在 {root} 中找到卡片:{query}"
)
if len(matches) > 1:
choices = "\n".join(
str(path)
for path in matches[:10]
)
raise ValueError(
"找到多个匹配文件,请提供更精确的名称:\n"
f"{choices}"
)
return matches[0]
def read_text(self, file_path: str | Path) -> str:
"""使用多个常见编码依次尝试读取卡片文本。"""
path = Path(file_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"文件不存在:{path}")
errors: list[str] = []
for encoding in self.encodings:
try:
return path.read_text(encoding=encoding)
except UnicodeError as exc:
errors.append(f"{encoding}: {exc}")
raise UnicodeError(
f"无法识别文件编码:{path}\n"
+ "\n".join(errors)
)
# ------------------------------------------------------------------
# 卡片头部与元数据解析
# ------------------------------------------------------------------
@staticmethod
def _normalize_minus(value: str) -> str:
return (
value.replace("−", "-")
.replace("–", "-")
)
@staticmethod
def _find_table_header(lines: list[str]) -> int:
"""定位包含 2θ、d 和 hkl 的衍射数据表头。"""
for index, line in enumerate(lines):
compact = re.sub(r"\s+", "", line).lower()
has_two_theta = any(
token in compact
for token in (
"2θ",
"2theta",
"2-theta",
)
)
has_d = "d(" in compact or "d(" in compact
has_hkl = (
"hkl" in compact
or "(hkl)" in compact
or all(
token in compact
for token in ("h", "k", "l")
)
)
if has_two_theta and has_d and has_hkl:
return index
raise ValueError("未找到衍射数据表头。")
@staticmethod
def _parse_identity(
lines: list[str],
) -> tuple[str, str, str | None]:
"""提取 PDF 编号、材料名和化学式。"""
card_pattern = re.compile(
r"(PDF#[\d-]+(?:\(RDB\))?)",
re.IGNORECASE,
)
pdf = "Unknown"
material = "Unknown"
formula: str | None = None
card_index: int | None = None
for index, line in enumerate(lines):
match = card_pattern.search(line)
if match:
pdf = match.group(1)
card_index = index
break
if card_index is None:
return pdf, material, formula
following = [
line.strip()
for line in lines[card_index + 1 :]
if line.strip()
]
if following:
material = following[0]
if len(following) >= 2:
candidate = following[1]
metadata_words = (
"射线",
"波长",
"校准",
"文献",
"晶系",
"Radiation",
"Reference",
)
if not any(
word in candidate
for word in metadata_words
):
formula = candidate
return pdf, material, formula
@staticmethod
def _parse_metadata(
lines: list[str],
table_header_index: int,
) -> dict[str, str]:
"""提取表格之前的键值型元数据。"""
metadata: dict[str, str] = {}
for raw_line in lines[:table_header_index]:
line = raw_line.strip()
if not line:
continue
for segment in re.split(r"\t+", line):
segment = segment.strip()
if not segment:
continue
match = re.match(
r"([^=::]+?)\s*[=::]\s*(.+)$",
segment,
)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
if key and value:
metadata[key] = value
return metadata
# ------------------------------------------------------------------
# 衍射数据解析
# ------------------------------------------------------------------
def _parse_data_line(
self,
line: str,
line_number: int,
) -> tuple[dict[str, object] | None, ParseIssue | None]:
"""
解析单条衍射数据。
hkl 后允许出现 0~4 个数值,依次解释为:
theta、1/(2d)、2pi/d 和 n^2。
"""
match = self._row_pattern.fullmatch(line)
if match is None:
return None, ParseIssue(
line_number=line_number,
line=line,
reason="不符合可识别的衍射数据格式",
)
values = match.groupdict()
h = self._normalize_minus(values["h"])
k = self._normalize_minus(values["k"])
l = self._normalize_minus(values["l"])
tail_tokens = re.findall(
NUMBER,
values.get("tail") or "",
)
tail: list[float | int | pd.NA] = [
pd.NA,
pd.NA,
pd.NA,
pd.NA,
]
for index, token in enumerate(tail_tokens):
if (
index == 3
and re.fullmatch(r"[+-]?\d+", token)
):
tail[index] = int(token)
else:
tail[index] = float(token)
row = {
"2theta": float(values["two_theta"]),
"d(AA)": float(values["d"]),
"I(f)": float(values["intensity"]),
"(hkl)": f"({h}{k}{l})",
"theta": tail[0],
"1/(2d)": tail[1],
"2pi/d": tail[2],
"n^2": tail[3],
}
return row, None
def parse_text(
self,
content: str,
*,
source: str | Path | None = None,
strict: bool = False,
) -> PDFCardResult:
"""解析已经读取到内存中的卡片文本。"""
lines = content.splitlines()
header_index = self._find_table_header(lines)
pdf, material, formula = self._parse_identity(lines)
metadata = self._parse_metadata(
lines,
header_index,
)
rows: list[dict[str, object]] = []
issues: list[ParseIssue] = []
data_started = False
for line_number, line in enumerate(
lines[header_index + 1 :],
start=header_index + 2,
):
if not line.strip():
continue
looks_numeric = re.match(
r"^[^\S\r\n]*[+-]?(?:\d|\.)",
line,
)
if not looks_numeric:
if data_started:
break
continue
data_started = True
row, issue = self._parse_data_line(
line,
line_number,
)
if row is not None:
rows.append(row)
elif issue is not None:
issues.append(issue)
data = pd.DataFrame(
rows,
columns=self.COLUMNS,
)
float_columns = [
"2theta",
"d(AA)",
"I(f)",
"theta",
"1/(2d)",
"2pi/d",
]
for column in float_columns:
data[column] = pd.to_numeric(
data[column],
errors="coerce",
)
n_squared = pd.to_numeric(
data["n^2"],
errors="coerce",
)
if n_squared.dropna().mod(1).eq(0).all():
data["n^2"] = n_squared.astype("Int64")
else:
data["n^2"] = n_squared.astype("Float64")
if issues:
details = "\n".join(
(
f"第 {issue.line_number} 行:"
f"{issue.reason};{issue.line!r}"
)
for issue in issues
)
if strict:
raise ValueError(
"存在未解析的数据行:\n"
+ details
)
warnings.warn(
(
f"共有 {len(issues)} 行疑似衍射数据未解析:\n"
f"{details}"
),
RuntimeWarning,
stacklevel=2,
)
return PDFCardResult(
pdf=pdf,
material=material,
formula=formula,
data=data,
source=(
Path(source).expanduser().resolve()
if source is not None
else None
),
metadata=metadata,
issues=issues,
)
def parse_file(
self,
file_path: str | Path,
*,
strict: bool = False,
) -> PDFCardResult:
"""解析单张卡片文件。"""
path = Path(file_path).expanduser().resolve()
return self.parse_text(
self.read_text(path),
source=path,
strict=strict,
)
# ------------------------------------------------------------------
# 批量解析
# ------------------------------------------------------------------
def parse_many(
self,
file_paths: Iterable[str | Path],
*,
strict: bool = False,
include_source: bool = True,
) -> pd.DataFrame:
"""解析多张卡片并合并所有衍射数据。"""
frames: list[pd.DataFrame] = []
for file_path in file_paths:
result = self.parse_file(
file_path,
strict=strict,
)
frame = result.data.copy()
frame.insert(0, "Formula", result.formula)
frame.insert(0, "Material", result.material)
frame.insert(0, "PDF", result.pdf)
if include_source:
frame.insert(
0,
"Source",
str(result.source),
)
frames.append(frame)
if not frames:
prefix = [
"PDF",
"Material",
"Formula",
]
if include_source:
prefix.insert(0, "Source")
return pd.DataFrame(
columns=prefix + self.COLUMNS,
)
return pd.concat(
frames,
ignore_index=True,
)
def parse_directory(
self,
directory: str | Path | None = None,
*,
pattern: str = "*.txt",
recursive: bool = True,
strict: bool = False,
include_source: bool = True,
) -> pd.DataFrame:
"""解析目录中所有匹配的卡片文件。"""
files = self.list_cards(
directory,
pattern=pattern,
recursive=recursive,
)
return self.parse_many(
files,
strict=strict,
include_source=include_source,
)
使用方式
我们通过类对PDF提取代码进行了封装,主要使用PDFCardParser类对PDF卡片进行处理。首先,从脚本中导入 PDFCardParser 类。
# 假设脚本文件名为 pdf_parser.py,这里根据自己的习惯命名
from pdf_parser import PDFCardParser
# 初始化解析器
# 如果不指定目录,默认会在脚本同级目录下寻找名为 'PDFcards' 的文件夹
parser = PDFCardParser(cards_dir="/path/to/your/pdf_cards")
解析单个文件
使用 parse_file 方法可以解析一个指定的卡片文件,并返回一个包含所有解析结果的 PDFCardResult 对象。
- 参数:
file_path: 卡片文件的路径。strict: (可选) 布尔值。如果为True,当文件中存在无法解析的数据行时会抛出ValueError异常;如果为False(默认),则会发出警告但继续执行。
- 返回:
PDFCardResult对象。
# 解析单个文件
result = parser.parse_file("example_card.txt")
# 访问解析结果
print(f"PDF编号: {result.pdf}")
print(f"材料名称: {result.material}")
print(f"化学式: {result.formula}")
print(f"数据行数: {result.peak_count}")
print(f"是否有效: {result.is_valid}")
# 获取结构化的衍射数据 (pandas DataFrame)
df_data = result.data
print(df_data.head())
# 获取元数据 (字典)
print(result.metadata)
# 获取解析过程中的问题 (列表)
for issue in result.issues:
print(f"第 {issue.line_number} 行有问题: {issue.reason}")
解析整个目录
使用 parse_directory 方法可以一次性解析指定目录下的所有匹配的卡片文件,并将所有数据合并成一个大的 pandas.DataFrame。
- 参数:
directory: (可选) 要解析的目录路径。默认为初始化时指定的cards_dir。pattern: (可选) 文件匹配模式,默认为"*.txt"。recursive: (可选) 是否递归搜索子目录,默认为True。strict: (可选) 同parse_file。include_source: (可选) 是否在结果中包含源文件路径,默认为True。
- 返回: 一个合并了所有卡片数据的
pandas.DataFrame。
# 解析整个目录
combined_df = parser.parse_directory(recursive=True)
# 查看合并后的数据
print(combined_df.head())
# 结果 DataFrame 会包含 'Source', 'PDF', 'Material', 'Formula' 以及衍射数据列
查找卡片文件
PDFCardParser 提供了便捷的方法来在指定目录中查找卡片文件。
list_cards(directory, pattern, recursive): 列出目录下所有匹配的文件路径。find_card(query, exact): 根据文件名查找卡片。exact=True为精确匹配,False为模糊匹配。
# 列出所有txt文件
all_files = parser.list_cards(pattern="*.txt")
print(all_files)
# 精确查找文件
try:
file_path = parser.find_card("00-001-1234.txt")
print(f"找到文件: {file_path}")
except FileNotFoundError as e:
print(e)
# 模糊查找文件
try:
# 查找文件名中包含 "silicon" 的文件
file_path = parser.find_card("silicon", exact=False)
print(f"找到文件: {file_path}")
except ValueError as e:
# 如果找到多个匹配项,会抛出 ValueError
print(e)
except FileNotFoundError as e:
print(e)
解析结果说明
PDFCardResult 对象属性
| 属性名 | 类型 | 描述 |
|---|---|---|
pdf |
str |
PDF卡片编号,如 "PDF#00-001-1234"。 |
material |
str |
材料名称。 |
formula |
str or None |
化学式。 |
data |
pd.DataFrame |
包含衍射数据的结构化表格。 |
source |
Path or None |
源文件的路径。 |
metadata |
dict |
从文件头部提取的键值对元数据。 |
issues |
list |
解析过程中遇到的问题列表。 |
is_valid |
bool |
卡片是否有效(成功解析出数据且无异常行)。 |
peak_count |
int |
解析出的衍射峰数量。 |
衍射数据列 (data DataFrame)
解析出的 data DataFrame 包含以下标准列,无法识别的列将填充为 pd.NA。
| 列名 | 描述 |
|---|---|
2theta |
衍射角 2θ。 |
d(AA) |
晶面间距 d。 |
I(f) |
相对强度。 |
(hkl) |
晶面指数。 |
theta |
(可选) 衍射角 θ。 |
1/(2d) |
(可选) 1/(2d) 值。 |
2pi/d |
(可选) 2π/d 值。 |
n^2 |
(可选) n的平方值,会自动识别为整数或浮点数类型。 |
更多推荐
所有评论(0)