using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; namespace Rmsys.Infrastructure; /// /// 任免文件 ↔ 上会干部 的姓名匹配。 /// /// 做法:不从文件里"猜"姓名(中文姓名边界不可靠,会出现"去刘志强""于陈晓东"这类误抽), /// 而是拿库里已知的姓名去文件中查找——姓名是确定的,命中即关联,不存在边界歧义。 /// /// 支持 .docx(直接读 word/document.xml,不依赖任何外部组件); /// .pdf / .doc 需服务器有 pdftotext / antiword,没有时返回 NotSupported,由界面提示改为手动设置人员。 /// public static class DocNameMatcher { public class Result { /// 文件中出现、且能在库里唯一确定的干部(按在文件中出现的位置排序) public List<(int CadreId, string Name, int Pos)> InFile { get; set; } = new(); /// 文件里出现但库内有多个同名人 → 需要人工确认 public List<(string Name, List CadreIds)> Ambiguous { get; set; } = new(); /// 文件里出现、但本场会议上会名单里没有的姓名(疑似写错或未纳入名单) public List UnknownNames { get; set; } = new(); /// 能否抽取文本 public bool TextOk { get; set; } /// 抽取失败原因 public string? Error { get; set; } public int TextLength { get; set; } } public static Result Match(string fileFullPath, IEnumerable<(int Id, string Name)> cadres) { var r = new Result(); var text = ExtractText(fileFullPath, out var err); if (text == null) { r.TextOk = false; r.Error = err; return r; } r.TextOk = true; r.TextLength = text.Length; var list = cadres.Where(x => !string.IsNullOrWhiteSpace(x.Name)).ToList(); // 1) 先按姓名长度倒序找:避免"王明"抢走"王小明"的位置 foreach (var g in list.GroupBy(x => x.Name).OrderByDescending(g => g.Key.Length)) { var name = g.Key; var poses = new List(); var idx = text.IndexOf(name, StringComparison.Ordinal); while (idx >= 0 && poses.Count < 20) { poses.Add(idx); idx = text.IndexOf(name, idx + name.Length, StringComparison.Ordinal); } if (poses.Count == 0) continue; if (g.Count() > 1) { r.Ambiguous.Add((name, g.Select(x => x.Id).ToList())); // 同名时不做自动关联,交给人工确认,避免把两个人的材料挂错 continue; } r.InFile.Add((g.First().Id, name, poses[0])); } r.InFile = r.InFile.OrderBy(x => x.Pos).ToList(); // 2) 找"库外姓名":把已匹配姓名抹掉后,剩余的「XX同志」才算可疑 var masked = text; foreach (var g in list.GroupBy(x => x.Name).OrderByDescending(g => g.Key.Length)) masked = masked.Replace(g.Key, new string('○', g.Key.Length)); var known = new HashSet(list.Select(x => x.Name)); foreach (Match m in Regex.Matches(masked, @"([\u4e00-\u9fa5]{2,4})(?=同志)")) { var cand = TrimLeading(m.Groups[1].Value); if (cand.Length < 2) continue; if (known.Contains(cand)) continue; if (IsStopWord(cand)) continue; if (!r.UnknownNames.Contains(cand)) r.UnknownNames.Add(cand); } return r; } /// /// 从正文里提取公文标题("关于……的通知/决定/批复")。 /// 用途:后台列表显示文件自己的标题,而不是系统拼的"关于XX等N名同志任免职的通知"。 /// public static string? ExtractTitle(string text) { foreach (var raw in text.Split('\n')) { var line = raw.Trim(); if (line.Length < 6 || line.Length > 60) continue; if (line.Contains("号") && line.Contains("〔")) continue; // 文号行 var m = Regex.Match(line, @"关于.{2,40}?(通知|决定|批复|意见|任免)"); if (m.Success) return m.Value; } return null; } /// 剥掉动词/介词前缀("免去刘志强"→"刘志强" 这类没有边界的残留) private static string TrimLeading(string s) { string[] junk = { "免去", "任命", "同意", "决定", "关于", "提名", "拟任", "拟免", "提请", "同志", "其中", "以及", "和", "及", "与", "为", "任", "免", "去", "于" }; foreach (var j in junk) { if (s.Length - j.Length >= 2 && s.StartsWith(j, StringComparison.Ordinal)) return s[j.Length..]; } return s; } private static bool IsStopWord(string s) { string[] stop = { "全体", "各位", "有关", "上述", "以上", "各位同志", "领导班子", "干部群众", "会议", "研究", "决定", "通知", "文件" }; return stop.Contains(s); } /// 抽取纯文本;失败返回 null 并给出原因 public static string? ExtractText(string fullPath, out string? error) { error = null; var ext = Path.GetExtension(fullPath).ToLowerInvariant(); try { if (ext == ".docx") return FromDocx(fullPath); if (ext == ".txt") return File.ReadAllText(fullPath, Encoding.UTF8); if (ext == ".pdf") { var txt = RunTool("pdftotext", $"-enc UTF-8 -q \"{fullPath}\" -"); if (txt != null) return txt; error = "该文件是 PDF,服务器缺少 PDF 文本抽取组件,无法自动匹配姓名;可改用 Word 上传,或在「人员」里手动勾选。"; return null; } if (ext == ".doc") { // 1) 内置纯托管解析:不依赖任何外部程序,Windows 客户机上同样可用 var txt = WordDocText.Extract(fullPath, out var docErr); if (!string.IsNullOrWhiteSpace(txt)) return txt; // 2) 兜底:服务器装了 LibreOffice 就转成文本再读(标准包自带) txt = ViaSoffice(fullPath); if (!string.IsNullOrWhiteSpace(txt)) return txt; // 3) 兜底:Linux 上的 antiword txt = RunTool("antiword", $"\"{fullPath}\""); if (!string.IsNullOrWhiteSpace(txt)) return txt; error = "该文件是老版 .doc,未能提取到文字" + (string.IsNullOrWhiteSpace(docErr) ? "" : $"({docErr})") + ";请另存为 .docx 后重新上传,或在「人员」里手动勾选。"; return null; } error = $"暂不支持从 {ext} 文件自动识别姓名,请在「人员」里手动勾选。"; return null; } catch (Exception ex) { error = "读取文件内容失败:" + ex.Message; return null; } } /// .docx = zip,正文在 word/document.xml;段落转成换行,去掉所有标签 private static string? FromDocx(string path) { using var zip = ZipFile.OpenRead(path); var entry = zip.GetEntry("word/document.xml"); if (entry == null) return null; using var sr = new StreamReader(entry.Open(), Encoding.UTF8); var xml = sr.ReadToEnd(); var sb = new StringBuilder(xml.Length / 4); // 段落、换行、制表 → 分隔符,避免相邻段落文字粘连 xml = Regex.Replace(xml, @"", "\n"); xml = Regex.Replace(xml, @"]*/>", "\n"); xml = Regex.Replace(xml, @"]*/>", " "); // 去掉所有标签 var plain = Regex.Replace(xml, @"<[^>]+>", ""); sb.Append(System.Net.WebUtility.HtmlDecode(plain)); return sb.ToString(); } /// 用 LibreOffice 把 .doc 转成 UTF-8 纯文本(服务器装了 LO 时作为兜底) private static string? ViaSoffice(string fullPath) { var exe = PdfConverter.ExePath; if (string.IsNullOrWhiteSpace(exe)) return null; var tmp = Path.Combine(Path.GetTempPath(), "rmsys_doc2txt_" + Guid.NewGuid().ToString("N")); try { Directory.CreateDirectory(tmp); RunTool(exe!, $"--headless --norestore --convert-to \"txt:Text (encoded):UTF8\" --outdir \"{tmp}\" \"{fullPath}\""); foreach (var f in Directory.GetFiles(tmp, "*.txt")) { var t = File.ReadAllText(f, Encoding.UTF8); if (!string.IsNullOrWhiteSpace(t)) return t; } return null; } catch { return null; } finally { try { Directory.Delete(tmp, true); } catch { /* 临时目录清理失败不影响结果 */ } } } private static string? RunTool(string exe, string args) { try { var psi = new System.Diagnostics.ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = System.Diagnostics.Process.Start(psi); if (p == null) return null; var outp = p.StandardOutput.ReadToEnd(); p.WaitForExit(20000); return p.ExitCode == 0 && outp.Length > 0 ? outp : null; } catch { return null; } } }