# 本文中的代码经过了小幅度的测试,并且可以正常使用。但是在使用时必须进行额外的检查,笔者仅分享提取的脚本,不做任何承诺。

PDF卡片

在材料研究中,PDF卡片常用于无机材料的物相鉴定。原始的PDF卡片格式包含丰富的物相信息和衍射数据,但这种格式无法直接被Pandas等Python库读取。虽然可以利用AI技术进行小批量数据提取,但这种方法存在命名标准不统一、难以实现大规模标准化数据结构等问题。为此,我们开发了一个专门用于读取PDF卡片的Python脚本工具。

Python脚本

我们的提取脚本具备以下优势特性:

  1. 精准逐行解析
    采用逐行处理机制,有效规避正则表达式跨行匹配可能引发的数据异常

  2. 出色的格式兼容性

    • 智能适配含/不含 n² 列的数据结构
    • 支持 hkl 晶面指数后跟随 0 至 4 个附加数值列
    • 全面兼容标准负号及各类 Unicode 负号变体
  3. 智能编码识别
    自动检测 UTF-8、GB18030、UTF-16 等主流文本编码格式

  4. 高效的批量处理
    支持单文件解析、多文件批处理及全目录扫描操作

  5. 灵活的容错机制
    提供严格模式选项:可配置为遇到异常数据时抛出错误或仅输出警告

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的平方值,会自动识别为整数或浮点数类型。

更多推荐