拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python处理FCOM程序PDF:从文本抽取到全文检索的完整方案

简介这份FCOM程序参考手册以波音737飞行操作手册为蓝本系统整理飞行运行中的标准操作与非正常处置逻辑面向飞行仿真软件设计、航空培训课件开发及程序逻辑校验等场景。资源为单个PDF文档大小仅18KB页面紧凑但覆盖完整已有236人学习/下载。文档从驾驶舱区域分工讲起包含座椅调整、起飞及着陆标准动作、刹车冷却表查表方法、放行/天气/杰普逊资料认读等正常程序也细化到中止启动条件、防冰使用规定、发动机起动电门使用时机、空速不可靠现象及处置、V1后发动机严重损坏、无线电通讯失效判断与处置等非正常情况。此外还列明B737侧风标准道面状况及风修正值计算方式、起飞形态警告与发动机火警处置原则。对于需要将操作手册转化为软件规则、检查单或培训材料的人员这份PDF可提供直接的参考结构和关键参数。1. 面对FCOM程序参考PDF先把它当作数据处理而不是文档阅读航司或维修单位的IT经常接到这样的文件一个名为“FCOM程序[参考].pdf”的PDF几千页双栏排版目录里的超链接有一半指向错误页码。FCOM是Flight Crew Operating Manual飞行机组操作手册里面装的是正常程序、非正常程序、系统描述和性能限制。这类手册的形势很特殊——它永远是“参考版”永远在修订读的人却必须依赖它执行关键步骤。问题是你不能靠人一页页划线标记也不能把PDF直接扔进Windows搜索就完事。双栏文本会让关键词命中和实际章节错位页脚带修订信息版本之间差异难追踪。这里把结论先放在前面把这份PDF当作需要批次清洗的数据按“文本层判断 → 章节抽取 → 表格结构化 → 版本对比 → 全文检索”的顺序处理比对几千页更可靠。这套做法同样适用船舶操作手册、重型设备维修手册以及任何带版次控制的工程文档。下面从最容易翻车的第一关开始拆。2. 拆FCOM程序PDF前的三道关卡文本层、页眉干扰与编号规则拿到“FCOM程序[参考].pdf”第一件事不是写解析器而是先回答一个问题这个PDF里面的文字能不能被直接复制出来很多参考版PDF其实是扫描件套了一层空壳表面能打开实际全是图片。跳过这一步后面所有正则和表格抽取都会白做。2.1 先判原生文本还是扫描件一条命令看文本密度这里常用的做法是用PyMuPDF打开文件逐页统计文本字符量。原生生成的双栏FCOM每页通常有2000个以上字符扫描件一页只有0到几十个字符差别非常明显import fitz # PyMuPDF def text_density_check(pdf_path: str): doc fitz.open(pdf_path) total_chars 0 empty_pages 0 for page_num in range(doc.page_count): page doc.load_page(page_num) chars len(page.get_text()) total_chars chars if chars 30: empty_pages 1 avg_chars total_chars / doc.page_count print(f页数{doc.page_count}, 总字符{total_chars}, 平均每页{avg_chars:.1f}, 空白页{empty_pages})这段代码的核心是把“能不能读”变成“平均每页字符数”这个可量化指标。判断标准可以按下面的经验值走平均每页字符数结论后续动作大于 500原生文本层完整直接用正则和坐标抽取50 到 500可能是混合排版或纯图页码较多抽样5页人工确认小于 50基本是扫描件先走OCR管线再做抽取注意用字符数做阈值存在误判空间。FCOM里系统图、极限性能表这些页面本身文字少如果整份PDF平均在300字符左右最好再查一下页面对应的内容而不是直接判定为扫描件。我一般还会顺便统计一下每页的字体列表原生文本页至少会包含Helvetica或Serif类字体信息扫描件通常没有可用字体信息。2.2 页眉页脚与双栏排版先做坐标过滤再做阅读顺序修复FCOM的每一页都有不变的页眉手册号、章节号、章节标题。页脚则是修订日期、打印日期、页码。如果不过滤它们章节标题会被错误识别“Rev 5”会混进正文里。这里需要按坐标过滤而不是按文本内容过滤因为页眉页脚的内容本身是合法的。def clean_blocks(page): blocks page.get_text(blocks) cleaned [] width, height page.rect.width, page.rect.height for block in blocks: x0, y0, x1, y1, text, *_ block # 顶部12%是页眉区底部6%是页脚区 if y0 height * 0.12 or y1 height * 0.94: continue if len(text.strip()) 2: continue cleaned.append((round(y0, 1), round(x0, 1), text.strip())) # 按阅读顺序排序先按y轴分桶再按x轴从左到右 cleaned.sort(keylambda item: (int(item[0] // 20), item[1])) return cleaned代码里的height * 0.12和height * 0.94是两个可调参数前者圈定页眉区后者圈定页脚区。FCOM不同机型的排版略有差异有的页眉更厚实建议把12%调整到15%再跑一遍对比抽取结果的标题数量。阅读顺序排序那行是关键int(item[0] // 20)把页面按20磅高度切成一条条横带在每条横带内再按x坐标排序。20这个值来自对FCOM双栏字号的观察——正文字高通常在10到14磅一行约18到20磅用20做桶高基本能保证同一物理行落在一个桶里。若桶高设置成200双栏右栏会被整体排到左栏后面。2.3 程序步骤与章节编号的正则特征先定规则再写逻辑FCOM程序章节的编号规律非常稳定例如1.2.3 LANDING章节号后紧跟大写英文标题程序步骤则常以“PFO”、“P”这类缩写开头。利用规则能快速切分章节边界import re SECTION_RE re.compile(r^\s*(\d(?:\.\d){1,2})\s([A-Z][A-Z0-9 /_-]{2,})$) STEP_RE re.compile(r^\s*(\[?[A-Z]{1,3}/[A-Z ]{1,4}\]?)\s(.)$)SECTION_RE用来匹配数字.数字[.数字] 大写标题比如3.2.1 ENGINE FIRE。STEP_RE匹配PFO、P/FO这类程序标识符。写正则时要注意两个坑一是FCOM标题可能包含斜杠和连字符字符集里必须显式加上二是程序的“条件-动作-结果”表格里第一列经常是缩写加斜杠直接命中STEP_RE的误报率很高所以抽取后要有一步“表格优先还是文本优先”的仲裁逻辑——表格里的内容优先按表格处理不要混进文本流。提示这套正则不是为了覆盖所有机型FCOM而是为了够用。不同厂家的手册编号规则不完全一致但章节层级和程序标识符的大结构是类似的规则可以复用。3. 用Python搭一个FCOM程序PDF抽取管道过了文本层判断这关接下来要解决的是把整本PDF变成结构化数据。这里说的“结构化”指的是能回答三个问题某条程序在哪个章节、在PDF的哪一页、表格里有几列内容。下面给出一套最小可运行的管道分三层递进。3.1 最小可用抽取管道文本、表格、元数据一次成型读取PDF时不要把文本、表格、图片分开处理那样后面还要再做页面级对齐。这里直接让每页输出一个JSON对象包含文本块列表、表格对象和原始页面尺寸import json from pathlib import Path import fitz import pdfplumber def extract_fcom(pdf_path: str, header_ratio0.12, footer_ratio0.94): result {} with pdfplumber.open(pdf_path) as pdf: for page_idx, page in enumerate(pdf.pages): width, height page.width, page.height texts [] tables [] for block in page.extract_text_lines(): x0, top, x1, bottom block[x0], block[top], block[x1], block[bottom] if top height * header_ratio or bottom height * footer_ratio: continue texts.append({ text: block[text], x0: round(x0, 1), top: round(top, 1), x1: round(x1, 1), bottom: round(bottom, 1), }) for table in page.extract_tables(): tables.append(table) result[page_idx 1] { size: [width, height], texts: texts, tables: tables, } return result data extract_fcom(FCOM程序[参考].pdf) Path(fcom_pages.json).write_text( json.dumps(data, ensure_asciiFalse, indent2) )这套抽取方案里pdfplumber负责表格和精细坐标PyMuPDF负责快速文本密度检查分工明确。extract_text_lines()比extract_text()好用很多因为它会返回每一行的bbox坐标方便后续做阅读顺序排序。参数header_ratio和footer_ratio在函数签名里直接暴露调优时不用翻主逻辑。输出JSON里保留x0和top值是为了给后续的标注工具和问题复现留原始证据这是处理工程手册时容易被忽略的一步。3.2 章节树组装把散落文本拼成可检索的结构拿到逐页数据后下一步是根据章节正则把文本块归入对应章节。不要试图一次性构建完整树先做“章节号 → 起始页”的映射再逐页追加from collections import OrderedDict def build_chapter_map(pages, section_re): chapter_map OrderedDict() current_h2 None current_h3 None for page_no, page_data in pages.items(): for line in page_data[texts]: text line[text].strip() match section_re.match(text) if not match: continue section_id match.group(1) section_title match.group(2) depth len(section_id.split(.)) if depth 2: current_h2 section_id current_h3 None chapter_map.setdefault( f{current_h2} {section_title}, {start_page: page_no, children: []} ) elif depth 3 and current_h2: current_h3 section_id chapter_map[f{current_h2} {page_data[texts][0][text]}][children].append( {f{section_id} {section_title}: page_no} ) return chapter_map这里的核心参数是depth。FCOM的标题层级一般到三级二级是“正常程序”这种大类三级是具体程序。二级标题出现时重置三级指针三级标题挂到当前二级节点下。这个逻辑能处理最常见的版本一个二级标题后面跟着若干个三级标题。注意上面代码中使用page_data[texts][0][text]做键是为了避免维护另一个状态变量如果页面上第一个文本不是真正的二级标题这里会出错更稳妥的做法是在current_h2变化时直接记录标题字符串。3.3 表格抽取FCOM程序表格的列与行策略FCOM程序章节中有大量“条件-动作-响应”型表格这类表格在PDF上的表现是带完整边框的。用pdfplumber的extract_tables()时我会显式指定策略import pdfplumber table_settings { vertical_strategy: lines, horizontal_strategy: lines, snap_tolerance: 3, join_tolerance: 3, edge_min_length: 5, } with pdfplumber.open(FCOM程序[参考].pdf) as pdf: for page_no, page in enumerate(pdf.pages, 1): tables page.extract_tables(table_settings) for table in tables: print(f第{page_no}页 表格{len(table)}行 x {len(table[0])}列)vertical_strategy和horizontal_strategy都设为lines含义是只按实际存在的线条切分表格这在FCOM的线框表格上效果最稳。snap_tolerance和join_tolerance是处理断线连接的参数扫描版的表格线经常会断裂调大到5可以多救回几列。实际使用常见的问题是表头跨页一个表格从第132页断到第133页后一页没有表头。处理方式很简单——检测到表格首行全是纯数字或空单元格时把上一页表格的最后一行结构复制过来作为表头。还有一个容易踩坑的点PDF物理页码与FCOM印刷页码不一致。参考版大概率是重新排版过的PDF第3页可能是印刷页1-00-02。做章节树和后续diff时数据库里需要单独存两个页码字段字段示例说明pdf_page45物理页索引从1开始printed_page1-31-05FCOM印刷页号出现在页眉section_id3.2.4所属章节号印刷页号是追踪版本修订的主键物理页码只是定位手段这个关系别搞反。4. 从difflib到SQLite FTS5FCOM程序版次对比与全文检索参考版FCOM最大的价值在“修订对比”。航空公司每年会收到几轮修订需要知道新版改了哪几页、哪个程序步骤。直接对整个PDF做文本diff是灾难——只要前面插入一行后面所有行都会标红。更实用的做法是先把文档按章节切好再逐章做差异对比。4.1 逐章diff先切章节再比较避免全局错位import difflib def diff_section(old_lines: list[str], new_lines: list[str], section_name: str): differ difflib.Differ() diff list(differ.compare(old_lines, new_lines)) added [line for line in diff if line.startswith( )] removed [line for line in diff if line.startswith(- )] print(f[ {section_name} ] 新增 {len(added)} 行删除 {len(removed)} 行) for line in diff: if line.startswith(( , - )): print(line) if len(line) 200: break调用前需要把第三章的抽取结果按section_id切分提取每个章节下的正文文本。difflib.Differ()输出的是带标记的行表示新增-表示删除。逐章diff的好处是章节标题和页眉被排除后步骤行号即使改变也不会影响其他章节的比较结果。commonprefix这类算法在整书级别会失效章节级别仍然可控。4.2 基于SQLite FTS5建一个可查询的全文索引版本对比做完把新旧版本里“存活”的内容写入SQLite的FTS5虚拟表。FTS5是SQLite自带的全文检索模块适合这种单机、千页级、需要快速命中的场景不需要额外起一个Elasticsearch实例import sqlite3 conn sqlite3.connect(fcom.db) conn.execute( CREATE VIRTUAL TABLE IF NOT EXISTS fcom_fts USING fts5( section_id, printed_page, content, tokenizeunicode61 ) ) rows [] for sec_id, page_no, text in prepared_texts: rows.append((sec_id, str(page_no), text)) conn.executemany( INSERT INTO fcom_fts(section_id, printed_page, content) VALUES (?, ?, ?), rows, ) conn.commit()FTS5的匹配语法默认把空格当作ANDpitch attitude会匹配两个词同时出现的行。要精确搜短语时需要加引号pitch attitude。snippet函数可以把命中上下文截取出来减少应用层的拼装工作。4.3 用FTS5的snippet参数控制命中上下文query pitch attitude sql SELECT section_id, printed_page, snippet(fcom_fts, 2, [, ], …, 15) FROM fcom_fts WHERE content MATCH ? for row in conn.execute(sql, (query,)): print(f{row[0]} | {row[1]} | {row[2]})snippet函数的最后一个参数15表示输出15个token前后用方括号标出命中词。FTS5检索词里带特殊符号时程序步骤里的“1/2”这种文本很容易触发语法错误。稳妥做法是在组装query时对用户输入做一层转义def escape_match(query: str) - str: query query.replace(, ) special [(, ), AND, OR, NOT, NEAR, *] for token in special: query query.replace(token, f{token}) return query.strip()这里把可能干扰FTS5语法的token强制转成普通字符串避免用户输入“P/FO AND NOT”这类组合时查询崩掉。FTS5的unicode61分词器对大小写不敏感但斜杠会被当成普通字符保留下来搜索“P/FO”时记得先做同样的转义。注意SQLite FTS5默认不处理中文分词但FCOM正文以英文为主unicode61足够。若要处理中文版手册就得换分词器或引入额外的分词组件这块不在本次标题范围内。5. 给FCOM程序PDF的产出做一个可用的检索接口前面的章节把抽取、入库都做完了最后一步是给“FCOM程序[参考].pdf”这篇文章一个随时能查的入口。直接启动一个Web服务可能是过度设计一个命令行接口在大多数内部场景里更实用因为它不依赖额外依赖也容易接进CI流程。5.1 把查询包成一个十几行的CLI#!/usr/bin/env python3 import argparse import sqlite3 def search_query(query: str, limit: int 5): clean query.replace(, ) conn sqlite3.connect(fcom.db) sql SELECT section_id, printed_page, snippet(fcom_fts, 2, [, ], …, 15) FROM fcom_fts WHERE content MATCH ? LIMIT ? for section_id, page, snippet in conn.execute(sql, (clean, limit)): print(f{section_id} | {page} | {snippet}) if __name__ __main__: parser argparse.ArgumentParser(descriptionFCOM program search) parser.add_argument(query, helpsearch text, e.g. engine fire) parser.add_argument(--limit, typeint, default5) args parser.parse_args() search_query(args.query, args.limit)这种接口的好处在于它是无状态、可编排的。内部工具链里可以直接调它验证“新版手册是否删除了某条程序步骤”也能把输出接到企业微信机器人或内部Wiki每天自动跑一遍关键步骤索引检查新增了哪些修订页。5.2 一个能直接复用的技巧用NEAR运算符约束程序步骤距离FCOM程序里经常出现这样的场景搜“engine fire”想定位到非正常程序但“engine”出现在第一行“fire”出现在表格第三列两个词相距上百个token普通AND匹配会把大量系统描述页也带进来。这里可以手动在query里拼NEARquery engine NEAR/15 fireNEAR/15表示两个词在15个token的距离内必须同时出现专门用于处理表格里“条件”和“动作”分布在同段落但位置错开的情况。这个距离值可以根据上一步的表格抽取情况调整程序表格里同一行的token距离基本在5到10个以内。如果搜出来的结果仍然发散把15收窄到8精确度会显著提升。把这条查询接到CLI里python fcom_cli.py engine NEAR/15 fire --limit 10返回结果会直接从SQLite里把所在章节、印刷页码和命中上下文打出来。对于“FCOM程序[参考].pdf”这种三级标题加上百个程序步骤的手册这套流程已经足够支撑日常的版本对比、程序查询和修订追踪。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门