使用Python提取PDF卡片

发布时间:2026/7/2 11:33:56

使用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(slotsTrue) class ParseIssue: 单条未解析数据行的诊断信息。 line_number: int line: str reason: str dataclass(slotsTrue) class PDFCardResult: 单张 XRD PDF 卡片的结构化解析结果。 pdf: str material: str formula: str | None data: pd.DataFrame source: Path | None None metadata: dict[str, str] field(default_factorydict) issues: list[ParseIssue] field(default_factorylist) 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 后 04 个附加数值列 - 支持普通负号和常见 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(?Ptwo_theta{NUMBER}){HSPACE} rf(?Pd{NUMBER}){HSPACE} rf(?Pintensity{NUMBER}){HSPACE} rf\({OPT_HSPACE} rf(?Ph{SIGNED_INT}){HSPACE} rf(?Pk{SIGNED_INT}){HSPACE} rf(?Pl{SIGNED_INT}) rf{OPT_HSPACE}\) rf(?Ptail(?:{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() ), keylambda 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(encodingencoding) 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 后允许出现 04 个数值依次解释为 theta、1/(2d)、2pi/d 和 n^2。 match self._row_pattern.fullmatch(line) if match is None: return None, ParseIssue( line_numberline_number, lineline, 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 :], startheader_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, columnsself.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], errorscoerce, ) n_squared pd.to_numeric( data[n^2], errorscoerce, ) 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, stacklevel2, ) return PDFCardResult( pdfpdf, materialmaterial, formulaformula, datadata, source( Path(source).expanduser().resolve() if source is not None else None ), metadatametadata, issuesissues, ) 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), sourcepath, strictstrict, ) # ------------------------------------------------------------------ # 批量解析 # ------------------------------------------------------------------ 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, strictstrict, ) 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( columnsprefix self.COLUMNS, ) return pd.concat( frames, ignore_indexTrue, ) 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, patternpattern, recursiverecursive, ) return self.parse_many( files, strictstrict, include_sourceinclude_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(fPDF编号: {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(recursiveTrue) # 查看合并后的数据 print(combined_df.head()) # 结果 DataFrame 会包含 Source, PDF, Material, Formula 以及衍射数据列查找卡片文件PDFCardParser提供了便捷的方法来在指定目录中查找卡片文件。list_cards(directory, pattern, recursive): 列出目录下所有匹配的文件路径。find_card(query, exact): 根据文件名查找卡片。exactTrue为精确匹配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, exactFalse) print(f找到文件: {file_path}) except ValueError as e: # 如果找到多个匹配项会抛出 ValueError print(e) except FileNotFoundError as e: print(e)解析结果说明PDFCardResult对象属性属性名类型描述pdfstrPDF卡片编号如 PDF#00-001-1234。materialstr材料名称。formulastrorNone化学式。datapd.DataFrame包含衍射数据的结构化表格。sourcePathorNone源文件的路径。metadatadict从文件头部提取的键值对元数据。issueslist解析过程中遇到的问题列表。is_validbool卡片是否有效成功解析出数据且无异常行。peak_countint解析出的衍射峰数量。衍射数据列 (dataDataFrame)解析出的dataDataFrame 包含以下标准列无法识别的列将填充为pd.NA。列名描述2theta衍射角 2θ。d(AA)晶面间距 d。I(f)相对强度。(hkl)晶面指数。theta(可选) 衍射角 θ。1/(2d)(可选) 1/(2d) 值。2pi/d(可选) 2π/d 值。n^2(可选) n的平方值会自动识别为整数或浮点数类型。

相关新闻