fix(server): 按 S01–S10 评测修复读标抽取(F1–F8、口径 Q1–Q4)
- 合并时待确认条目不再借正式条目进清单,没通过的类别记在 pending_categories - 结构识别:章(按章跳过不相干的部分)、表后「上述……否则无效」说明、★ 节标题 - INVALID 补写法;输出用到上限 92% 按截断处理,拆到底时采用已有输出 - 格式范本正文、整表概括句不收;项目信息多看评标方法所在分块 - WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(s3-v4) - 口径:★ 节继承实质性、★ 定义为计分项时降为重要、「必须/应」声明记 keyword 规则、 政府采购资格条件按 87 号令第六十三条带无效类别 - 提示词 substantive v5,qualification / schedule / tech_param v4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
TODO(S1):
|
||||
- 转换镜像要带按宋体行高调整过的字体包(BD Song / BD Hei)和 fontconfig 替换规则,现在用的是 Gotenberg 默认字体,
|
||||
分页偏差会偏大(S1:默认配置 +17%~+37%);
|
||||
- WPS 生成的旧版 DOC 要先转 DOCX、修正字体字符集再转 PDF,否则正文可能是空白(S1 §1.3)。现在直接转。
|
||||
- WPS 生成的旧版 DOC 的字体字符集已在 DOC 字体表里直接修正(fix_wps_doc_charset,评测 F7),不再需要先转 DOCX。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,11 +45,36 @@ def fix_wps_charset(docx: bytes) -> bytes:
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
# 旧版 DOC 字体表(SttbfFfn)里的一条字体记录(FFN):名字是 UTF-16 字符串,从记录开头第 40 字节起;
|
||||
# 第 0 字节是记录长度减 1,第 4 字节是字符集(chs)。名字后面以两个 0 字节结束
|
||||
FFN_NAME = re.compile(rb"(?:[\x00-\xff][\x4e-\x9f]){2,12}\x00\x00|(?:[A-Za-z ]\x00){4,20}\x00\x00")
|
||||
FFN_NAME_AT = 40
|
||||
FFN_CHS_AT = 4
|
||||
|
||||
|
||||
def fix_wps_doc_charset(doc: bytes) -> bytes:
|
||||
"""旧版 DOC 的同一个问题(S07:正文全变成「第第第第」):在字体表里找中文字体名,往前定位到记录开头,
|
||||
记录长度对得上、字符集是 SYMBOL(02) 的改成 GB2312(86)。直接改字节,不用先转 DOCX(Gotenberg 只能转出 PDF)。"""
|
||||
buf = bytearray(doc)
|
||||
for m in FFN_NAME.finditer(doc):
|
||||
name = doc[m.start() : m.end() - 2].decode("utf-16-le", "ignore")
|
||||
start = m.start() - FFN_NAME_AT
|
||||
if not (re.search(r"[一-鿿]", name) or name in CJK_LATIN) or start < 0 or buf[start + FFN_CHS_AT] != 0x02:
|
||||
continue
|
||||
# 名字后面可能还跟着备用名(也是 UTF-16),记录比名字长偶数个字节;长度对不上的是正文里碰巧像字体名的字节
|
||||
extra = buf[start] + 1 - (m.end() - start)
|
||||
if 0 <= extra <= 64 and extra % 2 == 0:
|
||||
buf[start + FFN_CHS_AT] = 0x86
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
async def to_pdf(data: bytes, fmt: str) -> bytes:
|
||||
if fmt == "pdf":
|
||||
return data
|
||||
if fmt == "docx":
|
||||
data = fix_wps_charset(data)
|
||||
if fmt == "doc":
|
||||
data = fix_wps_doc_charset(data)
|
||||
async with httpx.AsyncClient(base_url=get_settings().gotenberg_url, timeout=TIMEOUT) as c:
|
||||
r = await c.post("/forms/libreoffice/convert", files={"files": (f"input.{fmt}", data)})
|
||||
if r.status_code == 200:
|
||||
|
||||
@@ -28,7 +28,7 @@ from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.core.storage import storage
|
||||
from bid_doctor.document.models import DocumentPage, ParseJob, SourceDocument
|
||||
from bid_doctor.document.pipeline import convert, structure, text_layer
|
||||
from bid_doctor.document.pipeline import convert, text_layer
|
||||
from bid_doctor.document.service import doc_prefix
|
||||
from bid_doctor.notification.models import Notification
|
||||
from bid_doctor.project import extracted as project_extracted
|
||||
@@ -46,7 +46,7 @@ PROGRESS = {
|
||||
"merge": 95,
|
||||
}
|
||||
# text_layer v2:页眉页脚带单独成句,句子编号和 v1 不同(同一份规范化 PDF 在 v1、v2 下的编号不能混用)
|
||||
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v3", "sentence_split": "s3-v3"}
|
||||
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v4", "sentence_split": "s3-v4"}
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
@@ -168,14 +168,8 @@ async def _stages(ctx: TenantContext, job_id: UUID) -> dict[str, Any]:
|
||||
|
||||
await _stage(ctx, job_id, "structure")
|
||||
body = text_layer.body_sentences(pages)
|
||||
notes = extract.marking_notes(body)
|
||||
doc_info = extract.Doc(
|
||||
sentences=body,
|
||||
parents=structure.parents(body),
|
||||
continuations=text_layer.continuations(body),
|
||||
notes=notes,
|
||||
declared={m for n in notes for m in n.markers},
|
||||
)
|
||||
doc_info = extract.build_doc(body)
|
||||
notes = doc_info.notes
|
||||
|
||||
await _stage(ctx, job_id, "extract")
|
||||
async with tenant_tx(ctx) as s:
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
3. 中文编号的小节:「三、符合条件(必须提供,否则投标无效)」下面的 1. 2. 3.……(S02 第 20 页);
|
||||
4. 以冒号结尾的编号条目:「1.报价符合性审查:」下面不带编号的句子,到下一个编号为止。
|
||||
上级还可以有上级(第 4 种的上级常常是第 3 种),用 ancestors() 取整条链。
|
||||
TODO(结构识别):章、节层级,表格结构,分包识别。
|
||||
|
||||
另外三种结构(S01–S10 评测,docs/architecture/extract-eval.md §3):
|
||||
- chapters():每句属于哪一章(公告、须知、合同、采购需求、评审、格式……),抽取任务据此跳过不相干的章;
|
||||
- trailing_notes():表格、列表下方往回指的说明(「上述资格证明文件 1~6 项必须全部提供,否则投标无效」);
|
||||
- star_sections():★ 标在节标题上(「三、★商务要求」),整节都是实质性要求(口径 Q1)。
|
||||
TODO(结构识别):表格结构,分包识别。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,6 +28,7 @@ LIST_ITEM = re.compile(r"^((\d{1,2})|\(\d{1,2}\)|\d{1,2}[.、.](?!\d)|[①
|
||||
CHAPTER = re.compile(r"^第[一二三四五六七八九十百\d]+[章节]")
|
||||
CN_SECTION = re.compile(r"^(?:[一二三四五六七八九十]{1,3}、|([一二三四五六七八九十]{1,3}))")
|
||||
LEAD_MAX = 30 # 冒号引出的列表,最多往后认这么多句
|
||||
CN_NUM = "零一二三四五六七八九十"
|
||||
|
||||
|
||||
def _list_no(text: str) -> int | None:
|
||||
@@ -96,3 +102,165 @@ def ancestors(key: str, parents: dict[str, Sentence], depth: int = 3) -> list[Se
|
||||
out.append(p)
|
||||
key = p.key
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 章
|
||||
|
||||
|
||||
# 章标题:「第四章采购需求」,也可能前面粘着页眉(S03「常州市政府采购项目公开招标文件第三章资格审查」)
|
||||
CHAPTER_TITLE = re.compile(r"第([一二三四五六七八九十]{1,3})章\s*(\S{2,16})")
|
||||
# 章的类型按标题关键词判断,先判断的优先(「合同授予」不是合同,「主要条款及格式」算合同)
|
||||
CHAPTER_KINDS = (
|
||||
("award", ("授予",)),
|
||||
("complaint", ("质疑", "投诉")),
|
||||
("notice", ("公告", "邀请")),
|
||||
("instructions", ("须知", "资料表")),
|
||||
("qualification", ("资格审查",)),
|
||||
("evaluation", ("评标", "评审", "评分")),
|
||||
("contract", ("合同", "条款")),
|
||||
("requirements", ("需求", "技术规格", "技术要求")),
|
||||
("formats", ("格式", "组成")),
|
||||
)
|
||||
|
||||
|
||||
def cn_int(s: str) -> int:
|
||||
"""「三」→ 3,「十二」→ 12(只处理章节编号用到的范围)。"""
|
||||
if "十" not in s:
|
||||
return CN_NUM.index(s) if len(s) == 1 else 0
|
||||
head, _, tail = s.partition("十")
|
||||
return (CN_NUM.index(head) if head else 1) * 10 + (CN_NUM.index(tail) if tail else 0)
|
||||
|
||||
|
||||
def chapters(sentences: list[Sentence]) -> dict[str, str]:
|
||||
"""{句子编号: 章的类型}。认不出章的句子不在里面(调用方按「不跳过」处理)。
|
||||
|
||||
只认正文里的章标题:句子短(不超过 40 字)、不是目录(目录行带省略号,或者前后紧挨着另一个章标题,中间只隔页码)、
|
||||
不是引用(「见第一章《投标邀请》」「(8)第三章评标标准中……」)、章号比上一章大。
|
||||
认出来的章不可靠时(不到 3 章,或最后一章占了全文一半以上)返回空:按章跳过的任务就看全文,宁可多看不漏看。
|
||||
"""
|
||||
parents_of = parents(sentences)
|
||||
texts = [s.text.strip() for s in sentences]
|
||||
cands: list[tuple[int, int, str]] = [] # (句子位置, 章号, 类型)
|
||||
for i, s in enumerate(sentences):
|
||||
text = texts[i]
|
||||
m = CHAPTER_TITLE.search(text)
|
||||
if not m or len(text) > 40 or "…" in text or "...." in text or "见" in text[: m.start()] or "《" in text:
|
||||
continue
|
||||
if LIST_ITEM.match(text) or DOTTED.match(text) or s.key in parents_of or re.match(r"[第\d]", m.group(2)):
|
||||
continue # 编号条目里提到某一章、引用「第二章第26.1.2条」,不是章标题
|
||||
before = [t for t in texts[max(0, i - 3) : i] if not t.isdigit()][-1:]
|
||||
after = [t for t in texts[i + 1 : i + 4] if not t.isdigit()][:1]
|
||||
if any(CHAPTER_TITLE.match(t) and len(t) <= 40 for t in before + after):
|
||||
continue # 目录:几个章标题挨着(中间可能隔着页码)
|
||||
kind = next((k for k, words in CHAPTER_KINDS if any(w in m.group(2) for w in words)), "other")
|
||||
cands.append((i, cn_int(m.group(1)), kind))
|
||||
# 章号要严格递增:取最长的递增序列,正文里偶尔提到「第八章」的句子就被排除了(S04 p14)。
|
||||
# 一样长时取靠后的(目录在前、正文在后)
|
||||
best: list[list[tuple[int, int, str]]] = []
|
||||
for c in cands:
|
||||
prev = max((b for b in best if b[-1][1] < c[1]), key=lambda b: (len(b), b[0][0]), default=[])
|
||||
best.append([*prev, c])
|
||||
heads = [(i, kind) for i, _, kind in max(best, key=lambda b: (len(b), b[0][0]), default=[])]
|
||||
spans = list(zip(heads, [*heads[1:], (len(sentences), "")], strict=False))
|
||||
# 目录那一串胜出时,正文会全被算成最后一章:最后一章超过全文一半就不可靠(采购需求章本身可以很长,S06)
|
||||
if len(heads) < 3 or len(sentences) - heads[-1][0] > 0.5 * len(sentences):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for (start, kind), (end, _) in spans:
|
||||
for s in sentences[start:end]:
|
||||
out[s.key] = kind
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 表后往回指的说明
|
||||
|
||||
|
||||
TRAILING_NOTE = re.compile(
|
||||
r"^(?:(\d{1,2})|\(\d{1,2}\)|\d{1,2}[.、.]|总?备注[::]|注[::]|说明[::])?\s*(?:上述|以上)"
|
||||
)
|
||||
CONSEQUENCE = re.compile(r"无效|不实质性响应|未实质性响应|不合格|否决|不予受理|拒绝")
|
||||
TRAILING_MAX = 40 # 往回最多认这么多句
|
||||
CN_TOP = re.compile(r"^[一二三四五六七八九十]{1,3}、")
|
||||
TRAILING_ROW_MAX = 60
|
||||
# 说明的是标注方式,或者说的是「不导致无效」:不是往回指的无效说明(顾问审读第 5 条)
|
||||
NOT_CONSEQUENCE = re.compile(r"★|*|\*|斜体|下划线|非实质|不导致|不属于|不作为|不视为")
|
||||
|
||||
|
||||
def trailing_notes(sentences: list[Sentence]) -> dict[str, Sentence]:
|
||||
"""表格、列表下方往回指的说明:{被说明的句子编号: 说明那一句}。
|
||||
|
||||
「(1)上述资格证明文件 1~6 项必须全部提供……否则投标文件无效」写在资格证明表的下面(S05 p54),
|
||||
表里的每一行本身不写「无效」,上级条款也没有;只往上找上级的话认不出来(评测 §3.2,33 条漏抽)。
|
||||
范围:从说明往回,到章、「一、」这样的中文编号小节、十进制编号条款为止(「附件十一、……」这样的附件标题
|
||||
算在里面),最多 TRAILING_MAX 句、不超过前两页。
|
||||
"""
|
||||
out: dict[str, Sentence] = {}
|
||||
for i, s in enumerate(sentences):
|
||||
text = s.text.strip()
|
||||
if not (TRAILING_NOTE.match(text) and CONSEQUENCE.search(text)) or NOT_CONSEQUENCE.search(text):
|
||||
continue
|
||||
for x in reversed(sentences[max(0, i - TRAILING_MAX) : i]):
|
||||
xt = x.text.strip()
|
||||
if x.page_no < s.page_no - 2 or CHAPTER.match(xt) or CN_TOP.match(xt) or DOTTED.match(xt):
|
||||
break
|
||||
if TRAILING_NOTE.match(xt) and CONSEQUENCE.search(xt):
|
||||
continue # 同一组说明里的另一条
|
||||
if xt.endswith((":", ":")) and len(xt) <= 8:
|
||||
continue # 「说明:」这类引导句
|
||||
if not (LIST_ITEM.match(xt) or DOTTED.match(xt) or len(xt) <= TRAILING_ROW_MAX):
|
||||
continue # 只覆盖编号条目和表格行,不覆盖夹在中间的解释段落(S05 p54「专门面向中小企业,是指……」)
|
||||
out.setdefault(x.key, s)
|
||||
if xt.startswith("附件"):
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ★ 节
|
||||
|
||||
|
||||
# 编号 + ★ 的节标题:「三、★商务要求」「★(一)技术要求」「8★售后服务(质保期)」
|
||||
STAR_HEADING = re.compile(
|
||||
r"^(?P<s1>★)?\s*(?:(?P<cn>[一二三四五六七八九十]{1,3})、|((?P<paren>[一二三四五六七八九十]{1,3}))|(?P<num>\d{1,2})(?![\d..])[、]?)"
|
||||
r"\s*(?P<s2>★)?"
|
||||
)
|
||||
# 节标题去掉编号和 ★ 后的文字:短、不带标点和数字(「★5、采用2*15W功放芯片,声音清晰;」是参数行,不是节标题)
|
||||
STAR_TITLE = re.compile(r"^[^,,;;::。\d]{2,12}(?:([^)]{1,8}))?$")
|
||||
STAR_SCOPE_MAX = 800
|
||||
|
||||
|
||||
def _heading(text: str) -> tuple[str, int] | None:
|
||||
"""节编号的写法和序号:("cn", 3)("paren", 1)("num", 8)。"""
|
||||
m = STAR_HEADING.match(text)
|
||||
if not m or not any(m.group(k) for k in ("cn", "paren", "num")):
|
||||
return None
|
||||
for family in ("cn", "paren", "num"):
|
||||
if m.group(family):
|
||||
v = m.group(family)
|
||||
return family, int(v) if family == "num" else cn_int(v)
|
||||
return None
|
||||
|
||||
|
||||
def star_sections(sentences: list[Sentence]) -> dict[str, Sentence]:
|
||||
"""★ 标在节标题上时,节里的每一句:{句子编号: 节标题}(标题本身不在里面,它自己带 ★)。
|
||||
|
||||
节标题:带编号、带 ★、不超过 20 字、不以句号结尾。节到下一个同级编号(三 → 四、(一)→(二)、8 → 9)、
|
||||
更高一级的标题或下一章为止。
|
||||
"""
|
||||
out: dict[str, Sentence] = {}
|
||||
for i, s in enumerate(sentences):
|
||||
text = s.text.strip()
|
||||
h = _heading(text)
|
||||
m = STAR_HEADING.match(text)
|
||||
if h is None or m is None or not (m.group("s1") or m.group("s2")) or not STAR_TITLE.match(text[m.end() :]):
|
||||
continue
|
||||
family, no = h
|
||||
higher = {"cn": (), "paren": ("cn",), "num": ("cn", "paren")}[family]
|
||||
for x in sentences[i + 1 : i + 1 + STAR_SCOPE_MAX]:
|
||||
xt = x.text.strip()
|
||||
if CHAPTER.match(xt) or (CHAPTER_TITLE.search(xt[:40]) and len(xt) <= 40):
|
||||
break
|
||||
xh = _heading(xt)
|
||||
if xh and (xh[0] in higher or (xh[0] == family and xh[1] == no + 1)):
|
||||
break
|
||||
out.setdefault(x.key, s)
|
||||
return out
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""文字层:从规范化 PDF 取逐字坐标和格式 → 切句 → 每句生成 quads 和稳定编号(架构 §3.5,技术验证 S2、S3)。
|
||||
|
||||
切句规则:
|
||||
1. 同一行里字间空隙大于 1.5 个字号时断开(表格单元格、制表位),得到「行片段」;
|
||||
2. 句子在 。!?; 处结束;行片段没写到右边距附近(段落末行)时也结束;
|
||||
3. 句子不跨页。编号 <文档>:p<页>:s<页内序号>;规范化 PDF 不再重新生成,所以编号稳定。
|
||||
1. 有框线的表格按单元格取字:一格读完再读下一格(同一行从左到右),每格的结束就是句子的结束。
|
||||
以前按整页横向一行取字,一行里几格各自换行时,各格的第 1 行拼在一起、第 2 行又拼在一起,
|
||||
竖排窄列成了一字一句(S02 评分表「项/目/实/施」,评测 F8);
|
||||
2. 表格以外,同一行里字间空隙大于 1.5 个字号时断开(制表位),得到「行片段」;
|
||||
3. 句子在 。!?; 处结束;行片段没写到右边距附近(段落末行)时也结束,单元格里按这一格的右边界算;
|
||||
4. 句子不跨页。编号 <文档>:p<页>:s<页内序号>;规范化 PDF 不再重新生成,所以编号稳定。
|
||||
格式:斜体看字体名或字形倾斜;下划线看字下方的横线;加粗看字体名(S2:斜体且下划线按字符 100% 一致)。
|
||||
TODO(S3 改进):表格按单元格切句;页眉页脚带单独识别(现在都算正文)。
|
||||
页眉页脚带里的页码和重复行单独成句(location = header_footer)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -140,7 +143,45 @@ def _flags(c: dict[str, Any], hlines: list[tuple[float, float, float]]) -> str:
|
||||
return ("b" if "Bold" in name else "") + ("i" if italic else "") + ("u" if under else "")
|
||||
|
||||
|
||||
def _fragments(chars: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
||||
Cell = tuple[float, float, float, float] # 单元格 (x0, top, x1, bottom)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FragInfo:
|
||||
right: float | None = None # 单元格的右边界:判断段落末行用它,不用页面右边距
|
||||
cell_end: bool = False # 单元格的最后一个片段:句子在这里结束
|
||||
|
||||
|
||||
def _cell_of(c: dict[str, Any], cells: list[Cell]) -> int | None:
|
||||
x, y = (c["x0"] + c["x1"]) / 2, (c["top"] + c["bottom"]) / 2
|
||||
return next((i for i, (x0, top, x1, bottom) in enumerate(cells) if x0 <= x <= x1 and top <= y <= bottom), None)
|
||||
|
||||
|
||||
def _fragments(
|
||||
chars: list[dict[str, Any]], cells: list[Cell] | None = None
|
||||
) -> tuple[list[list[dict[str, Any]]], list[FragInfo]]:
|
||||
"""行片段,按阅读顺序:表格外的文字一行一行,表格里一格一格(格子按上边、左边排序)。"""
|
||||
cells = cells or []
|
||||
by_cell: dict[int | None, list[dict[str, Any]]] = {}
|
||||
for c in chars:
|
||||
by_cell.setdefault(_cell_of(c, cells), []).append(c)
|
||||
blocks: list[tuple[tuple[float, float], list[list[dict[str, Any]]], FragInfo | None]] = []
|
||||
for frag in _line_fragments(by_cell.pop(None, [])):
|
||||
blocks.append(((min(c["top"] for c in frag), frag[0]["x0"]), [frag], None))
|
||||
for i, cell_chars in by_cell.items():
|
||||
assert i is not None
|
||||
x0, top, x1, _ = cells[i]
|
||||
blocks.append(((top, x0), _line_fragments(cell_chars), FragInfo(right=x1)))
|
||||
frags: list[list[dict[str, Any]]] = []
|
||||
infos: list[FragInfo] = []
|
||||
for _, fs, info in sorted(blocks, key=lambda b: b[0]):
|
||||
for j, f in enumerate(fs):
|
||||
frags.append(f)
|
||||
infos.append(FragInfo(right=info.right, cell_end=j == len(fs) - 1) if info else FragInfo())
|
||||
return frags, infos
|
||||
|
||||
|
||||
def _line_fragments(chars: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
||||
chars = sorted(chars, key=lambda c: (round(c["bottom"]), c["x0"]))
|
||||
lines: list[list[dict[str, Any]]] = []
|
||||
cur: list[dict[str, Any]] = []
|
||||
@@ -206,7 +247,7 @@ def _underlines(page: Any) -> list[tuple[float, float, float]]:
|
||||
return [(x0, x1, y) for x0, x1, y in hl if not (joined(x0, y) and joined(x1, y))]
|
||||
|
||||
|
||||
def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: set[str]) -> Page:
|
||||
def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], infos: list[FragInfo], repeats: set[str]) -> Page:
|
||||
"""页眉页脚带里的页码和重复行单独成句(location = header_footer),不并进正文句子(S3 的改进点 2)。"""
|
||||
hlines = _underlines(page)
|
||||
chars = [c for c in page.chars if str(c["text"]).strip()]
|
||||
@@ -237,9 +278,10 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: se
|
||||
cur.append((fi, c))
|
||||
if c["text"] in END:
|
||||
ended = True
|
||||
if cur and not ended and frag[-1]["x1"] < right - 2 * frag[-1]["size"]:
|
||||
limit = infos[fi].right or right
|
||||
if cur and (infos[fi].cell_end or (not ended and frag[-1]["x1"] < limit - 2 * frag[-1]["size"])):
|
||||
groups.append(("body", cur))
|
||||
cur = []
|
||||
cur, ended = [], False
|
||||
if cur:
|
||||
groups.append(("body", cur))
|
||||
open_tail = not ended # 页面写完了句子还没结束
|
||||
@@ -282,9 +324,14 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: se
|
||||
|
||||
def extract(doc_id: str, pdf: bytes) -> list[Page]:
|
||||
with pdfplumber.open(io.BytesIO(pdf)) as doc:
|
||||
pages = [(p, _fragments([c for c in p.chars if str(c["text"]).strip()])) for p in doc.pages]
|
||||
repeats = _band_repeats(pages)
|
||||
return [_page(doc_id, p, frags, repeats) for p, frags in pages]
|
||||
pages = [(p, *_fragments([c for c in p.chars if str(c["text"]).strip()], _cells(p))) for p in doc.pages]
|
||||
repeats = _band_repeats([(p, frags) for p, frags, _ in pages])
|
||||
return [_page(doc_id, p, frags, infos, repeats) for p, frags, infos in pages]
|
||||
|
||||
|
||||
def _cells(page: Any) -> list[Cell]:
|
||||
"""有框线的表格的单元格(pdfplumber 按横竖线找表格)。"""
|
||||
return [(c[0], c[1], c[2], c[3]) for t in page.find_tables() for c in t.cells if c]
|
||||
|
||||
|
||||
CLOSERS = set("”’))》」』】")
|
||||
|
||||
Reference in New Issue
Block a user