|
|
@@ -29,7 +29,7 @@ import org.springframework.stereotype.Service;
|
|
|
* (Python fitz/PyMuPDF → PDFBox,python-docx → POI)。
|
|
|
*
|
|
|
* <p>阶段10同步:① PDF正文按"版面块"识别段落(块边界即段落边界,块内合并
|
|
|
- * 段内软换行,相邻行主字号突变亦视为段落边界);② 整页为逐行等距同字号的
|
|
|
+ * 段内软换行,相邻行主字号或主字体签名突变亦视为段落边界);② 整页为逐行等距同字号的
|
|
|
* 退化网格(扫描件文本层典型特征)时回退纯文本启发式合并;③ 提取结果携带
|
|
|
* 脱敏失败原因(content_extract_error),供管理员"编辑"界面排障与重新解析。</p>
|
|
|
*/
|
|
|
@@ -138,12 +138,48 @@ public class DocumentContentService {
|
|
|
// ------------------------------------------------------------ PDF 提取(版面块段落化)
|
|
|
|
|
|
/**
|
|
|
- * 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)。
|
|
|
+ * 块内相邻行主字号相对差达到该比例、或主字体签名不同时视为段落边界
|
|
|
+ * (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文同字号,
|
|
|
+ * 仅字体家族或加粗不同,字号差信号失灵,需字体信号互补)。
|
|
|
* 注:PDFBox 的字形高度(getHeightDir)较 fitz 的字号度量压缩了相对差异
|
|
|
* (实测16pt/11pt标题正文 ≈ 0.195),故取 0.15 以对齐 Python 版 0.2 的效果。
|
|
|
*/
|
|
|
private static final double FONT_SIZE_SPLIT_RATIO = 0.15;
|
|
|
|
|
|
+ /** 字体名中的风格 token:剥离后不参与家族比较(加粗位单独保留在签名中)。 */
|
|
|
+ private static final List<String> FONT_STYLE_TOKENS = List.of("bold", "italic", "oblique");
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对齐 _font_signature:剥离子集前缀与风格 token 的家族名(小写)+ 加粗位,
|
|
|
+ * 如 "ABCDEE+Calibri-Bold" → "calibri|b"。返回空串表示无字体信息。
|
|
|
+ * 加粗位仅取字体名 token(PDFBox 无 fitz span flags 的加粗位)。
|
|
|
+ */
|
|
|
+ static String fontSignature(String raw) {
|
|
|
+ if (raw == null || raw.isEmpty()) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String cleaned = raw.replaceFirst("^[A-Za-z]{6}\\+", "");
|
|
|
+ String[] parts = cleaned.split("[-,]");
|
|
|
+ StringBuilder family = new StringBuilder();
|
|
|
+ boolean bold = false;
|
|
|
+ for (String part : parts) {
|
|
|
+ if (part.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String token = part.toLowerCase(Locale.ROOT);
|
|
|
+ if (token.equals("bold")) {
|
|
|
+ bold = true;
|
|
|
+ }
|
|
|
+ if (!FONT_STYLE_TOKENS.contains(token)) {
|
|
|
+ family.append(token);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (family.isEmpty()) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return bold ? family + "|b" : family.toString();
|
|
|
+ }
|
|
|
+
|
|
|
/** 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)。 */
|
|
|
private static final double DEGENERATE_GAP_TOLERANCE = 2.0;
|
|
|
private static final double DEGENERATE_SIZE_TOLERANCE = 0.5;
|
|
|
@@ -161,15 +197,18 @@ public class DocumentContentService {
|
|
|
private static final Pattern SENTENCE_END = Pattern.compile(
|
|
|
"[。!?!?;;::…..\u201d\u2019」』))\\]}>》]+$");
|
|
|
|
|
|
- /** 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章")。 */
|
|
|
+ /**
|
|
|
+ * 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")。
|
|
|
+ * 数字含全角0-9,分隔符含全角句点"."(公文"1."式三级标题)。
|
|
|
+ */
|
|
|
private static final Pattern LIST_START = Pattern.compile(
|
|
|
- "^(?:\\d{1,3}[.、))]"
|
|
|
- + "|[((][一二三四五六七八九十百\\d]{1,4}[))]"
|
|
|
+ "^(?:[0-90-9]{1,3}[.、)).]"
|
|
|
+ + "|[((][一二三四五六七八九十百0-90-9]{1,4}[))]"
|
|
|
+ "|[一二三四五六七八九十]{1,3}、"
|
|
|
- + "|第[一二三四五六七八九十百千\\d]{1,5}[章节条部分篇])");
|
|
|
+ + "|第[一二三四五六七八九十百千0-90-9]{1,5}[章节条部分篇])");
|
|
|
|
|
|
- /** 版面行:(文本, 主字号, bbox顶, bbox底)。 */
|
|
|
- private record LineItem(String text, double mainSize, double top, double bottom) {
|
|
|
+ /** 版面行:(文本, 主字号, 主字体签名, bbox顶, bbox底)。 */
|
|
|
+ private record LineItem(String text, double mainSize, String font, double top, double bottom) {
|
|
|
}
|
|
|
|
|
|
/** 版面块:(行列表, bbox顶, bbox底)。 */
|
|
|
@@ -179,13 +218,21 @@ public class DocumentContentService {
|
|
|
/**
|
|
|
* 逐字符收集版面行(对齐 fitz page.get_text("dict") 的 lines 结构):
|
|
|
* PDFTextStripper 按 sortByPosition 顺序回调 writeString,
|
|
|
- * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度。
|
|
|
+ * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度,
|
|
|
+ * 行主字体签名按非空白字符数取众数(对齐 _line_style 的加权口径,
|
|
|
+ * 避免行首少量西文数字导致正文的行主字体来回漂移)。
|
|
|
*/
|
|
|
private static final class LineCollector extends PDFTextStripper {
|
|
|
|
|
|
private final List<LineItem> lines = new ArrayList<>();
|
|
|
private final StringBuilder current = new StringBuilder();
|
|
|
private final List<Double> sizes = new ArrayList<>();
|
|
|
+ /** 签名权重:保持插入顺序以对齐 Python max(首次插入者优先)。 */
|
|
|
+ private final java.util.LinkedHashMap<String, Integer> fontWeights =
|
|
|
+ new java.util.LinkedHashMap<>();
|
|
|
+ /** 原始字体名 → 签名缓存:每页字体种类有限,避免逐字形重算。 */
|
|
|
+ private final java.util.HashMap<String, String> signatureCache =
|
|
|
+ new java.util.HashMap<>();
|
|
|
private double top = Double.MAX_VALUE;
|
|
|
private double bottom = -Double.MAX_VALUE;
|
|
|
|
|
|
@@ -203,6 +250,10 @@ public class DocumentContentService {
|
|
|
current.append(unicode);
|
|
|
if (!unicode.isBlank()) {
|
|
|
sizes.add((double) position.getHeightDir());
|
|
|
+ String rawFont = position.getFont().getName();
|
|
|
+ String signature = signatureCache.computeIfAbsent(
|
|
|
+ rawFont == null ? "" : rawFont, DocumentContentService::fontSignature);
|
|
|
+ fontWeights.merge(signature, 1, Integer::sum);
|
|
|
}
|
|
|
double y = position.getYDirAdj();
|
|
|
double height = position.getHeightDir();
|
|
|
@@ -230,10 +281,19 @@ public class DocumentContentService {
|
|
|
for (double size : sizes) {
|
|
|
mainSize = Math.max(mainSize, size);
|
|
|
}
|
|
|
- lines.add(new LineItem(text, mainSize, top, bottom));
|
|
|
+ String dominantFont = "";
|
|
|
+ int dominantWeight = 0;
|
|
|
+ for (var entry : fontWeights.entrySet()) {
|
|
|
+ if (entry.getValue() > dominantWeight) {
|
|
|
+ dominantFont = entry.getKey();
|
|
|
+ dominantWeight = entry.getValue();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ lines.add(new LineItem(text, mainSize, dominantFont, top, bottom));
|
|
|
}
|
|
|
current.setLength(0);
|
|
|
sizes.clear();
|
|
|
+ fontWeights.clear();
|
|
|
top = Double.MAX_VALUE;
|
|
|
bottom = -Double.MAX_VALUE;
|
|
|
}
|
|
|
@@ -351,20 +411,30 @@ public class DocumentContentService {
|
|
|
return maxSize - minSize <= DEGENERATE_SIZE_TOLERANCE;
|
|
|
}
|
|
|
|
|
|
- /** 对齐 _merge_line_items:条目编号/字号突变/句末标点切分,其余按软换行合并。 */
|
|
|
+ /**
|
|
|
+ * 对齐 _merge_line_items:条目编号/字号或主字体签名突变/句末标点切分,其余按软换行合并。
|
|
|
+ * 字号/字体为空表示无版面信息,不参与比较;字体切分要求前一行至少 2 个字符——
|
|
|
+ * 单字符行如项目符号"•"不可能是标题,避免符号与条目文本被字体差异拆开。
|
|
|
+ */
|
|
|
private static List<String> mergeLineItems(List<LineItem> items) {
|
|
|
List<String> paragraphs = new ArrayList<>();
|
|
|
StringBuilder buffer = new StringBuilder();
|
|
|
double previousSize = 0.0;
|
|
|
+ String previousFont = "";
|
|
|
+ String previousText = "";
|
|
|
for (LineItem item : items) {
|
|
|
String text = item.text();
|
|
|
double size = item.mainSize();
|
|
|
+ String font = item.font();
|
|
|
boolean split = false;
|
|
|
if (!buffer.isEmpty() && LIST_START.matcher(text).find()) {
|
|
|
split = true;
|
|
|
- } else if (!buffer.isEmpty() && previousSize > 0 && size > 0
|
|
|
- && Math.abs(size - previousSize) / Math.max(size, previousSize)
|
|
|
- >= FONT_SIZE_SPLIT_RATIO) {
|
|
|
+ } else if (!buffer.isEmpty()
|
|
|
+ && (previousSize > 0 && size > 0
|
|
|
+ && Math.abs(size - previousSize) / Math.max(size, previousSize)
|
|
|
+ >= FONT_SIZE_SPLIT_RATIO
|
|
|
+ || !previousFont.isEmpty() && !font.isEmpty() && !font.equals(previousFont)
|
|
|
+ && previousText.length() >= 2)) {
|
|
|
split = true;
|
|
|
}
|
|
|
if (buffer.isEmpty()) {
|
|
|
@@ -377,6 +447,8 @@ public class DocumentContentService {
|
|
|
joinLines(buffer, text);
|
|
|
}
|
|
|
previousSize = size;
|
|
|
+ previousFont = font;
|
|
|
+ previousText = text;
|
|
|
Matcher endMatcher = SENTENCE_END.matcher(buffer);
|
|
|
if (!buffer.isEmpty() && endMatcher.find()) {
|
|
|
paragraphs.add(buffer.toString());
|
|
|
@@ -415,7 +487,7 @@ public class DocumentContentService {
|
|
|
paragraphs.addAll(mergeLineItems(group));
|
|
|
group = new ArrayList<>();
|
|
|
} else {
|
|
|
- group.add(new LineItem(line, 0.0, 0.0, 0.0));
|
|
|
+ group.add(new LineItem(line, 0.0, "", 0.0, 0.0));
|
|
|
}
|
|
|
}
|
|
|
paragraphs.addAll(mergeLineItems(group));
|