ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

招股说明书PDF解析:版面还原、章节切分与表格校验

招股说明书PDF解析:版面还原、章节切分与表格校验 简介《天鹅股份首次公开发行股票招股说明书.PDF》是山东天鹅棉业机械股份有限公司首次公开发行A股的正式披露文件面向投行、法律、财务、证券研究及关注棉业机械行业的投资者。文件完整呈现发行概况拟发行不超过2334万股每股面值1元发行价8.93元预计2016年4月15日发行拟登陆上海证券交易所发行后总股本9334万股。同时收录控股股东山东省供销合作社联合社36个月锁定承诺、其他股东12个月锁定承诺、董监高及核心技术人员减持限制、中德证券先行赔付承诺与发行人声明便于读者梳理IPO审核要点、股份流通限制、公司治理和投资风险。资源包共1个PDF文件大小约5.96MB结构清晰已有256人学习下载适合作为招股书研读、投融资尽调与行业案例检索的参考资料。1. 一份招股说明书PDF为什么成了文档解析的硬骨头拿到「天鹅股份首次公开发行股票招股说明书.PDF」这类文件多数人的第一反应是 CtrlF第二反应是整本丢给大模型。两次都会失望前者在三百多页、正文五号宋体、表格字号更小的文档里只能捞到零散词条后者会因为上下文超长而丢掉中间章节答案还带不出页码无法回溯核对。招股说明书PDF的特殊性在于它同时是排版文档和数据仓库——「释义」「概览」「业务与技术」是连续散文董监高薪酬、关联交易、审计报告附注是密集数字表两者混排、跨页断裂、页眉页脚穿插。写这份材料的工程师通常在做三件事把财务数据抽成结构化表、把全文做成可检索知识库、把问答结果锚回原文页码。后面几章按「先看清版面再切章节再抽数字最后做检索」的顺序推演每一步都给可复现的命令和参数。2. 招股说明书PDF的文本层判定与坐标级版面还原2.1 先分清这份PDF是文本版还是扫描版这一步跳过后面全是无用功。文本版能直接抽出字符和坐标扫描版必须先走 OCR两者的表格识别策略完全不同。用 PyMuPDF 十行代码就能判定别急着上大模型。import fitz # PyMuPDF包名是 fitz doc fitz.open(天鹅股份_招股说明书.pdf) print(页数:, doc.page_count, 是否加密:, doc.needs_pass) empty_pages, char_total [], 0 for i, page in enumerate(doc): text page.get_text(text).strip() # 按阅读顺序拼文本 char_total len(text) # 一页抽不出 20 个字符但页面里又存在图像对象基本可判为扫描页 if len(text) 20 and page.get_images(fullTrue): empty_pages.append(i) print(总字符数:, char_total, 疑似扫描页:, len(empty_pages), empty_pages[:10]) print(平均每页字符:, round(char_total / doc.page_count, 1))get_text()的三个常用模式要分清楚text只回字符串适合统计和全文检索blocks回(x0, y0, x1, y1, text, block_no, block_type)七元组适合做段落合并dict回嵌套字典能拿到每个 span 的字号、字体和 flags是做标题识别的唯一选择。page.get_images(fullTrue)返回图像对象列表fullTrue才会带上 xref 等信息。判定阈值按经验取平均每页字符数低于 200 且疑似扫描页占比超过 30%就直接转 OCR 流程用 PaddleOCR 或 ocrmypdf 先做一层文本覆盖不要指望后续脚本能凭空变出文字。判定信号文本版特征扫描版特征处理动作平均每页字符数800 以上低于 200低于阈值走 OCRpage.get_images()少量或无每页一个大图走 OCRpdffonts输出有嵌入字体列表空空则走 OCR复制粘贴效果文字可选只能选中整页走 OCR这条命令在 Linux/macOS 下用 poppler-utils 更快验证一遍pdffonts 招股说明书.pdf | head -20输出为空基本就是扫描件再用pdfinfo 招股说明书.pdf | grep -E Pages|Encrypted确认页数与是否加密。2.2 用坐标块还原段落顺序顺便干掉页眉页脚招股说明书里「释义」表是左右两栏「董监高情况」是姓名加职务两栏直接get_text(text)出来的顺序经常串行。稳妥做法是按块取文本用 y 坐标做主排序键、x 坐标做次排序键并且把页面上下边缘的重复内容剔掉。import fitz PAGE_TOP_CUT, PAGE_BOTTOM_CUT 60, 60 # 页眉页脚带的高度按实际版心调 def page_blocks(page, y_tol5): h page.rect.height blocks page.get_text(blocks) kept [] for b in blocks: x0, y0, x1, y1, text, _, btype b if btype ! 0: # 0 是文本块1 是图像块 continue text text.strip() if not text: continue if y0 PAGE_TOP_CUT or y1 h - PAGE_BOTTOM_CUT: continue # 丢掉页眉页脚 kept.append((x0, y0, text)) # 先按 y 归并到 y_tol 容差的一行再按 x 从左到右排 kept.sort(keylambda t: (round(t[1] / y_tol), t[0])) return [t[2] for t in kept] doc fitz.open(天鹅股份_招股说明书.pdf) for line in page_blocks(doc[88])[:15]: print(repr(line[:60]))y_tol5是关键参数同一行的左右两栏 y 坐标通常差不到 2 个点跨行的间距一般在 12 个点以上取 5 能把「同一行」和「下一行」分开。PAGE_TOP_CUT设小了页眉会混进正文设大了首行正文会被吃掉招股说明书版心一般上下留白 50 到 70 点先打印前 20 个块的y0观察一下再定。这个函数返回的是行列表做章节切分前先跑一遍全文把每行的页码、行号一起存下来后面做引用回溯全靠它。提示不要用page.get_text(text)的输出做章节切分那个顺序在双栏页面上不可靠切出来的「第X节」和正文会错位。2.3 表格抽取pdfplumber 的线框策略什么时候失效有完整框线的表格用 pdfplumber 的 lines 策略最稳无框线的靠 text 策略猜列边界。招股说明书的财务附注表格九成带框线但「发行人股权结构图」这类半框表格会翻车。import pdfplumber with pdfplumber.open(天鹅股份_招股说明书.pdf) as pdf: page pdf.pages[104] tables page.extract_tables({ vertical_strategy: lines, # 竖线由实际线条决定 horizontal_strategy: lines, # 横线同理 snap_tolerance: 3, # 断开的线在 3 点内吸附成一条 join_tolerance: 3, edge_min_length: 5, # 短于 5 点的线忽略去掉装饰线段 intersection_tolerance: 3, }) for t in tables: for row in t[:6]: print([None if c is None else c.replace(\n, ) for c in row])四个容差参数的作用是消化矢量线条的微小错位。招股说明书里表格线经常被打断成多段snap_tolerance和join_tolerance设在 3 到 5 之间能自动接上设太大会把相邻两个表粘成一个。edge_min_length用来过滤表格外的横线装饰。如果输出列数明显不对先把vertical_strategy换成text再跑一遍对比text 策略按字符间距推断列边界对无框线的「分地区收入构成」表更合适。两条策略都试过还是乱就退回 2.2 的坐标块方案用 x0 聚类自己分列。2.4 提速与两个高频报错三百页文档逐页抽表格单进程要跑几分钟到十几分钟。按页区间切给多进程是性价比最高的优化PyMuPDF 的对象不能跨进程传递每个子进程自己fitz.open()一次。from multiprocessing import Pool import fitz PATH 天鹅股份_招股说明书.pdf def work(rng): start, end rng doc fitz.open(PATH) # 每个进程独立打开不要传 doc 对象 out [] for i in range(start, end): out.append((i, doc[i].get_text(blocks))) doc.close() return out if __name__ __main__: doc fitz.open(PATH) n doc.page_count step 40 # 每 40 页一个任务任务太碎反而慢 ranges [(i, min(i step, n)) for i in range(0, n, step)] with Pool(4) as p: results p.map(work, ranges) print(已处理页数:, sum(len(r) for r in results))step取 40 左右比较均衡太小进程调度开销占比高太大最后一个任务拖尾。两个常见报错一是抽出来是乱码或方框说明 PDF 里的字体没有 ToUnicode 映射先用pdftotext -enc UTF-8验证一遍能出正确文本就换 poppler 系工具二是ValueError: cannot save with zero pages通常是打开了加密文档先检查doc.needs_pass需要口令就在fitz.open(path)后调用doc.authenticate(pwd)。3. 用书签与标题锚点把招股说明书PDF切成章节树3.1 优先读 PDF 内置书签成本最低招股说明书的排版流程相对规范多数成品带 PDF 书签直接读出来的层级比任何正则都准。import fitz doc fitz.open(天鹅股份_招股说明书.pdf) toc doc.get_toc(simpleTrue) # [[level, title, page], ...] page 从 1 开始 print(书签条目数:, len(toc)) for lvl, title, page in toc[:12]: print( * (lvl - 1) f[{lvl}] {title} - p{page})get_toc(simpleTrue)返回三元组列表level是层级page是 1 基页码正好可以直接拿来当章节的起止边界。判断可信度的标准很简单条目数在 20 到 200 之间、顶级标题里能看到「释义」「概览」「业务与技术」「财务会计信息」这类词就可以直接用。如果return []说明这份文件没有书签走 3.2 的字号加正则方案。3.2 没有书签时用字号和正则做标题锚点招股说明书的节标题有稳定特征字号明显大于正文、通常加粗、以「第X节」开头。用dict模式把 span 级的字号和 flags 抓出来过滤。import re import fitz SEC re.compile(r^第[一二三四五六七八九十百]节\s*\S) def find_headings(doc, skip_pages8, min_size12.0): hits [] for i in range(skip_pages, doc.page_count): # 跳过封面、目录本身 d doc[i].get_text(dict) for blk in d[blocks]: for line in blk.get(lines, []): for span in line[spans]: txt span[text].strip() if not txt or span[size] min_size: continue if SEC.match(txt): hits.append({ title: txt, page: i 1, size: round(span[size], 1), bold: bool(span[flags] 2 ** 4), # bit4 表示加粗 bbox: [round(v, 1) for v in span[bbox]], }) return hits doc fitz.open(天鹅股份_招股说明书.pdf) for h in find_headings(doc)[:15]: print(h)skip_pages8是为了跳过目录页——目录里每一行都长得像节标题不跳过会一次性匹配出几十个假锚点。min_size12.0需要按实际文档调先打印正文 span 的字号分布取正文众数字号加 1.5 到 2 点作为阈值。span[flags]是位掩码bit4 为 1 表示加粗字体招股说明书节标题一般同时满足大字号和加粗两个条件与一下能显著降噪。真锚点排序后按页码单调递增校验一遍出现倒退说明匹配到了表格里的大字单元格回退阈值重跑。3.3 章节树的存储结构与边界规则切分结果建议存成一张带父子关系的表后面无论是做检索过滤还是做章节级摘要都用得上。import pandas as pd def build_tree(toc_like, doc): rows, stack [], [] for idx, h in enumerate(toc_like): level h.get(level, 1) while stack and stack[-1][level] level: stack[-1][end_page] h[page] stack.pop() node { section_id: fS{idx:04d}, parent_id: stack[-1][section_id] if stack else None, level: level, title: h[title], start_page: h[page], end_page: doc.page_count, # 先给默认值出栈时回填 char_count: 0, } rows.append(node) stack.append(node) # 收尾栈里剩下的节点统一收到尾页 while stack: stack.pop()[end_page] doc.page_count return pd.DataFrame(rows) tree build_tree(toc, doc) # toc 来自 get_toc 或 find_headings print(tree[[section_id, level, title, start_page, end_page]].head(10)) print(总节点数:, len(tree), 顶级节点:, (tree.level 1).sum())核心是那个栈遇到同级或更高层级的标题说明前一个节点结束了回填end_page再弹栈。char_count在切正文时累加用来筛掉「释义」这类只有半页的短节点做检索时可以给短章节单独加权。三条硬规则必须补上跨页续写的段落属于当前章节边界按标题所在页的起始坐标切不是整页切目录页产生的重复锚点在skip_pages之外还要做一次标题去重页眉里重复出现的章节名有些排版会带要在取块阶段就按 y 坐标过滤掉否则每个 chunk 头上都会挂一句无关的标题。注意切分粒度不要到「节」就停。「财务会计信息」一节通常上百页内部还有「合并资产负债表」「应收账款账龄分析」等小标题至少做到二级检索命中率会明显不同。4. 招股说明书PDF表格抽取与财务数字的可校验落地4.1 合并单元格与跨页表的还原pdfplumber 遇到合并单元格会给None直接转 DataFrame 会出现大面积空洞。招股说明书里的「项目/本期/上期」这种表头经常横向合并需要先做一次表头补全再纵向填充。import pdfplumber import pandas as pd def table_to_df(raw, header_rows1): # 先去掉全空的行列 raw [r for r in raw if any(c and c.strip() for c in r)] header [ if c is None else c.replace(\n, ).strip() for c in raw[0]] # 表头横向合并补全空单元格继承左边非空值 filled, last [], for c in header: last c if c else last filled.append(last) body raw[header_rows:] width len(filled) body [(r [None] * width)[:width] for r in body] df pd.DataFrame(body, columnsfilled) # 纵向合并补全首列空值继承上一行常见于「其中」这类分组 df.iloc[:, 0] df.iloc[:, 0].ffill() return df with pdfplumber.open(天鹅股份_招股说明书.pdf) as pdf: tables pdf.pages[120].extract_tables({vertical_strategy: lines, horizontal_strategy: lines}) df table_to_df(tables[0]) print(df.head(8).to_string())横向补全解决的是表头合并纵向ffill解决的是分组行留空——招股说明书的费用明细里「其中」下的子项往往只有第一行写主科目名。跨页表要在extract_tables之外单独处理判断上一页最后一行是否是数字行、下一页第一行是否没有表头两个条件成立就拼接。拼接前记得剥掉重复表头否则会出现「项目」重复成数据行。4.2 数字归一化万元、括号负数与特殊符号财务表格的数字坑集中在三处单位在不同表之间切换、负数用括号表示、破折号表示零或缺失。抽出来直接float()必炸。import re NUM re.compile(r^-?[\d,](\.\d)?$) def norm_num(s, unit万元): if s is None: return None t str(s).strip().replace(,, ).replace( , ) t t.replace(\u2212, -).replace(, -) # 两种减号统一 if t in {, -, —, , 不适用, N/A}: return 0.0 if t in {-, —, } else None neg t.startswith(() and t.endswith()) if neg: t t[1:-1] if not NUM.match(t): return None v float(t) if neg: v -v if unit 万元: v v * 10000 # 统一落到「元」做校验 return v for s in [1,234.56, (2,300.00), —, 12,000]: print(s, -, norm_num(s))符号集里最容易漏的是 Unicode 减号\u2212和全角减号会计软件导出的 PDF 里两者都出现过不统一就会把负数识别成解析失败。括号负数在审计报告附注里极为常见必须先剥括号再转 float顺序反了NUM匹配直接失败。破折号在这类文档里通常表示零返回0.0「不适用」是语义缺失返回None两者在后续校验里的处理方式不同不要混。4.3 用会计恒等式反向校验抽取质量抽完不校验等于抽了个寂寞。资产负债表天然带恒等式是最省事的自检手段。def check_balance_sheet(assets, liabilities, equity, tol0.02): # 全部统一到「元」后比较tol 是为两位小数四舍五入留的余量 total liabilities equity diff abs(assets - total) return { assets: assets, liab_plus_equity: total, diff: diff, ok: diff max(tol, abs(assets) * 1e-6), } print(check_balance_sheet(assets1_234_567_890.12, liabilities456_789_012.34, equity777_778_877.78))tol给的是绝对误差下限abs(assets) * 1e-6是相对误差两者取大值因为大额数字的舍入误差会随量级放大。校验不通过时按顺序查三处表头单位是不是「元」而不是「万元」差一万倍「其中」子项是否被当成了合计行导致重复计入跨页拼接时是否漏了半页数据。这套校验也适用于利润表——营业收入减营业成本减税金及附加等不等于营业利润逻辑一样只是项数更多。4.4 落到一张能查的表抽完之后用一张宽表存起来字段设计要能支持「按报告期、按科目、按章节回溯到页码」这三种查询。CREATE TABLE financial_item ( id BIGSERIAL PRIMARY KEY, doc_id VARCHAR(64) NOT NULL, -- 文档标识 section_id VARCHAR(16) NOT NULL, -- 章节树节点来自第 3 章 statement VARCHAR(32) NOT NULL, -- 合并资产负债表 / 母公司利润表 period VARCHAR(16) NOT NULL, -- 2023-12-31 或 2023 年度 item_name VARCHAR(128) NOT NULL, -- 科目名称 item_value NUMERIC(20, 2), -- 统一到「元」 unit_raw VARCHAR(8), -- 原始单位便于排查 page_no INT NOT NULL, -- 页码用于回溯 bbox JSONB, -- 单元格坐标 UNIQUE (doc_id, statement, period, item_name) ); CREATE INDEX idx_fin_period ON financial_item (doc_id, period); CREATE INDEX idx_fin_item ON financial_item (doc_id, item_name);unit_raw一定要留出问题时能立刻区分是抽取错还是单位换算错。bbox存坐标是为了前端做原文高亮。(doc_id, statement, period, item_name)这个唯一键同时也是幂等写入的保证重跑抽取不会产生重复行。常见坑现象定位方法单位混用数值差一万倍对比unit_raw与表头文字括号负数值与方向相反检查norm_num前的原始字符串「其中」子项重复计入合计偏大按item_name前缀分组核对跨页拼接丢行恒等式差一个量级对比相邻两页末行与首行千分位残留float()抛异常统计None返回率5. 招股说明书PDF检索问答的引用回溯技巧5.1 分块跟着章节树走别按固定字数硬切固定 500 字切块在招股说明书上问题很大一个财务表格会被切成三段每段都缺表头单独看毫无意义。合理的做法是以第 3 章的章节树为骨架章内再按段落聚合到 600 到 1000 字遇到表格整表作为一个块并且在块头拼上「章节标题 表名 单位」这三段上下文。每个块带上section_id、page_no、bbox三个字段检索命中后直接跳原文位置。def make_chunk(section, texts, tables, max_chars900): chunks, buf [], for seg in texts: if len(buf) len(seg) max_chars and buf: chunks.append({text: buf, type: text}) buf buf seg if buf: chunks.append({text: buf, type: text}) for t in tables: # 表格块把表名和单位写进正文检索时才不会被切碎 head f{section[title]}{t[caption]}单位{t[unit]}\n chunks.append({text: head t[markdown], type: table}) for c in chunks: c.update({section_id: section[section_id], page_no: section[start_page]}) return chunksmax_chars900是中文语料的经验值太小会切断论证太大稀释检索相关性。表格块把caption和unit拼在前面是因为用户提问「报告期应收账款周转率是多少」向量检索靠的是这些文字纯 Markdown 表格本身语义很弱。5.2 混合检索加 RRF 融合表格类问题单独加权招股说明书的问题分两类概念类「什么是发行人的实际控制人」适合向量检索数字类「2023 年研发投入占营业收入比例」关键词匹配更准。用 BM25 和向量各召回 20 条再用 RRF 融合公式是score Σ 1 / (k rank)k取 60 是常用默认值。def rrf_fuse(bm25_ids, vec_ids, k60, w_table1.3): scores {} for rank, doc_id in enumerate(bm25_ids, 1): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank) for rank, doc_id in enumerate(vec_ids, 1): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank) # 表格块略微加权数字类问题的命中率会明显上来 for doc_id in scores: if doc_id.startswith(table::): scores[doc_id] * w_table return sorted(scores.items(), keylambda x: -x[1])[:8]k60让排名靠前的文档之间差距变缓避免单路召回霸榜。w_table1.3是表格块的加权系数调它的依据是线上 badcase如果用户问数字时经常返回一大堆正文段落就把这个值往上加 0.1 试。5.3 用块坐标做可回溯引用答案里必须带得出页码和原文片段否则业务方没法核对。块的page_no加上bbox就是完整的定位信息前端拿到后可以在 PDF.js 上画高亮框。def to_citation(chunk): return { section: chunk.get(title, ), page: chunk[page_no], quote: chunk[text][:120], rect: chunk.get(bbox), # [x0, y0, x1, y1]PDF 用户空间坐标 }注意 PDF.js 的坐标系原点在左上、y 轴向下而 PyMuPDF 的bbox是 PDF 原生坐标系的左上原点两者对得上但渲染窗口有缩放和旋转时要按viewport.convertToViewportRectangle()换算一次否则高亮框会偏。生成答案时提示模型逐条引用块编号输出后用块编号反查page_no任何没有原文支撑的句子直接丢弃——这一步比调分块参数更能提升可信度。本文还有配套的精品资源点击获取
返回列表