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; }
}
}