招股说明书PDF结构化解析:从文本抽取到表格识别的完整指南

发布时间:2026/9/18 12:15:39
招股说明书PDF结构化解析:从文本抽取到表格识别的完整指南 简介诺威健康上市招股说明书为诺威健康科技控股有限公司赴港上市提交的聆讯后资料集是一份医疗健康与新经济行业投融资研究的重要一手PDF文件。内容以繁体中文撰写涵盖公司业务、财务、行业竞争及风险因素等标准章节适合关注临床合同研究组织CRO赛道、港股新股研究及跨境医药投资的分析师与研究者。该文件为单个PDF大小3.47MB便于下载归档与关键词检索目前已有131人学习使用。通过其中完整的公司运营与募资信息读者可快速掌握企业临床服务能力、客户结构及资金用途也可作为学习港股上市披露文件格式与专业表述的真实案例。1. 招股说明书 PDF 拿在手里先想想怎么把它变成可检索的数据诺威健康上市招股说明书.pdf 这串名字里真正有操作含义的是“PDF”。招股书在申报、审核、公告阶段会产生多个版本每个都像排版工整的 Word 转存文件但内部混着电子文本、扫描签字页、嵌入字体和复杂表格框线。投研、底稿核查、信披系统对接时我们要的不是能翻页的 PDF而是能进 Excel 和数据库的文本、表格、图片字段。PDF 恰好是最难直接结构化的格式文本流可能不按视觉顺序排列表格没有单元格语义扫描页没有可复制文本。所以拿到这份文件第一件事不是打开阅读器而是按“类型判断 → 文本抽取 → 表格抽取 → 章节定位 → 校验”这套流程处理。下面从判断文件是电子版还是扫描版说起把这一类监管文档从头拆到尾。2. 招股说明书 PDF 的打开方式先判断是电子版还是扫描版2.1 用 PyMuPDF 做页面级文本量检测处理招股说明书 PDF 最忌讳一上来就开 OCR。只要 PDF 本身带文本层OCR 等于把已经存在的字符串重新识别一遍既慢又引入识别误差。大部分申报系统导出的电子招股书是带文本层的只有签字页、验资报告页会被扫描成图片。因此第一步是确认每一页有没有文本层。我习惯用 PyMuPDF 抽样看字符数它可以按物理页码读取文本速度比 pdfplumber 快不少。import fitz def text_layer_stats(pdf_path, sample12): doc fitz.open(pdf_path) chars [] for pno in range(2, min(sample 2, doc.page_count)): text doc[pno].get_text(text) chars.append(len(text.strip())) print(总物理页数:, doc.page_count) print(抽样页字符数:, chars) print(平均字符数:, sum(chars) / len(chars) if chars else 0) text_layer_stats(诺威健康上市招股说明书.pdf)这段代码从第 3 页开始抽样跳过封面和扉页。fitz.open()打开 PDF 后get_text(text)返回该页全部文本块的字符串。不同版面下字符数差异大但招股书正文页通常不低于 300 字财务附注页也有科目名和数字。如果平均字符数接近 0极大概率是扫描版。需要注意个别 PDF 把字体做成了矢量轮廓get_text返回空这种情况只能走渲染转图片分支判断标准不能只看抽样页还要用整页渲染的结果辅证。2.2 扫描版 PDF 用 pdftoppm 转图片后再 OCR确认整份或大部分页面没有文本层后先转图再做中文 OCR。转图工具我用pdftoppm来自 poppler-utilsWindows 下可用pdftoppm.exemacOS 用 Homebrew 的poppler包安装。关键是 DPI300 是中文文档的中庸选择低于 200 会让小五号字笔画黏连高于 400 对纯文字页没有实际收益反而让 OCR 时间线性上涨。mkdir -p scan pdftoppm -png -r 300 -f 1 -l 30 诺威健康上市招股说明书.pdf scan/page ls scan-f 1 -l 30只转前 30 页这是试错阶段的标准做法。先看一个小批量识别效果再决定整本转换避免白等。如果想验证扫描页文本层判断的正确率可以在转图后挑三五页做单页识别。OCR 引擎我优先用 PaddleOCR它对中文、数字、表格线的支持比 Tesseract 更稳。最新版 PaddleOCR 的调用方式已经变化2.x 用ocr.ocr(img)3.x 用ocr.predict(img)参数use_angle_clsTrue处理倾斜页面langch指定中文模型。from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch) for img in [scan/page-01.png, scan/page-02.png]: result ocr.predict(img) for line in result[0][rec_texts]: print(line)这里需要注意predict返回的结构在不同小版本里并不一致建议打印result[0]后看 key 是rec_texts还是text。OCR 是最后手段因为哪怕识别率 99%几百页招股书累计的错误也会覆盖关键数字。如果 OCR 结果和候选数字对不上宁可回到转图像素检查。判断结果PDF 特征处理路径注意事项电子版全文可复制抽样页平均字符数 200直接用 pdfplumber/PyMuPDF 抽取要处理字符断行和嵌入字体扫描版全文不可复制抽样页平均字符数 ≈ 0pdftoppm 渲染 PaddleOCRDPI 300 起步小批量试参混合版部分页面可复制部分不可按页分流签字页、附件多是扫描图2.3 混合版 PDF 按页分流不为几张扫描页耽误全文招股书里签字页、律师事务所鉴证报告、验资复核报告经常是图片。处理混合版时不该整本 OCR更不该整本跳过而是对每一页单独判断只有文本量低于阈值的页才转图片。这个阈值设 20 个字符比较合适因为盖章签字页上通常只有“本页无正文”几个字这些页面即使抽出来也没有分析价值但必须保留占位。import fitz doc fitz.open(诺威健康上市招股说明书.pdf) for pno in range(doc.page_count): page doc[pno] text_len len(page.get_text(text).strip()) if text_len 20: pix page.get_pixmap(dpi300) pix.save(fscan/page-{pno 1:03d}.png)range(doc.page_count)遍历所有物理页get_pixmap(dpi300)渲染当前页pno 1用来和 PDF 阅读器的页码对应。混合版处理完会得到一份“文本页 图片页”并存的结果清单。后续凡是文本层可用的页面都用 PyMuPDF 或 pdfplumber 抽取扫描图片页则统一走上一节的 OCR 管线。3. 招股说明书 PDF 表格拆解用 pdfplumber 把财务附注转成 DataFrame3.1 用 find_tables 先看表格边界而不是急着 extract招股说明书 PDF 里最有价值的是财务报表及其附注表格结构通常是三层表头、跨页、合并单元格、千分位数字。看到这种表格就想着extract_tables()全量抽出往往会得到大量错位值。我先用page.find_tables()返回每个表格的 bbox也就是边界坐标确认页面里有几个表格、每个表格大概占多大面积再决定下一步。import pdfplumber with pdfplumber.open(诺威健康上市招股说明书.pdf) as pdf: page pdf.pages[66] print(页面尺寸:, page.width, page.height) tables page.find_tables() for i, t in enumerate(tables): print(i, [round(v, 1) for v in t.bbox])t.bbox是(x0, y0, x1, y1)元组分别代表表格左上角和右下角的坐标。pdfplumber 默认用页面线条位置判断表格对全程带框线的招股书已经很稳。如果打印出来只有一个表格且 y0 到 y1 覆盖了正文中间区域说明表格检测正确。如果 bbox 把页眉页脚也包进去就要调整vertical_strategy和horizontal_strategy。常用策略有三个lines只看线条text看文字对齐explicit必须传入显式线条默认是lines。3.2 单元格清洗去掉换行、补空值、过滤整行空行接下来用find_tables()[0].extract()把表格数据提取成二维列表再放进 pandas。这个过程的坑在于单元格里的文本可能带换行符extract()对合并单元格的处理方式也和我们直觉不同合并后的内容会被复制到合并区域覆盖的每一行里。另一个坑是None单元格不处理的话 pandas 会把它当作 NaN 整行删除。import pandas as pd def table_rows_to_df(rows): rows [ [ if cell is None else cell.replace(\n, ) for cell in row] for row in rows if any(cell is not None and str(cell).strip() for cell in row) ] if not rows: return pd.DataFrame() return pd.DataFrame(rows[1:], columnsrows[0])cell.replace(\n, )把单元格内换行去掉保留空格在数值列清洗时处理。if any(...)过滤的是整行所有单元格都为空的行不是因为合并单元格导致的重复行。第一行作为列名看似正确但遇到两层表头的招股说明书写法时不成立。那时不要指定columns直接pd.DataFrame(rows)之后再用后半段表头单独拼接。3.3 跨页表格合并需要追踪物理页码财务报表动辄跨三四页续页表头样式和首页并不完全一样。最可靠的方式是把每个表格的每一行都追加页码再用关键词过滤重复表头。这里的页码取物理页码也就是 PDF 文件里的顺序和页脚印刷页码不同。开一个迭代器可以少写重复代码def iter_tables_in_range(pdf, start, end): for pno in range(start, end): page pdf.pages[pno] for t in page.find_tables(): yield pno 1, t.extract() raw_rows [] with pdfplumber.open(诺威健康上市招股说明书.pdf) as pdf: for pno, rows in iter_tables_in_range(pdf, 60, 80): for row in rows: if row and any(c is not None and str(c).strip() for c in row): raw_rows.append(row [pno])row [pno]是在原有列后面追加来源物理页方便后续定位异常行。过滤重复表头时不能只用“如果第一列是项目就跳过”因为数据行第一列也可能是“项目”。更稳的办法是判断整行是否和上一行完全一样只有完全一样的才删这能去掉多数续页重复表头又不误删正常数据。提示合并单元格导致同一科目名出现在多行时不要用drop_duplicates()否则会把合法数据行删掉。正确做法是在清洗完、转完数字后再按业务键去重。3.4 金额转数字千分位、括号负数、单位行一起处理招股书财务表里的数字基本带千分位逗号负数用括号表示单位通常写“金额单位万元”。解析时统一用函数处理def parse_amount(v): if v is None: return None s str(v).replace(,, ).replace( , ) if s.startswith(() and s.endswith()): s - s[1:-1] try: return float(s) except ValueError: return None df[金额] df[金额].map(parse_amount)replace(,, )处理千分位replace( , )处理数字间空格。括号负数的规则是先加负号再去括号。这里ValueError分支返回None而不是抛异常是为了让清洗流程继续跑最后统计哪些单元格无法转数字并人工确认。如果金额列混入了“万元”两个字float会失败所以应该提前过滤“单位”行。一个参考参数表如下场景处理参数说明表头占两行不指定 columns先保留原始二维列表再单独拼接表头跨页续表每行追加页码row [pno]定位错位行千分位数字replace(,, )先清洗再转 float括号负数字符串替换(123)--123缺失单元格替换为避免 pandas 误判为 NaN 丢弃4. 招股说明书 PDF 的正文与章节定位目录锚点加中文重排4.1 把 PDF 目录页码映射到物理页码招股书的目录页显示的是印刷页码不是 PDF 阅读器显示的物理页码。这个差异来自封面、承诺函、目录自身占据的页面数。要定位“某章节在 PDF 第几页”不能直接拿目录页码去跳。正确做法是先从前面几页抽目录文本匹配章节名和印刷页码再搜索正文中该章节标题第一次出现的位置算出偏移量。import re import pdfplumber with pdfplumber.open(诺威健康上市招股说明书.pdf) as pdf: toc for pno in range(1, 8): toc pdf.pages[pno].extract_text() or pattern re.compile(r(第[一二三四五六七八九十][节部]\s*\S{2,20}?)\s(\d)\s*$, re.M) for m in pattern.finditer(toc): print(m.group(1), m.group(2))正则里第[一二三四五六七八九十][节部]匹配“第一节”“第二部”这类关键词\S{2,20}?非贪婪匹配标题最后的数字是印刷页码。目录在 PDF 里可能是两栏甚至三栏行尾页码不一定正好在行末因此$在部分页面上会失效。遇到这种情况改用整段文本里找“标题数字换行”的组合。re.M让^$可以匹配每一行的开头和结尾。4.2 正文标题定位与偏移量计算拿到目录中的“第一节”之后再到全文搜索正文里这个标题第一次出现的物理页码。搜索时不能只用in判断因为页眉会重复出现章节名。我一般这么处理先找所有出现该标题的页码再取第一次且文本中出现的位置不在页面顶部 15% 的那一页。def locate_chapter_physical(pdf_path, title): with pdfplumber.open(pdf_path) as pdf: hits [] for pno, page in enumerate(pdf.pages): text page.extract_text() or idx text.find(title) if idx ! -1 and idx 80: hits.append(pno 1) return hits[0] if hits else Noneidx 80是粗略跳过页眉区。page.extract_text()返回当前页文本find只找第一次出现。如果命中多个物理页取第一个正常情况就是该章的正文起始页。将hits[0]减去目录给出的页码就是整份招股书的物理偏移量。这个偏移量应该在全书中保持不变如果第 200 页后偏移量跳变说明目录和正文存在不连续或插页处理时要特别留意。目录页字段示例用途章节标题第一节 释义传给 locate_chapter_physical印刷页码18与物理页码比较得到偏移量物理页码21用于后续抽取范围4.3 中文正文断行重排先排除表格区域再做段拼接电子版 PDF 抽取出的中文文本总是按行断开的。招股书的公文语体在 PDF 中每一行几乎都是视觉行宽不是自然段。做词频、人名识别、关键段落提取前要把断开的行拼回自然段。但这份 PDF 里表格区域也会被extract_text当作文本输出因此重排前先剔除表格内的词否则段尾会和单元格数字拼在一起。def outside_text(page): table_boxes [t.bbox for t in page.find_tables()] words page.extract_words() kept [] for w in words: if table_boxes and any( box[0] - 2 w[x0] and w[x1] box[2] 2 and box[1] - 2 w[y0] and w[y1] box[3] 2 for box in table_boxes ): continue kept.append(w[text]) return keptextract_words()返回每个词的位置和文本依次用 bbox 判断词是否落在表格边界内。宽容度 2 点是为了避免边框线略偏导致的误判。kept是表格外的词列表按words的顺序排好后再做回行拼接就会干净很多。真正拼接自然段的代码可以只判断当前行末尾标点import re def reflow(lines): out [] for line in lines: if re.search(r[。」』]\s*$, line): out.append(line.strip()) else: if out: out[-1] line.strip() else: out.append(line.strip()) return \n.join(out)out[-1] line.strip()表示不换行直接接到上一段末尾。中文字符之间不需要加空格。如果正文里有英文或数字拼接后可能让“ABC”和“公司”之间没有空格影响分词这种情况下再把line.strip()替换成 line.strip()但对中文匹配影响不大。4.4 关键字段抽取示例找连续出现的金额指标正文重排后业务数据提取就轻松很多。例如在“主要财务指标”章节里通常会连续出现“营业收入”“净利润”“扣非净利润”等指标名每个指标名后跟三个年度的金额。用正则按行匹配“指标名 三个数字”的模式可以一次性拿回结构化记录。for line in reflow(lines).splitlines(): if 营业收入 in line: nums re.findall(r-?\d{1,3}(?:,\d{3})*(?:\.\d)?, line) if len(nums) 3: print(line, nums[:3])r-?\d{1,3}(?:,\d{3})*(?:\.\d)?匹配带千分位的数字支持负数。如果一行里三个年的金额齐全就记录该行。这里只输出前三个数避免把表格页的残留内容也算进去。括号负数在正则里不会命中需要先用第 3 章的parse_amount逻辑把整行的负号格式统一。5. 招股说明书 PDF 抽取结果验证偏移量对账和表格边界可视化5.1 用页脚页码验证 PDF 物理偏移量招股书几乎所有页面都有印刷页码而且和目录页码一致。所以一个很好用的完整性校验是解析每页页脚算出“物理页码 - 印刷页码”应该是个常数。如果这个偏移量在中途变化意味着有人删页、插页或重新排版过这本 PDF 可能与目录不一致。def collect_pdf_offset(pdf_path): with pdfplumber.open(pdf_path) as pdf: offsets set() for pno, page in enumerate(pdf.pages): text (page.extract_text() or ).replace(\n, ) tail text[-80:] m re.search(r第\s*(\d)\s*页, tail) if m: offsets.add(pno 1 - int(m.group(1))) return offsetstext[-80:]取页面末尾 80 个字符一般足以覆盖页脚。页脚格式可能是“第 18 页 共 256 页”也可能是“18/256”第\s*(\d)\s*页对前者有效。后者要加备选正则(\d)\s*/\s*(\d)。offsets是集合如果最终只有一个元素比如{3}说明物理页码与印刷页码相差 3 页整个文档内部是自洽的。集合里有多个元素就要找出具体哪几页偏移异常重点检查那一页的表是否被漏拆。5.2 用文本长度分布定位异常页抽取完整性的第二个快速检查是统计每一页的文本长度。招股书正文页文本长度相对稳定表格页则偏短。把长度分布列出来能立刻发现哪几页是封面、空白页或扫描页。做法不需要额外库一行 dict 推导就够from collections import Counter with pdfplumber.open(诺威健康上市招股说明书.pdf) as pdf: lengths [len(page.extract_text() or ) for page in pdf.pages] print(Counter(lengths).most_common(10))Counter输出长度值出现频次。如果某页长度为 0 且不是封面或“本页无正文”页就要怀疑它是扫描图片页之前的分流逻辑漏掉了它。如果某个长度的页数特别多但那不是正常正文长度就说明整段页面的抽取有问题通常是字体嵌入导致文本顺序错乱需要回到页级重新检查。5.3 把表格识别框画回 PDF用眼睛做最终校对代码对表格的处理再快也免不了视觉校对。最好的校对方式是把 pdfplumber 识别出的表格框画回 PDF另存一份标记文件然后翻页检查框是否贴合原表。我用 PyMuPDF 画矩形import fitz import pdfplumber with pdfplumber.open(诺威健康上市招股说明书.pdf) as pdf, \ fitz.open(诺威健康上市招股说明书.pdf) as doc: for pno in range(60, 70): for table in pdf.pages[pno].find_tables(): x0, y0, x1, y1 table.bbox doc[pno].draw_rect(fitz.Rect(x0, y0, x1, y1), color(1, 0, 0), width1.5) doc.save(check_tables.pdf)fitz.Rect(x0, y0, x1, y1)的坐标系统与 pdfplumber 相同都是 PDF 点坐标因此可以直接复用 bbox。color(1, 0, 0)是红色width1.5画 1.5 点宽的线。保存为check_tables.pdf后只需要翻一遍重点看框有没有漏掉边框线、有没有把标题行框进去、有没有把两个表格框在一起。如果find_tables()在某个页面上漏检可以调整策略为page.find_tables(strategytext)因为某些表格边框颜色浅或线型是虚线默认按线策略查不到。注意draw_rect会修改当前页面对象不要在源文件名上直接保存另存为check_tables.pdf是底线避免破坏原始文件。页码对账加画框校对基本能保证抽出来的表格没有大面积漏行。至于单元格里的数值是否准确还需要拿两三个关键财务指标和人工读数做交叉验证这是任何代码都替代不了的。本文还有配套的精品资源点击获取