diff --git a/.gitignore b/.gitignore index a24e7ed..7977c52 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,13 @@ $RECYCLE.BIN/ # VisualStudioCode .vs -Test \ No newline at end of file +Test + +# Local publish artifacts +dist/ + +# Local standalone schedule generator +/schedule_generator/ + +# Keep the reproducible standalone Windows publish profile in source control. +!RapidOCRWinform/Properties/PublishProfiles/StandaloneWinX64.pubxml diff --git a/Assets/mobao-avatar.png b/Assets/mobao-avatar.png new file mode 100644 index 0000000..a89e5d0 Binary files /dev/null and b/Assets/mobao-avatar.png differ diff --git a/Assets/mobao.ico b/Assets/mobao.ico new file mode 100644 index 0000000..051f76e Binary files /dev/null and b/Assets/mobao.ico differ diff --git a/README.md b/README.md index 4db62a1..13e5625 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,19 @@ If you find this project helpful, please consider giving us a ⭐ Star. Your sup --- +## Local schedule-image organizer + +`RapidOCRWinform` now includes a complete four-image schedule workflow. Select exactly four structurally identical schedule images, choose the target year/month, run OCR, review the merged table, edit any date or name, and export the six required columns to `.xlsx`. Saturdays and Sundays are ignored by default; the checkbox can retain them, and that preference persists in `config/settings.json` beside the application. + +Records are merged by calendar date rather than upload order. Missing workdays, duplicate dates, invalid dates/weekdays, weekday mismatches, empty positions, multiple months, ambiguous columns, and low-confidence OCR are shown explicitly for manual review. The default layout uses columns 1–4 plus the final two columns; the two right-side column indices can be changed without rerunning OCR. + +```powershell +dotnet run --project .\RapidOCRWinform\RapidOCRWinform.csproj +dotnet test .\RapidOCRSchedule.Tests\RapidOCRSchedule.Tests.csproj +``` + +--- + ## 👉 Quick Start The following example demonstrates how to get started using a **.NET Console Application**. @@ -130,7 +143,61 @@ Console.ReadKey(); | ------------ | --------------- | ------------------------------------------------------------- | | Core Library | RapidOCRLib | Core C# OCR wrapper library with sync/async APIs | | Console App | RapidOCRConsole | .NET console demo with built-in PP-OCRv5 model | -| WinForms App | RapidOCRWinform | WinForms demo (model not included; copy from console project) | +| WinForms App | RapidOCRWinform | Schedule-image OCR desktop app with embedded PP-OCRv5 models | + +## 📦 Offline Windows x64 Packaging + +`RapidOCRWinform` can be published as either a framework-dependent or self-contained application: + +- **Framework-dependent (smaller):** the target computer must have the .NET 10 Desktop Runtime installed. +- **Self-contained (no .NET installation):** the package includes the .NET runtime and is larger, but can run offline immediately after extraction. + +Framework-dependent publish command: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -c Release ` + -r win-x64 ` + --self-contained false ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true +``` + +Fully self-contained publish command: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true +``` + +The repository also includes a reusable publish profile: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -p:PublishProfile=StandaloneWinX64 ` + -o .\dist\RapidOCRSchedule-standalone-win-x64 +``` + +The publish directory only needs one distributable file: + +```text +RapidOCRSchedule.exe +``` + +The executable contains the .NET Desktop runtime, native OCR libraries, all three ONNX models, and the dictionary. It does not require Python, Anaconda, CUDA, Office, an installed .NET runtime, or an online service, but it is specific to Windows x64. On first launch it verifies and materializes the embedded models under `%LOCALAPPDATA%\RapidOCRSchedule\model-cache`; the .NET single-file host also extracts native libraries to the user's temporary directory. These are automatically managed runtime caches, not deployment prerequisites. + +Run the headless end-to-end check to verify model extraction, ONNX Runtime, OpenCV, and OCR inference: + +```powershell +$test = Start-Process .\RapidOCRSchedule.exe -ArgumentList --self-test -Wait -PassThru +if ($test.ExitCode -ne 0) { throw "Standalone package self-test failed" } +``` --- diff --git a/README_zh.md b/README_zh.md index 7472898..9dbcbba 100644 --- a/README_zh.md +++ b/README_zh.md @@ -24,6 +24,29 @@ RapidOCR 是一款完全开源免费、支持离线快速部署的多平台多 .Neter们,如果您觉得本项目对您的工作或学习有所帮助,恳请您不吝赐予一颗 ⭐ Star,给予我们宝贵的支持与鼓励! +## 本地排班图片整理 + +`RapidOCRWinform` 已提供面向排班表的完整桌面流程: + +1. 一次选择正好 4 张结构相同的排班图片(上传顺序不影响结果)。 +2. 选择目标年月;程序从每张图片提取日期、星期、前两个岗位以及右侧两个指定岗位。 +3. 4 张图片的记录按实际日期合并排序;默认忽略周六和周日,也可在“整理设置”中取消勾选以保留周末。该选择保存在程序目录的 `config/settings.json`,再次启动仍会沿用。未忽略日期中的缺失记录会自动生成待填写行。 +4. 在结果表格中检查并修改日期、星期和姓名,然后导出为 `.xlsx`。 + +程序以界面中选择的目标年月为权威日历。OCR 完成后,会根据四张图片各自从上到下每隔 4 天的排布规律,以及可识别的日期/星期锚点确定四组顺序,再统一用目标年月计算并回填最终日期和星期;OCR 的日期和星期只用于定位、核对,姓名仍取自 OCR。像 `817`、缺少斜杠或星期只识别成“星期”等情况都会被日历结果纠正;如果四组对应关系存在歧义,则不会盲目改写,并保留待人工检查。原图和 OCR 坐标诊断保留在“原图与识别诊断”页。 + +默认列映射为从左到右第 1~4 列,以及倒数第 2、倒数第 1 列。若右侧目标区域不是表格最后两列,可在左侧把“岗位三列”“岗位四列”改为实际列序号;修改后会直接重新整理缓存的 OCR 结果,无需再次识别图片。 + +```powershell +dotnet run --project .\RapidOCRWinform\RapidOCRWinform.csproj +``` + +三个 ONNX 模型和 `ppocrv5_dict.txt` 已编译进 WinForms 程序;首次启动会校验并释放到用户模型缓存并自动初始化,不需要选择模型目录,也不需要在程序旁放置 `models` 目录。核心日期/几何规则与 Excel OOXML 导出测试可用以下命令运行: + +```powershell +dotnet test .\RapidOCRSchedule.Tests\RapidOCRSchedule.Tests.csproj +``` + ### 👉快速入门 > 下面以控制台应用来演示如何快速入门 #### 1.增加一个控制台应用 @@ -92,7 +115,61 @@ Console.ReadKey(); |-------|-------|--------| |核心库|RapidOCRLib|C#的RapidOCR核心封装库,提供众多同步、异步的Detection方法| |console应用|RapidOCRConsole|.net OCR的Console演示,自带PP-OCRv5轻量模型,可以直接运行,引用了上面的核心库RapidOCRLib,学习者以此项目为主| -|winform应用|RapidOCRWinform|.net OCR的winform演示,可以直接运行,但不带模型,学习者可以从RapidOCRConsole目录拷贝模型| +|winform应用|RapidOCRWinform|排班图片 OCR 桌面应用,内嵌 PP-OCRv5 模型,可发布为独立单文件 EXE| + +### 📦 Windows x64 离线打包 + +`RapidOCRWinform` 可以发布为“框架依赖”或“自包含”应用: + +- **框架依赖包(体积较小)**:目标电脑必须安装 .NET 10 Desktop Runtime。 +- **自包含包(无需安装 .NET)**:包内携带 .NET 运行时,体积更大,但解压后即可离线运行。 + +框架依赖发布命令: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -c Release ` + -r win-x64 ` + --self-contained false ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true +``` + +完全自包含发布命令: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true +``` + +也可以直接使用仓库内已固化的发布配置: + +```powershell +dotnet publish .\RapidOCRWinform\RapidOCRWinform.csproj ` + -p:PublishProfile=StandaloneWinX64 ` + -o .\dist\RapidOCRSchedule-standalone-win-x64 +``` + +发布目录只需要一个文件: + +```text +RapidOCRSchedule.exe +``` + +该 EXE 已包含 .NET 桌面运行时、OCR 原生库、三个 ONNX 模型和字典,不依赖 Python、Anaconda、CUDA、Office、已安装的 .NET 或联网服务,但限定为 Windows x64。首次启动会把模型校验后释放到 `%LOCALAPPDATA%\RapidOCRSchedule\model-cache`,.NET 单文件宿主也会把原生 DLL 释放到用户临时目录;这些都是自动管理的运行时缓存,不是需要随 EXE 分发的外部环境。 + +可用无界面自检确认模型、ONNX Runtime 和 OpenCV 均能工作: + +```powershell +$test = Start-Process .\RapidOCRSchedule.exe -ArgumentList --self-test -Wait -PassThru +if ($test.ExitCode -ne 0) { throw "独立包自检失败" } +``` ### ❓ 参数说明及调参 @@ -141,4 +218,4 @@ public async Task DetectAsync(string imgFile, int padding = 50, int m ### 🎉 联系方式 -[QQ群](https://rapidai.github.io/RapidOCRDocs/main/communicate/#qq) \ No newline at end of file +[QQ群](https://rapidai.github.io/RapidOCRDocs/main/communicate/#qq) diff --git a/RapidOCRCSharp.sln b/RapidOCRCSharp.sln index 5ee1864..2f11c33 100644 --- a/RapidOCRCSharp.sln +++ b/RapidOCRCSharp.sln @@ -11,6 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RapidOCRWinform", "RapidOCR EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RapidOCRLib.Tests", "RapidOCRLib.Tests\RapidOCRLib.Tests.csproj", "{F09F59AD-C0B1-4F96-9F86-FE6FA25EE12F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RapidOCRSchedule.Core", "RapidOCRSchedule.Core\RapidOCRSchedule.Core.csproj", "{51786420-E70F-4451-B177-A8AB1B9DAD55}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RapidOCRSchedule.Excel", "RapidOCRSchedule.Excel\RapidOCRSchedule.Excel.csproj", "{B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RapidOCRSchedule.Tests", "RapidOCRSchedule.Tests\RapidOCRSchedule.Tests.csproj", "{9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +75,42 @@ Global {F09F59AD-C0B1-4F96-9F86-FE6FA25EE12F}.Release|x64.Build.0 = Release|Any CPU {F09F59AD-C0B1-4F96-9F86-FE6FA25EE12F}.Release|x86.ActiveCfg = Release|Any CPU {F09F59AD-C0B1-4F96-9F86-FE6FA25EE12F}.Release|x86.Build.0 = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|x64.ActiveCfg = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|x64.Build.0 = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|x86.ActiveCfg = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Debug|x86.Build.0 = Debug|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|Any CPU.Build.0 = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|x64.ActiveCfg = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|x64.Build.0 = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|x86.ActiveCfg = Release|Any CPU + {51786420-E70F-4451-B177-A8AB1B9DAD55}.Release|x86.Build.0 = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|x64.Build.0 = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Debug|x86.Build.0 = Debug|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|Any CPU.Build.0 = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|x64.ActiveCfg = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|x64.Build.0 = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|x86.ActiveCfg = Release|Any CPU + {B3BE055E-35B5-4250-B4B8-7F8A9A9DD3A5}.Release|x86.Build.0 = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|x64.Build.0 = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Debug|x86.Build.0 = Debug|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|Any CPU.Build.0 = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|x64.ActiveCfg = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|x64.Build.0 = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|x86.ActiveCfg = Release|Any CPU + {9D6FC8E1-0767-40AE-95B5-20C9CCD74B7F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/RapidOCRConsole/Program.cs b/RapidOCRConsole/Program.cs index bcdb4db..8888613 100644 --- a/RapidOCRConsole/Program.cs +++ b/RapidOCRConsole/Program.cs @@ -16,8 +16,10 @@ }; await ocrEngin.InitModels(); -//get the image will be ocr. -var demoWillOCRFile = Path.Combine(AppContext.BaseDirectory, "Assets", "demo.png"); +// Use the first command-line argument when supplied; otherwise run the bundled demo. +var demoWillOCRFile = args.Length > 0 + ? Path.GetFullPath(args[0]) + : Path.Combine(AppContext.BaseDirectory, "Assets", "demo.png"); //ocr detect var result = ocrEngin.Detect(demoWillOCRFile, 50); if (result != null) @@ -41,6 +43,3 @@ Console.WriteLine("显示strResult"); Console.WriteLine(result.StrRes); } - - -Console.ReadKey(); diff --git a/RapidOCRLib/AngleNet.cs b/RapidOCRLib/AngleNet.cs index 9e2af73..8ed4df9 100644 --- a/RapidOCRLib/AngleNet.cs +++ b/RapidOCRLib/AngleNet.cs @@ -12,7 +12,7 @@ namespace RapidOCRLib { - class AngleNet + class AngleNet : IDisposable { private readonly float[] MeanValues = { 127.5F, 127.5F, 127.5F }; private readonly float[] NormValues = { 1.0F / 127.5F, 1.0F / 127.5F, 1.0F / 127.5F }; @@ -26,7 +26,19 @@ public AngleNet() { } ~AngleNet() { - angleNet.Dispose(); + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + angleNet?.Dispose(); + angleNet = null; } public async Task InitModel(string path, int numThread) @@ -40,10 +52,12 @@ public async Task InitModel(string path, int numThread) angleNet = new InferenceSession(path, op); inputNames = angleNet.InputMetadata.Keys.ToList(); - // 从 ONNX 模型 metadata 读取输入尺寸,兼容 v2 (192x48) 和 v5 (160x80) + // Read static dimensions from the model when available. Some PaddleOCR + // classifier exports expose both spatial dimensions as dynamic (-1), + // in which case use the standard PP-OCR classifier input size. var dims = angleNet.InputMetadata.First().Value.Dimensions; - _dstHeight = dims[2]; - _dstWidth = dims[3]; + _dstHeight = dims[2] > 0 ? dims[2] : 48; + _dstWidth = dims[3] > 0 ? dims[3] : 192; await Task.CompletedTask; } @@ -109,6 +123,11 @@ public List GetAngles(List partImgs, bool doAngle, bool mostAngle) private Angle GetAngle(Mat src) { + if (src == null || src.Rows <= 0 || src.Cols <= 0) + { + throw new ArgumentException("Angle classifier received an empty image.", nameof(src)); + } + Angle angle = new Angle(); Mat angleImg = new Mat(); CvInvoke.Resize(src, angleImg, new Size(_dstWidth, _dstHeight)); diff --git a/RapidOCRLib/CrnnNet.cs b/RapidOCRLib/CrnnNet.cs index 7c786d2..06a9bf8 100644 --- a/RapidOCRLib/CrnnNet.cs +++ b/RapidOCRLib/CrnnNet.cs @@ -12,7 +12,7 @@ namespace RapidOCRLib { - class CrnnNet + class CrnnNet : IDisposable { private readonly float[] MeanValues = { 127.5F, 127.5F, 127.5F }; private readonly float[] NormValues = { 1.0F / 127.5F, 1.0F / 127.5F, 1.0F / 127.5F }; @@ -26,8 +26,20 @@ class CrnnNet public CrnnNet() { } ~CrnnNet() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) { crnnNet?.Dispose(); + crnnNet = null; } public async Task InitModel(string path, string keysPath, int numThread) @@ -149,4 +161,4 @@ private TextLine ScoreToTextLine(float[] srcData, int h, int w) } } -} \ No newline at end of file +} diff --git a/RapidOCRLib/DbNet.cs b/RapidOCRLib/DbNet.cs index 8c89475..a254df9 100644 --- a/RapidOCRLib/DbNet.cs +++ b/RapidOCRLib/DbNet.cs @@ -14,7 +14,7 @@ namespace RapidOCRLib { - class DbNet + class DbNet : IDisposable { private readonly float[] MeanValues = { 0.485F * 255F, 0.456F * 255F, 0.406F * 255F }; private readonly float[] NormValues = { 1.0F / 0.229F / 255.0F, 1.0F / 0.224F / 255.0F, 1.0F / 0.225F / 255.0F }; @@ -27,7 +27,19 @@ public DbNet() { } ~DbNet() { - dbNet.Dispose(); + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + dbNet?.Dispose(); + dbNet = null; } public async Task InitModel(string path, int numThread) @@ -206,32 +218,7 @@ private static List GetMiniBox(VectorOfPoint contours, out float minEdge public static int CompareByX(PointF left, PointF right) { - if (left == null && right == null) - { - return 1; - } - - if (left == null) - { - return 0; - } - - if (right == null) - { - return 1; - } - - if (left.X > right.X) - { - return 1; - } - - if (left.X == right.X) - { - return 0; - } - - return -1; + return left.X.CompareTo(right.X); } private static double GetScore(VectorOfPoint contours, Mat fMapMat) diff --git a/RapidOCRLib/OcrLite.cs b/RapidOCRLib/OcrLite.cs index 28d430e..0f4589f 100644 --- a/RapidOCRLib/OcrLite.cs +++ b/RapidOCRLib/OcrLite.cs @@ -12,7 +12,7 @@ namespace RapidOCRLib { - public partial class OcrLite + public partial class OcrLite : IDisposable { public bool isPartImg { get; set; } public bool isDebugImg { get; set; } @@ -50,6 +50,17 @@ public OcrLite() angleNet = new AngleNet(); crnnNet = new CrnnNet(); } + + /// + /// Releases the native ONNX Runtime sessions owned by this OCR engine. + /// + public void Dispose() + { + dbNet.Dispose(); + angleNet.Dispose(); + crnnNet.Dispose(); + GC.SuppressFinalize(this); + } /// /// constructor /// diff --git a/RapidOCRSchedule.Core/EmployeeNameResolver.cs b/RapidOCRSchedule.Core/EmployeeNameResolver.cs new file mode 100644 index 0000000..d678a12 --- /dev/null +++ b/RapidOCRSchedule.Core/EmployeeNameResolver.cs @@ -0,0 +1,243 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace RapidOCRSchedule.Core; + +public sealed class EmployeeNameResolver +{ + private const uint SimplifiedChineseMapFlag = 0x02000000; + private readonly bool _convertTraditionalToSimplified; + private readonly IReadOnlyList _entries; + private readonly Dictionary _exactNames; + + public EmployeeNameResolver( + IEnumerable? employeeNames, + bool convertTraditionalToSimplified = true) + { + _convertTraditionalToSimplified = convertTraditionalToSimplified; + IReadOnlyList normalizedNames = NormalizeEmployeeNames(employeeNames, convertTraditionalToSimplified); + _entries = normalizedNames + .Select(static name => new EmployeeNameEntry(name, Canonicalize(name))) + .ToArray(); + _exactNames = _entries.ToDictionary(static entry => entry.CanonicalName, static entry => entry.Name, StringComparer.Ordinal); + } + + public static IReadOnlyList NormalizeEmployeeNames( + IEnumerable? employeeNames, + bool convertTraditionalToSimplified = true) + { + if (employeeNames is null) + { + return []; + } + + List normalizedNames = []; + HashSet seen = new(StringComparer.Ordinal); + foreach (string? employeeName in employeeNames) + { + string normalized = NormalizeInput(employeeName, convertTraditionalToSimplified).Trim(); + string canonical = Canonicalize(normalized); + if (canonical.Length == 0 || !seen.Add(canonical)) + { + continue; + } + + normalizedNames.Add(normalized); + } + + return normalizedNames; + } + + public string Resolve(string? recognizedName) + { + string normalized = NormalizeInput(recognizedName, _convertTraditionalToSimplified).Trim(); + string canonical = Canonicalize(normalized); + if (canonical.Length == 0) + { + return string.Empty; + } + + if (_exactNames.TryGetValue(canonical, out string? exactName)) + { + return exactName; + } + + // Fuzzy correction is deliberately conservative. Chinese employee names with + // fewer than three characters are too easy to confuse, and matching surnames + // must agree. Ambiguous one-edit matches are left unchanged for manual review. + if (canonical.Length < 3) + { + return normalized; + } + + string? uniqueMatch = null; + foreach (EmployeeNameEntry entry in _entries) + { + if (entry.CanonicalName.Length < 3 || + entry.CanonicalName[0] != canonical[0] || + Math.Abs(entry.CanonicalName.Length - canonical.Length) > 1 || + EditDistanceAtMostOne(canonical, entry.CanonicalName) > 1) + { + continue; + } + + if (uniqueMatch is not null) + { + return normalized; + } + + uniqueMatch = entry.Name; + } + + return uniqueMatch ?? normalized; + } + + private static string NormalizeInput(string? value, bool convertTraditionalToSimplified) => + convertTraditionalToSimplified + ? ToSimplifiedChinese(value) + : (value ?? string.Empty).Normalize(NormalizationForm.FormKC); + + public static string ToSimplifiedChinese(string? value) + { + string normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC); + if (normalized.Length == 0) + { + return string.Empty; + } + + if (!OperatingSystem.IsWindows()) + { + return normalized; + } + + try + { + return MapToSimplifiedChinese(normalized); + } + catch (InvalidOperationException) + { + // Name-list matching still provides a safe one-edit fallback if Windows + // cannot perform the simplified-Chinese conversion on this installation. + return normalized; + } + } + + [SupportedOSPlatform("windows")] + private static string MapToSimplifiedChinese(string value) + { + int requiredLength = LCMapStringEx( + "zh-CN", + SimplifiedChineseMapFlag, + value, + -1, + null, + 0, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero); + if (requiredLength == 0) + { + throw new InvalidOperationException($"Windows 中文转换失败,错误码:{Marshal.GetLastWin32Error()}。"); + } + + StringBuilder destination = new(requiredLength); + int writtenLength = LCMapStringEx( + "zh-CN", + SimplifiedChineseMapFlag, + value, + -1, + destination, + destination.Capacity, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero); + if (writtenLength == 0) + { + throw new InvalidOperationException($"Windows 中文转换失败,错误码:{Marshal.GetLastWin32Error()}。"); + } + + return destination.ToString(); + } + + [DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [SupportedOSPlatform("windows")] + private static extern int LCMapStringEx( + string localeName, + uint mapFlags, + string source, + int sourceLength, + StringBuilder? destination, + int destinationLength, + IntPtr versionInformation, + IntPtr reserved, + IntPtr sortHandle); + + private static string Canonicalize(string value) + { + StringBuilder builder = new(value.Length); + foreach (char character in value.Normalize(NormalizationForm.FormKC)) + { + if (!char.IsWhiteSpace(character)) + { + builder.Append(character); + } + } + + return builder.ToString(); + } + + private static int EditDistanceAtMostOne(string left, string right) + { + if (left.Equals(right, StringComparison.Ordinal)) + { + return 0; + } + + if (Math.Abs(left.Length - right.Length) > 1) + { + return 2; + } + + int leftIndex = 0; + int rightIndex = 0; + int edits = 0; + while (leftIndex < left.Length && rightIndex < right.Length) + { + if (left[leftIndex] == right[rightIndex]) + { + leftIndex++; + rightIndex++; + continue; + } + + if (++edits > 1) + { + return edits; + } + + if (left.Length > right.Length) + { + leftIndex++; + } + else if (right.Length > left.Length) + { + rightIndex++; + } + else + { + leftIndex++; + rightIndex++; + } + } + + if (leftIndex < left.Length || rightIndex < right.Length) + { + edits++; + } + + return edits; + } + + private sealed record EmployeeNameEntry(string Name, string CanonicalName); +} diff --git a/RapidOCRSchedule.Core/Models.cs b/RapidOCRSchedule.Core/Models.cs new file mode 100644 index 0000000..5f01cb0 --- /dev/null +++ b/RapidOCRSchedule.Core/Models.cs @@ -0,0 +1,161 @@ +namespace RapidOCRSchedule.Core; + +public readonly record struct OcrBounds(float Left, float Top, float Right, float Bottom) +{ + public float Width => Math.Max(0, Right - Left); + public float Height => Math.Max(0, Bottom - Top); + public float CenterX => (Left + Right) / 2F; + public float CenterY => (Top + Bottom) / 2F; + + public static OcrBounds FromLTRB(float left, float top, float right, float bottom) + { + if (!float.IsFinite(left) || !float.IsFinite(top) || + !float.IsFinite(right) || !float.IsFinite(bottom)) + { + throw new ArgumentOutOfRangeException(nameof(left), "OCR coordinates must be finite numbers."); + } + + if (right < left || bottom < top) + { + throw new ArgumentException("OCR bounds must have non-negative width and height."); + } + + return new OcrBounds(left, top, right, bottom); + } +} + +public sealed record OcrToken( + string SourceImageId, + string Text, + OcrBounds Bounds, + float Confidence) +{ + public string SourceImageId { get; init; } = + string.IsNullOrWhiteSpace(SourceImageId) + ? throw new ArgumentException("A source image id is required.", nameof(SourceImageId)) + : SourceImageId; + + public string Text { get; init; } = Text?.Trim() ?? string.Empty; + + public float Confidence { get; init; } = + float.IsFinite(Confidence) ? Math.Clamp(Confidence, 0F, 1F) : 0F; +} + +public sealed class GeometryOptions +{ + public float RowToleranceFactor { get; set; } = 0.72F; + public float MinimumRowTolerance { get; set; } = 0.006F; + public float ColumnToleranceFactor { get; set; } = 0.72F; + public float MinimumColumnTolerance { get; set; } = 0.012F; + public float LowConfidenceThreshold { get; set; } = 0.62F; + public int MinimumPopulatedTargetCells { get; set; } = 1; +} + +public sealed class TableLayoutProfile +{ + public int DateColumn { get; set; } = 1; + public int WeekdayColumn { get; set; } = 2; + public int Role1Column { get; set; } = 3; + public int Role2Column { get; set; } = 4; + public int Role3Column { get; set; } = -2; + public int Role4Column { get; set; } = -1; + public float BodyTop { get; set; } = 0F; + public string CellTextSeparator { get; set; } = "、"; + public GeometryOptions Geometry { get; set; } = new(); + + public IReadOnlyList TargetColumns => + [DateColumn, WeekdayColumn, Role1Column, Role2Column, Role3Column, Role4Column]; + + public void Validate() + { + if (TargetColumns.Any(static value => value == 0)) + { + throw new ArgumentException("Column 0 is invalid. Use 1 for the first column or -1 for the last column."); + } + + if (TargetColumns.Distinct().Count() != TargetColumns.Count) + { + throw new ArgumentException("The six target columns must be different."); + } + + if (BodyTop is < 0F or >= 1F) + { + throw new ArgumentOutOfRangeException(nameof(BodyTop), "BodyTop must be within [0, 1)."); + } + + ArgumentNullException.ThrowIfNull(Geometry); + } +} + +public enum ScheduleIssueCode +{ + MissingWorkday, + DuplicateDate, + InvalidDate, + InvalidWeekday, + WeekdayMismatch, + EmptyRole1, + EmptyRole2, + EmptyRole3, + EmptyRole4, + AmbiguousRow, + AmbiguousColumn, + LowOcrConfidence, + MultipleMonths, + OutOfTargetMonth, + UnassignedOcrToken, + InsufficientColumns, +} + +public sealed record ScheduleIssue( + ScheduleIssueCode Code, + string Message, + string? PropertyName = null, + string? SourceImageId = null); + +public sealed class ScheduleCandidate +{ + public string RawDateText { get; set; } = string.Empty; + public string RawWeekdayText { get; set; } = string.Empty; + public string Role1 { get; set; } = string.Empty; + public string Role2 { get; set; } = string.Empty; + public string Role3 { get; set; } = string.Empty; + public string Role4 { get; set; } = string.Empty; + public string SourceImageId { get; set; } = string.Empty; + public float RowCenterY { get; set; } + public List Issues { get; } = []; + public List SourceTokens { get; } = []; +} + +public sealed class ScheduleRow +{ + public DateOnly? Date { get; set; } + public string RawDateText { get; set; } = string.Empty; + public string Weekday { get; set; } = string.Empty; + public string Role1 { get; set; } = string.Empty; + public string Role2 { get; set; } = string.Empty; + public string Role3 { get; set; } = string.Empty; + public string Role4 { get; set; } = string.Empty; + public string SourceImageId { get; set; } = string.Empty; + public bool IsMissingPlaceholder { get; set; } + public List Issues { get; } = []; +} + +public sealed class ScheduleExtractionResult +{ + public List Candidates { get; } = []; + public List UnassignedTokens { get; } = []; + public List Issues { get; } = []; +} + +public sealed class ScheduleBuildResult +{ + public List Rows { get; } = []; + public List UnlocatedCandidates { get; } = []; + public List WeekendCandidates { get; } = []; + public List DuplicateCandidates { get; } = []; + public List Issues { get; } = []; + + public int ProblemRowCount => Rows.Count(static row => row.Issues.Count > 0); + public bool HasProblems => ProblemRowCount > 0 || Issues.Count > 0 || UnlocatedCandidates.Count > 0; +} diff --git a/RapidOCRSchedule.Core/RapidOCRSchedule.Core.csproj b/RapidOCRSchedule.Core/RapidOCRSchedule.Core.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/RapidOCRSchedule.Core/RapidOCRSchedule.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/RapidOCRSchedule.Core/ScheduleExtractor.cs b/RapidOCRSchedule.Core/ScheduleExtractor.cs new file mode 100644 index 0000000..177a571 --- /dev/null +++ b/RapidOCRSchedule.Core/ScheduleExtractor.cs @@ -0,0 +1,582 @@ +namespace RapidOCRSchedule.Core; + +/// +/// Converts unordered, normalized OCR boxes into schedule-row candidates. +/// It never relies on the OCR engine's return order. +/// +public sealed class ScheduleExtractor +{ + public ScheduleExtractionResult Extract( + IEnumerable tokens, + TableLayoutProfile? profile = null) + { + ArgumentNullException.ThrowIfNull(tokens); + profile ??= new TableLayoutProfile(); + profile.Validate(); + + ScheduleExtractionResult result = new(); + OcrToken[] materialized = tokens + .Where(static token => !string.IsNullOrWhiteSpace(token.Text)) + .ToArray(); + + IGrouping[] sources = materialized + .GroupBy(static token => token.SourceImageId, StringComparer.OrdinalIgnoreCase) + .OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + List sharedColumnCenters = InferSharedColumnCenters(sources, profile, result); + + foreach (IGrouping source in sources) + { + ExtractSource(source.Key, source.ToArray(), profile, sharedColumnCenters, result); + } + + return result; + } + + private static void ExtractSource( + string sourceImageId, + IReadOnlyList sourceTokens, + TableLayoutProfile profile, + IReadOnlyList sharedColumnCenters, + ScheduleExtractionResult result) + { + List bodyTokens = sourceTokens + .Where(token => token.Bounds.CenterY >= profile.BodyTop) + .OrderBy(static token => token.Bounds.CenterY) + .ThenBy(static token => token.Bounds.CenterX) + .ToList(); + + if (bodyTokens.Count == 0) + { + result.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.UnassignedOcrToken, + "图片中没有可用于表格解析的 OCR 文本。", + SourceImageId: sourceImageId)); + return; + } + + float medianHeight = Median(bodyTokens.Select(static token => token.Bounds.Height).Where(static value => value > 0)); + float rowTolerance = Math.Max( + profile.Geometry.MinimumRowTolerance, + medianHeight * profile.Geometry.RowToleranceFactor); + List rows = ClusterRows(bodyTokens, rowTolerance); + + float medianWidth = Median(bodyTokens.Select(static token => token.Bounds.Width).Where(static value => value > 0)); + float columnTolerance = Math.Max( + profile.Geometry.MinimumColumnTolerance, + medianWidth * profile.Geometry.ColumnToleranceFactor); + IReadOnlyList columns = sharedColumnCenters; + + int requiredPositiveColumn = profile.TargetColumns.Where(static value => value > 0).DefaultIfEmpty(0).Max(); + int requiredColumnCount = Math.Max(requiredPositiveColumn, profile.TargetColumns.Count); + if (columns.Count < requiredColumnCount) + { + result.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.InsufficientColumns, + $"{Path.GetFileName(sourceImageId)} 仅推断出 {columns.Count} 列,无法可靠映射全部 6 个目标字段;请检查图片或调整右侧列序号。", + SourceImageId: sourceImageId)); + } + + int[] resolvedTargets = profile.TargetColumns + .Select(column => ResolveColumnIndex(column, columns.Count)) + .ToArray(); + bool targetCollision = resolvedTargets.Where(static index => index >= 0).Distinct().Count() != + resolvedTargets.Count(static index => index >= 0); + + float[] dateRowCenters = rows + .Where(static row => RowContainsDate(row)) + .Select(static row => row.Center) + .Order() + .ToArray(); + float typicalRowGap = Median( + dateRowCenters.Zip(dateRowCenters.Skip(1), static (top, bottom) => bottom - top) + .Where(static gap => gap > 0)); + float bodyMinimumY = dateRowCenters.Length > 0 + ? dateRowCenters[0] - Math.Max(typicalRowGap, rowTolerance * 2F) + : profile.BodyTop; + float bodyMaximumY = dateRowCenters.Length > 0 + ? dateRowCenters[^1] + Math.Max(typicalRowGap, rowTolerance * 2F) + : 1F; + + foreach (RowCluster row in rows) + { + if (IsSpreadsheetColumnHeaderRow(row)) + { + continue; + } + + float assignmentTolerance = GetAssignmentTolerance(columns, columnTolerance); + Dictionary> cells = AssignCells(row, columns, assignmentTolerance, out List rejectedTokens); + result.UnassignedTokens.AddRange(rejectedTokens); + string dateText = JoinCell(cells, resolvedTargets[0], string.Empty); + string weekdayText = JoinCell(cells, resolvedTargets[1], string.Empty); + string role1 = JoinCell(cells, resolvedTargets[2], profile.CellTextSeparator); + string role2 = JoinCell(cells, resolvedTargets[3], profile.CellTextSeparator); + string role3 = JoinCell(cells, resolvedTargets[4], profile.CellTextSeparator); + string role4 = JoinCell(cells, resolvedTargets[5], profile.CellTextSeparator); + + if (IsHeaderRow(dateText, weekdayText)) + { + continue; + } + + int populatedTargetCells = new[] { dateText, weekdayText, role1, role2, role3, role4 } + .Count(static value => !string.IsNullOrWhiteSpace(value)); + bool hasDate = DateTextParser.TryParseMonthDay(dateText, out _, out _); + bool withinDataBody = row.Center >= bodyMinimumY && row.Center <= bodyMaximumY; + if (!hasDate && (!withinDataBody || populatedTargetCells < profile.Geometry.MinimumPopulatedTargetCells)) + { + result.UnassignedTokens.AddRange(row.Tokens); + continue; + } + + ScheduleCandidate candidate = new() + { + SourceImageId = sourceImageId, + RawDateText = dateText, + RawWeekdayText = weekdayText, + Role1 = role1, + Role2 = role2, + Role3 = role3, + Role4 = role4, + RowCenterY = row.Center, + }; + candidate.SourceTokens.AddRange(row.Tokens); + + if (!hasDate) + { + candidate.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.InvalidDate, + "日期无法识别,请手动检查。", + nameof(ScheduleRow.RawDateText), + sourceImageId)); + } + + if (targetCollision || resolvedTargets.Any(static index => index < 0)) + { + candidate.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.AmbiguousColumn, + "目标列映射不完整或发生重叠,请调整岗位三、岗位四列序号。", + SourceImageId: sourceImageId)); + } + + if (row.CenterSpan > rowTolerance * 0.85F) + { + candidate.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.AmbiguousRow, + "本行文字的纵向位置分散,可能跨越了相邻两行,请对照原图检查。", + SourceImageId: sourceImageId)); + } + + if (HasBoundaryAmbiguity(row, columns, resolvedTargets, columnTolerance)) + { + candidate.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.AmbiguousColumn, + "本行存在靠近两列边界的文字,列归属需要人工检查。", + SourceImageId: sourceImageId)); + } + + if (row.Tokens.Any(token => token.Confidence < profile.Geometry.LowConfidenceThreshold)) + { + candidate.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.LowOcrConfidence, + "本行包含低置信度 OCR 文字,请对照原图检查。", + SourceImageId: sourceImageId)); + } + + result.Candidates.Add(candidate); + } + + if (result.UnassignedTokens.Any(token => + string.Equals(token.SourceImageId, sourceImageId, StringComparison.OrdinalIgnoreCase))) + { + int count = result.UnassignedTokens.Count(token => + string.Equals(token.SourceImageId, sourceImageId, StringComparison.OrdinalIgnoreCase)); + result.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.UnassignedOcrToken, + $"有 {count} 个 OCR 文本未形成有效排班行,可能是标题、手绘标记或识别噪声。", + SourceImageId: sourceImageId)); + } + } + + private static List ClusterRows(IReadOnlyList tokens, float tolerance) + { + List rows = []; + foreach (OcrToken token in tokens) + { + RowCluster? best = rows.Count == 0 ? null : rows[^1]; + if (best is null || Math.Abs(token.Bounds.CenterY - best.Center) > tolerance) + { + best = new RowCluster(); + rows.Add(best); + } + + best.Add(token); + } + + return rows; + } + + private static List InferSharedColumnCenters( + IReadOnlyList> sources, + TableLayoutProfile profile, + ScheduleExtractionResult result) + { + List positioned = []; + List<(RowCluster Row, int GlobalIndex)> fallbackRows = []; + int globalRowIndex = 0; + foreach (IGrouping source in sources) + { + List sourceTokens = source + .Where(token => token.Bounds.CenterY >= profile.BodyTop && !string.IsNullOrWhiteSpace(token.Text)) + .OrderBy(static token => token.Bounds.CenterY) + .ThenBy(static token => token.Bounds.CenterX) + .ToList(); + float medianHeight = Median(sourceTokens.Select(static token => token.Bounds.Height).Where(static value => value > 0)); + float rowTolerance = Math.Max( + profile.Geometry.MinimumRowTolerance, + medianHeight * profile.Geometry.RowToleranceFactor); + foreach (RowCluster row in ClusterRows(sourceTokens, rowTolerance)) + { + fallbackRows.Add((row, globalRowIndex)); + OcrToken[] orderedTokens = row.Tokens + .OrderBy(static token => token.Bounds.CenterX) + .ToArray(); + if (TryFindDateTokenSpan(orderedTokens, out int dateStartIndex, out _)) + { + positioned.AddRange(orderedTokens + .Skip(dateStartIndex) + .Select(token => new PositionedToken(token, globalRowIndex))); + } + else if (TryFindSemanticHeaderStart(orderedTokens, out int headerStartIndex)) + { + positioned.AddRange(orderedTokens + .Skip(headerStartIndex) + .Select(token => new PositionedToken(token, globalRowIndex))); + } + + globalRowIndex++; + } + } + + if (positioned.Count == 0) + { + positioned.AddRange(fallbackRows.SelectMany( + static entry => entry.Row.Tokens.Select(token => new PositionedToken(token, entry.GlobalIndex)))); + } + + float medianWidth = Median(positioned.Select(static item => item.Token.Bounds.Width).Where(static value => value > 0)); + float tolerance = Math.Max( + profile.Geometry.MinimumColumnTolerance, + medianWidth * profile.Geometry.ColumnToleranceFactor); + + List clusters = []; + foreach (PositionedToken item in positioned.OrderBy(static item => item.Token.Bounds.CenterX)) + { + ColumnCluster? nearest = clusters + .Where(cluster => Math.Abs(cluster.Center - item.Token.Bounds.CenterX) <= tolerance) + .OrderBy(cluster => Math.Abs(cluster.Center - item.Token.Bounds.CenterX)) + .FirstOrDefault(); + if (nearest is null) + { + nearest = new ColumnCluster(); + clusters.Add(nearest); + } + + nearest.Add(item); + } + + List ordered = clusters.OrderBy(static cluster => cluster.Center).ToList(); + int requiredColumnCount = Math.Max( + profile.TargetColumns.Where(static value => value > 0).DefaultIfEmpty(0).Max(), + profile.TargetColumns.Count); + List recurring = ordered.Where(static cluster => cluster.RowCount >= 2).ToList(); + if (recurring.Count < requiredColumnCount) + { + return ordered.Select(static cluster => cluster.Center).ToList(); + } + + float typicalGap = Median( + recurring.Zip(recurring.Skip(1), static (left, right) => right.Center - left.Center) + .Where(gap => gap > tolerance * 1.2F)); + List selected = [.. recurring]; + int retainedSparse = 0; + foreach (ColumnCluster sparse in ordered.Where(static cluster => cluster.RowCount == 1)) + { + if (FitsMissingGridPosition(sparse.Center, recurring, typicalGap)) + { + selected.Add(sparse); + retainedSparse++; + } + } + + if (retainedSparse > 0) + { + result.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.AmbiguousColumn, + $"列模型中有 {retainedSparse} 列只在单行出现,已按表格间距保留,请重点检查这些列。")); + } + + return selected.OrderBy(static cluster => cluster.Center).Select(static cluster => cluster.Center).ToList(); + } + + private static bool FitsMissingGridPosition( + float center, + IReadOnlyList recurring, + float typicalGap) + { + if (typicalGap <= 0 || recurring.Count < 2) + { + return false; + } + + ColumnCluster? left = recurring.LastOrDefault(cluster => cluster.Center < center); + ColumnCluster? right = recurring.FirstOrDefault(cluster => cluster.Center > center); + const float minimumFactor = 0.42F; + const float maximumFactor = 1.65F; + + if (left is not null && right is not null) + { + float leftGap = center - left.Center; + float rightGap = right.Center - center; + return right.Center - left.Center > typicalGap * 1.55F && + leftGap >= typicalGap * minimumFactor && leftGap <= typicalGap * maximumFactor && + rightGap >= typicalGap * minimumFactor && rightGap <= typicalGap * maximumFactor; + } + + float edgeGap = left is not null ? center - left.Center : right!.Center - center; + return edgeGap >= typicalGap * minimumFactor && edgeGap <= typicalGap * maximumFactor; + } + + private static bool TryFindSemanticHeaderStart( + IReadOnlyList orderedTokens, + out int startIndex) + { + startIndex = -1; + for (int index = 0; index < orderedTokens.Count; index++) + { + if (orderedTokens[index].Text.Contains("日期", StringComparison.Ordinal)) + { + startIndex = index; + break; + } + } + + if (startIndex < 0) + { + return false; + } + + string combined = string.Concat(orderedTokens.Skip(startIndex).Select(static token => token.Text)); + return combined.Contains("星期", StringComparison.Ordinal) || + combined.Contains("周", StringComparison.Ordinal); + } + + private static bool RowContainsDate(RowCluster row) + { + OcrToken[] ordered = row.Tokens.OrderBy(static token => token.Bounds.CenterX).ToArray(); + return TryFindDateTokenSpan(ordered, out _, out _); + } + + private static bool TryFindDateTokenSpan( + IReadOnlyList orderedTokens, + out int startIndex, + out int tokenCount) + { + startIndex = -1; + tokenCount = 0; + for (int start = 0; start < orderedTokens.Count; start++) + { + for (int count = 1; count <= Math.Min(3, orderedTokens.Count - start); count++) + { + string candidate = string.Concat( + orderedTokens.Skip(start).Take(count).Select(static token => token.Text)); + if (DateTextParser.TryParseMonthDay(candidate, out _, out _)) + { + startIndex = start; + tokenCount = count; + return true; + } + } + } + + return false; + } + + private static bool IsSpreadsheetColumnHeaderRow(RowCluster row) + { + string[] texts = row.Tokens + .Select(static token => token.Text.Trim()) + .Where(static text => text.Length > 0) + .ToArray(); + if (texts.Length < 4) + { + return false; + } + + int labelCount = texts.Count(IsSpreadsheetColumnLabel); + return labelCount >= 4 && labelCount >= (int)Math.Ceiling(texts.Length * 0.70D); + } + + private static bool IsSpreadsheetColumnLabel(string text) => + text.Length is >= 1 and <= 3 && + text.All(static character => character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'); + + private static Dictionary> AssignCells( + RowCluster row, + IReadOnlyList columns, + float assignmentTolerance, + out List rejectedTokens) + { + Dictionary> cells = []; + rejectedTokens = []; + if (columns.Count == 0) + { + rejectedTokens.AddRange(row.Tokens); + return cells; + } + + foreach (OcrToken token in row.Tokens) + { + int columnIndex = Enumerable.Range(0, columns.Count) + .OrderBy(index => Math.Abs(columns[index] - token.Bounds.CenterX)) + .First(); + if (Math.Abs(columns[columnIndex] - token.Bounds.CenterX) > assignmentTolerance) + { + rejectedTokens.Add(token); + continue; + } + + if (!cells.TryGetValue(columnIndex, out List? cellTokens)) + { + cellTokens = []; + cells[columnIndex] = cellTokens; + } + + cellTokens.Add(token); + } + + return cells; + } + + private static float GetAssignmentTolerance(IReadOnlyList columns, float columnTolerance) + { + float typicalGap = Median( + columns.Zip(columns.Skip(1), static (left, right) => right - left) + .Where(static gap => gap > 0)); + float preferred = Math.Max(0.018F, columnTolerance * 2.2F); + return typicalGap > 0 ? Math.Min(preferred, typicalGap * 0.42F) : preferred; + } + + private static string JoinCell( + IReadOnlyDictionary> cells, + int columnIndex, + string separator) + { + if (columnIndex < 0 || !cells.TryGetValue(columnIndex, out List? tokens)) + { + return string.Empty; + } + + return string.Join( + separator, + tokens.OrderBy(static token => token.Bounds.Left) + .Select(static token => token.Text.Trim()) + .Where(static text => text.Length > 0)); + } + + private static bool HasBoundaryAmbiguity( + RowCluster row, + IReadOnlyList columns, + IReadOnlyList targetColumns, + float columnTolerance) + { + if (columns.Count < 2) + { + return true; + } + + HashSet targetSet = targetColumns.Where(static index => index >= 0).ToHashSet(); + foreach (OcrToken token in row.Tokens) + { + int[] nearest = Enumerable.Range(0, columns.Count) + .OrderBy(index => Math.Abs(columns[index] - token.Bounds.CenterX)) + .Take(2) + .ToArray(); + if (nearest.Length < 2 || !targetSet.Contains(nearest[0])) + { + continue; + } + + float firstDistance = Math.Abs(columns[nearest[0]] - token.Bounds.CenterX); + float secondDistance = Math.Abs(columns[nearest[1]] - token.Bounds.CenterX); + if (secondDistance - firstDistance < columnTolerance * 0.22F) + { + return true; + } + } + + return false; + } + + private static int ResolveColumnIndex(int configuredIndex, int columnCount) => + configuredIndex > 0 ? configuredIndex - 1 : columnCount + configuredIndex; + + private static bool IsHeaderRow(string dateText, string weekdayText) + { + string date = dateText.Replace(" ", string.Empty, StringComparison.Ordinal); + string weekday = weekdayText.Replace(" ", string.Empty, StringComparison.Ordinal); + return (date.Contains("日期", StringComparison.Ordinal) || date == "日") && + (weekday.Contains("星期", StringComparison.Ordinal) || weekday.Contains("周", StringComparison.Ordinal)); + } + + private static float Median(IEnumerable values) + { + float[] sorted = values.Order().ToArray(); + if (sorted.Length == 0) + { + return 0F; + } + + int middle = sorted.Length / 2; + return sorted.Length % 2 == 0 + ? (sorted[middle - 1] + sorted[middle]) / 2F + : sorted[middle]; + } + + private sealed class RowCluster + { + private float _centerTotal; + private float _minimumCenter = float.PositiveInfinity; + private float _maximumCenter = float.NegativeInfinity; + + public List Tokens { get; } = []; + public float Center => Tokens.Count == 0 ? 0F : _centerTotal / Tokens.Count; + public float CenterSpan => Tokens.Count == 0 ? 0F : _maximumCenter - _minimumCenter; + + public void Add(OcrToken token) + { + Tokens.Add(token); + _centerTotal += token.Bounds.CenterY; + _minimumCenter = Math.Min(_minimumCenter, token.Bounds.CenterY); + _maximumCenter = Math.Max(_maximumCenter, token.Bounds.CenterY); + } + } + + private sealed class ColumnCluster + { + private float _centerTotal; + + public List Items { get; } = []; + public float Center => Items.Count == 0 ? 0F : _centerTotal / Items.Count; + public int RowCount => Items.Select(static item => item.RowIndex).Distinct().Count(); + + public void Add(PositionedToken item) + { + Items.Add(item); + _centerTotal += item.Token.Bounds.CenterX; + } + } + + private sealed record PositionedToken(OcrToken Token, int RowIndex); +} diff --git a/RapidOCRSchedule.Core/ScheduleService.cs b/RapidOCRSchedule.Core/ScheduleService.cs new file mode 100644 index 0000000..852c006 --- /dev/null +++ b/RapidOCRSchedule.Core/ScheduleService.cs @@ -0,0 +1,582 @@ +namespace RapidOCRSchedule.Core; + +/// +/// Merges extracted rows by date, creates all missing calendar dates, +/// and performs the business validations required by the schedule workflow. +/// +public sealed class ScheduleService +{ + public ScheduleBuildResult Build( + int targetYear, + int targetMonth, + IEnumerable candidates, + bool alignOcrRowsToCalendar = false, + bool ignoreWeekends = false) + { + if (targetYear is < 1900 or > 9999) + { + throw new ArgumentOutOfRangeException(nameof(targetYear)); + } + + if (targetMonth is < 1 or > 12) + { + throw new ArgumentOutOfRangeException(nameof(targetMonth)); + } + + ArgumentNullException.ThrowIfNull(candidates); + List preparedCandidates = candidates.ToList(); + if (alignOcrRowsToCalendar) + { + preparedCandidates = AlignOcrRowsToCalendar(targetYear, targetMonth, preparedCandidates); + } + + ScheduleBuildResult result = new(); + List located = []; + + foreach (ScheduleCandidate candidate in preparedCandidates) + { + // Build may be rerun when the user changes the target month. Remove findings that + // are relative to the previous target so they cannot leak into the new result. + candidate.Issues.RemoveAll(static issue => + issue.Code is ScheduleIssueCode.InvalidDate or + ScheduleIssueCode.OutOfTargetMonth or + ScheduleIssueCode.InvalidWeekday or + ScheduleIssueCode.WeekdayMismatch or + ScheduleIssueCode.EmptyRole1 or + ScheduleIssueCode.EmptyRole2 or + ScheduleIssueCode.EmptyRole3 or + ScheduleIssueCode.EmptyRole4); + + if (!DateTextParser.TryCreateDate(targetYear, candidate.RawDateText, out DateOnly date) && + !TryRecoverCompactOcrDate(targetYear, targetMonth, candidate, out date)) + { + AddIssueIfMissing( + candidate.Issues, + new ScheduleIssue( + ScheduleIssueCode.InvalidDate, + "日期无法识别,请手动填写有效的月/日。", + nameof(ScheduleRow.RawDateText), + candidate.SourceImageId)); + ValidateUnlocatedCandidate(candidate, date: null); + result.UnlocatedCandidates.Add(candidate); + continue; + } + + candidate.RawDateText = date.ToString("M/d"); + located.Add(new LocatedCandidate(candidate, date)); + } + + int[] recognizedMonths = located.Select(static item => item.Date.Month).Distinct().Order().ToArray(); + if (recognizedMonths.Length > 1) + { + result.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.MultipleMonths, + $"4 张图片中识别到多个月份:{string.Join("、", recognizedMonths.Select(static month => $"{month}月"))}。请核对目标月份和原图。")); + } + + foreach (LocatedCandidate item in located.Where(item => item.Date.Month != targetMonth)) + { + AddIssueIfMissing( + item.Candidate.Issues, + new ScheduleIssue( + ScheduleIssueCode.OutOfTargetMonth, + $"识别日期 {item.Date:M/d} 不属于目标月份 {targetMonth} 月。", + nameof(ScheduleRow.RawDateText), + item.Candidate.SourceImageId)); + ValidateUnlocatedCandidate(item.Candidate, item.Date); + result.UnlocatedCandidates.Add(item.Candidate); + } + + List targetMonthCandidates = located + .Where(item => item.Date.Month == targetMonth) + .ToList(); + if (ignoreWeekends) + { + result.WeekendCandidates.AddRange( + targetMonthCandidates + .Where(static item => IsWeekend(item.Date)) + .Select(static item => item.Candidate)); + targetMonthCandidates = targetMonthCandidates + .Where(static item => !IsWeekend(item.Date)) + .ToList(); + } + + Dictionary> byDate = targetMonthCandidates + .GroupBy(static item => item.Date) + .ToDictionary(static group => group.Key, static group => group.ToList()); + + DateOnly first = new(targetYear, targetMonth, 1); + DateOnly last = new(targetYear, targetMonth, DateTime.DaysInMonth(targetYear, targetMonth)); + for (DateOnly date = first; date <= last; date = date.AddDays(1)) + { + if (ignoreWeekends && IsWeekend(date)) + { + continue; + } + + if (!byDate.TryGetValue(date, out List? matches) || matches.Count == 0) + { + ScheduleRow missing = new() + { + Date = date, + RawDateText = date.ToString("M/d"), + Weekday = WeekdayTextParser.ToChinese(date.DayOfWeek), + IsMissingPlaceholder = true, + }; + missing.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.MissingWorkday, + "该日期未在 4 张图片中找到,请手动补充。")); + result.Rows.Add(missing); + continue; + } + + LocatedCandidate selected = matches + .OrderBy(static item => item.Candidate.SourceImageId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static item => item.Candidate.RowCenterY) + .First(); + ScheduleRow row = CreateRow(selected.Candidate, date); + + if (matches.Count > 1) + { + result.DuplicateCandidates.AddRange(matches.Select(static item => item.Candidate)); + string sources = string.Join( + "、", + matches.Select(static item => Path.GetFileName(item.Candidate.SourceImageId)).Distinct()); + row.SourceImageId = string.Join( + "; ", + matches.Select(static item => item.Candidate.SourceImageId).Distinct(StringComparer.OrdinalIgnoreCase)); + row.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.DuplicateDate, + $"同一日期识别出 {matches.Count} 条记录({sources}),当前显示第一条,请对照原图决定。")); + } + + ValidateRow(row, selected.Candidate.RawWeekdayText); + result.Rows.Add(row); + } + + return result; + } + + public ScheduleBuildResult Revalidate( + int targetYear, + int targetMonth, + IEnumerable editedRows, + bool ignoreWeekends = false) + { + ArgumentNullException.ThrowIfNull(editedRows); + return Build( + targetYear, + targetMonth, + CreateCandidatesFromRows(editedRows), + ignoreWeekends: ignoreWeekends); + } + + /// + /// Revalidates an untouched build result without losing duplicate or unlocated source rows. + /// UI callers with edited rows should use the enumerable overload and include their editable + /// unlocated rows, as the WinForms application does. + /// + public ScheduleBuildResult Revalidate( + int targetYear, + int targetMonth, + ScheduleBuildResult previous, + bool ignoreWeekends = false) + { + ArgumentNullException.ThrowIfNull(previous); + List candidates = CreateCandidatesFromRows( + previous.Rows.Where(static row => + row.Issues.All(static issue => issue.Code != ScheduleIssueCode.DuplicateDate))); + candidates.AddRange(previous.UnlocatedCandidates); + candidates.AddRange(previous.DuplicateCandidates); + candidates.AddRange(previous.WeekendCandidates); + return Build(targetYear, targetMonth, candidates, ignoreWeekends: ignoreWeekends); + } + + private static bool IsWeekend(DateOnly date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + + private static List CreateCandidatesFromRows(IEnumerable editedRows) + { + List candidates = []; + foreach (ScheduleRow row in editedRows) + { + bool completelyEmpty = string.IsNullOrWhiteSpace(row.Role1) && + string.IsNullOrWhiteSpace(row.Role2) && + string.IsNullOrWhiteSpace(row.Role3) && + string.IsNullOrWhiteSpace(row.Role4); + if (row.IsMissingPlaceholder && completelyEmpty) + { + continue; + } + + ScheduleCandidate candidate = new() + { + RawDateText = !string.IsNullOrWhiteSpace(row.RawDateText) + ? row.RawDateText + : row.Date?.ToString("M/d") ?? string.Empty, + RawWeekdayText = row.Weekday, + Role1 = row.Role1, + Role2 = row.Role2, + Role3 = row.Role3, + Role4 = row.Role4, + SourceImageId = string.IsNullOrWhiteSpace(row.SourceImageId) ? "手动编辑" : row.SourceImageId, + }; + candidate.Issues.AddRange(row.Issues.Where(static issue => IsPersistentExtractionIssue(issue.Code))); + candidates.Add(candidate); + } + + return candidates; + } + + private static ScheduleRow CreateRow(ScheduleCandidate candidate, DateOnly date) + { + ScheduleRow row = new() + { + Date = date, + RawDateText = date.ToString("M/d"), + Weekday = WeekdayTextParser.ToChinese(date.DayOfWeek), + Role1 = candidate.Role1.Trim(), + Role2 = candidate.Role2.Trim(), + Role3 = candidate.Role3.Trim(), + Role4 = candidate.Role4.Trim(), + SourceImageId = candidate.SourceImageId, + }; + row.Issues.AddRange(candidate.Issues); + return row; + } + + private static void ValidateRow(ScheduleRow row, string rawWeekdayText) + { + if (row.Date is not DateOnly date) + { + row.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.InvalidDate, + "日期无法识别。", + nameof(ScheduleRow.RawDateText))); + return; + } + + if (!WeekdayTextParser.TryParse(rawWeekdayText, out DayOfWeek recognizedWeekday)) + { + row.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.InvalidWeekday, + "星期无法识别,已按日期自动计算。", + nameof(ScheduleRow.Weekday))); + } + else if (recognizedWeekday != date.DayOfWeek) + { + row.Issues.Add(new ScheduleIssue( + ScheduleIssueCode.WeekdayMismatch, + $"OCR 星期为“{rawWeekdayText}”,与日期对应的{WeekdayTextParser.ToChinese(date.DayOfWeek)}不一致。", + nameof(ScheduleRow.Weekday))); + } + + AddEmptyRoleIssue(row, row.Role1, ScheduleIssueCode.EmptyRole1, nameof(ScheduleRow.Role1), "岗位一(队长)为空。"); + AddEmptyRoleIssue(row, row.Role2, ScheduleIssueCode.EmptyRole2, nameof(ScheduleRow.Role2), "岗位二为空。"); + AddEmptyRoleIssue(row, row.Role3, ScheduleIssueCode.EmptyRole3, nameof(ScheduleRow.Role3), "岗位三为空。"); + AddEmptyRoleIssue(row, row.Role4, ScheduleIssueCode.EmptyRole4, nameof(ScheduleRow.Role4), "岗位四为空。"); + } + + private static void AddEmptyRoleIssue( + ScheduleRow row, + string value, + ScheduleIssueCode code, + string propertyName, + string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + row.Issues.Add(new ScheduleIssue(code, message, propertyName, row.SourceImageId)); + } + } + + private static void AddIssueIfMissing(List issues, ScheduleIssue issue) + { + if (issues.All(existing => existing.Code != issue.Code)) + { + issues.Add(issue); + } + } + + private static void ValidateUnlocatedCandidate(ScheduleCandidate candidate, DateOnly? date) + { + if (!WeekdayTextParser.TryParse(candidate.RawWeekdayText, out DayOfWeek weekday)) + { + AddIssueIfMissing( + candidate.Issues, + new ScheduleIssue( + ScheduleIssueCode.InvalidWeekday, + "星期无法识别。", + nameof(ScheduleRow.Weekday), + candidate.SourceImageId)); + } + else if (date.HasValue && weekday != date.Value.DayOfWeek) + { + AddIssueIfMissing( + candidate.Issues, + new ScheduleIssue( + ScheduleIssueCode.WeekdayMismatch, + $"OCR 星期为“{candidate.RawWeekdayText}”,与日期对应的{WeekdayTextParser.ToChinese(date.Value.DayOfWeek)}不一致。", + nameof(ScheduleRow.Weekday), + candidate.SourceImageId)); + } + + AddCandidateEmptyRoleIssue(candidate, candidate.Role1, ScheduleIssueCode.EmptyRole1, nameof(ScheduleRow.Role1), "岗位一(队长)为空。"); + AddCandidateEmptyRoleIssue(candidate, candidate.Role2, ScheduleIssueCode.EmptyRole2, nameof(ScheduleRow.Role2), "岗位二为空。"); + AddCandidateEmptyRoleIssue(candidate, candidate.Role3, ScheduleIssueCode.EmptyRole3, nameof(ScheduleRow.Role3), "岗位三为空。"); + AddCandidateEmptyRoleIssue(candidate, candidate.Role4, ScheduleIssueCode.EmptyRole4, nameof(ScheduleRow.Role4), "岗位四为空。"); + } + + private static void AddCandidateEmptyRoleIssue( + ScheduleCandidate candidate, + string value, + ScheduleIssueCode code, + string propertyName, + string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + AddIssueIfMissing( + candidate.Issues, + new ScheduleIssue(code, message, propertyName, candidate.SourceImageId)); + } + } + + private static List AlignOcrRowsToCalendar( + int targetYear, + int targetMonth, + List candidates) + { + ImageSeries[] series = candidates + .GroupBy(static candidate => candidate.SourceImageId, StringComparer.OrdinalIgnoreCase) + .OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(static group => new ImageSeries( + group.Key, + group.OrderBy(static candidate => candidate.RowCenterY).ToList())) + .ToArray(); + if (series.Length != 4 || series.Any(static value => string.IsNullOrWhiteSpace(value.SourceImageId))) + { + return candidates; + } + + int daysInMonth = DateTime.DaysInMonth(targetYear, targetMonth); + List assignments = []; + foreach (int[] offsets in GetFourDaySeriesPermutations()) + { + bool countsMatch = true; + int score = 0; + for (int index = 0; index < series.Length; index++) + { + int expectedCount = ((daysInMonth - offsets[index]) / 4) + 1; + if (series[index].Candidates.Count != expectedCount) + { + countsMatch = false; + break; + } + + score += ScoreSeries( + targetYear, + targetMonth, + offsets[index], + series[index].Candidates); + } + + if (countsMatch) + { + assignments.Add(new CalendarAssignment(offsets, score)); + } + } + + CalendarAssignment[] ranked = assignments + .OrderByDescending(static assignment => assignment.Score) + .ToArray(); + if (ranked.Length == 0 || ranked[0].Score <= 0 || + (ranked.Length > 1 && ranked[0].Score == ranked[1].Score)) + { + return candidates; + } + + Dictionary calendarDates = []; + for (int seriesIndex = 0; seriesIndex < series.Length; seriesIndex++) + { + int firstDay = ranked[0].Offsets[seriesIndex]; + for (int rowIndex = 0; rowIndex < series[seriesIndex].Candidates.Count; rowIndex++) + { + calendarDates[series[seriesIndex].Candidates[rowIndex]] = + new DateOnly(targetYear, targetMonth, firstDay + rowIndex * 4); + } + } + + return candidates.Select(candidate => + { + if (!calendarDates.TryGetValue(candidate, out DateOnly calendarDate)) + { + return candidate; + } + + ScheduleCandidate corrected = CloneCandidate(candidate); + corrected.RawDateText = calendarDate.ToString("M/d"); + corrected.RawWeekdayText = WeekdayTextParser.ToChinese(calendarDate.DayOfWeek); + return corrected; + }).ToList(); + } + + private static int ScoreSeries( + int targetYear, + int targetMonth, + int firstDay, + IReadOnlyList candidates) + { + int score = 0; + for (int index = 0; index < candidates.Count; index++) + { + DateOnly expected = new(targetYear, targetMonth, firstDay + index * 4); + ScheduleCandidate candidate = candidates[index]; + if (DateTextParser.TryCreateDate(targetYear, candidate.RawDateText, out DateOnly recognizedDate)) + { + if (recognizedDate.Month != targetMonth) + { + score -= 20; + } + else if (recognizedDate.Day == expected.Day) + { + score += 30; + } + else + { + score -= Math.Min(30, Math.Abs(recognizedDate.Day - expected.Day) * 5); + } + } + + if (WeekdayTextParser.TryParse(candidate.RawWeekdayText, out DayOfWeek recognizedWeekday)) + { + score += recognizedWeekday == expected.DayOfWeek ? 10 : -10; + } + } + + return score; + } + + private static IEnumerable GetFourDaySeriesPermutations() + { + for (int first = 1; first <= 4; first++) + { + for (int second = 1; second <= 4; second++) + { + for (int third = 1; third <= 4; third++) + { + for (int fourth = 1; fourth <= 4; fourth++) + { + int[] values = [first, second, third, fourth]; + if (values.Distinct().Count() == 4) + { + yield return values; + } + } + } + } + } + } + + private static ScheduleCandidate CloneCandidate(ScheduleCandidate source) + { + ScheduleCandidate clone = new() + { + RawDateText = source.RawDateText, + RawWeekdayText = source.RawWeekdayText, + Role1 = source.Role1, + Role2 = source.Role2, + Role3 = source.Role3, + Role4 = source.Role4, + SourceImageId = source.SourceImageId, + RowCenterY = source.RowCenterY, + }; + clone.Issues.AddRange(source.Issues); + clone.SourceTokens.AddRange(source.SourceTokens); + return clone; + } + + private static bool TryRecoverCompactOcrDate( + int targetYear, + int targetMonth, + ScheduleCandidate candidate, + out DateOnly date) + { + date = default; + string compact = DateTextParser.Normalize(candidate.RawDateText) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .TrimEnd('日', '号'); + string monthText = targetMonth.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!compact.StartsWith(monthText, StringComparison.Ordinal) || compact.Length <= monthText.Length) + { + return false; + } + + List alternatives = []; + AddCompactDateAlternative(compact[monthText.Length..], targetYear, targetMonth, alternatives); + + char possibleSeparator = compact[monthText.Length]; + if (possibleSeparator is '1' or 'I' or 'i' or 'l' or '|' or '!') + { + AddCompactDateAlternative(compact[(monthText.Length + 1)..], targetYear, targetMonth, alternatives); + } + + alternatives = alternatives.Distinct().ToList(); + if (alternatives.Count == 1) + { + date = alternatives[0]; + return true; + } + + if (alternatives.Count > 1 && + WeekdayTextParser.TryParse(candidate.RawWeekdayText, out DayOfWeek weekday)) + { + DateOnly[] weekdayMatches = alternatives + .Where(value => value.DayOfWeek == weekday) + .ToArray(); + if (weekdayMatches.Length == 1) + { + date = weekdayMatches[0]; + return true; + } + } + + return false; + } + + private static void AddCompactDateAlternative( + string dayText, + int targetYear, + int targetMonth, + List alternatives) + { + if (!int.TryParse( + dayText, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int day)) + { + return; + } + + try + { + alternatives.Add(new DateOnly(targetYear, targetMonth, day)); + } + catch (ArgumentOutOfRangeException) + { + // This interpretation is not a real calendar date. + } + } + + private static bool IsPersistentExtractionIssue(ScheduleIssueCode code) => code is + ScheduleIssueCode.DuplicateDate or + ScheduleIssueCode.AmbiguousRow or + ScheduleIssueCode.AmbiguousColumn or + ScheduleIssueCode.LowOcrConfidence or + ScheduleIssueCode.InsufficientColumns; + + private sealed record ImageSeries(string SourceImageId, List Candidates); + private sealed record CalendarAssignment(int[] Offsets, int Score); + private sealed record LocatedCandidate(ScheduleCandidate Candidate, DateOnly Date); +} diff --git a/RapidOCRSchedule.Core/TextParsers.cs b/RapidOCRSchedule.Core/TextParsers.cs new file mode 100644 index 0000000..bcc4deb --- /dev/null +++ b/RapidOCRSchedule.Core/TextParsers.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace RapidOCRSchedule.Core; + +public static partial class DateTextParser +{ + [GeneratedRegex(@"^\s*(?\d{1,2})\s*(?:[/\-.]|月)\s*(?\d{1,2})\s*(?:日|号)?\s*$", RegexOptions.CultureInvariant)] + private static partial Regex MonthDayRegex(); + + public static bool TryParseMonthDay(string? value, out int month, out int day) + { + month = 0; + day = 0; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string normalized = Normalize(value); + Match match = MonthDayRegex().Match(normalized); + if (!match.Success || + !int.TryParse(match.Groups["month"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out month) || + !int.TryParse(match.Groups["day"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out day) || + month is < 1 or > 12 || day is < 1 or > 31) + { + month = 0; + day = 0; + return false; + } + + return true; + } + + public static bool TryCreateDate(int year, string? value, out DateOnly date) + { + date = default; + if (!TryParseMonthDay(value, out int month, out int day)) + { + return false; + } + + try + { + date = new DateOnly(year, month, day); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + public static string Normalize(string value) => + value.Normalize(NormalizationForm.FormKC) + .Replace('/', '/') + .Replace('-', '-') + .Replace('—', '-') + .Trim(); +} + +public static class WeekdayTextParser +{ + private static readonly IReadOnlyDictionary Values = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["星期一"] = DayOfWeek.Monday, + ["周一"] = DayOfWeek.Monday, + ["礼拜一"] = DayOfWeek.Monday, + ["星期二"] = DayOfWeek.Tuesday, + ["周二"] = DayOfWeek.Tuesday, + ["礼拜二"] = DayOfWeek.Tuesday, + ["星期三"] = DayOfWeek.Wednesday, + ["周三"] = DayOfWeek.Wednesday, + ["礼拜三"] = DayOfWeek.Wednesday, + ["星期四"] = DayOfWeek.Thursday, + ["周四"] = DayOfWeek.Thursday, + ["礼拜四"] = DayOfWeek.Thursday, + ["星期五"] = DayOfWeek.Friday, + ["周五"] = DayOfWeek.Friday, + ["礼拜五"] = DayOfWeek.Friday, + ["星期六"] = DayOfWeek.Saturday, + ["周六"] = DayOfWeek.Saturday, + ["礼拜六"] = DayOfWeek.Saturday, + ["星期日"] = DayOfWeek.Sunday, + ["星期天"] = DayOfWeek.Sunday, + ["周日"] = DayOfWeek.Sunday, + ["周天"] = DayOfWeek.Sunday, + ["礼拜日"] = DayOfWeek.Sunday, + ["礼拜天"] = DayOfWeek.Sunday, + }; + + public static bool TryParse(string? value, out DayOfWeek weekday) + { + weekday = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string normalized = value.Normalize(NormalizationForm.FormKC) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Trim(); + return Values.TryGetValue(normalized, out weekday); + } + + public static string ToChinese(DayOfWeek weekday) => weekday switch + { + DayOfWeek.Monday => "星期一", + DayOfWeek.Tuesday => "星期二", + DayOfWeek.Wednesday => "星期三", + DayOfWeek.Thursday => "星期四", + DayOfWeek.Friday => "星期五", + DayOfWeek.Saturday => "星期六", + DayOfWeek.Sunday => "星期日", + _ => throw new ArgumentOutOfRangeException(nameof(weekday)), + }; +} diff --git a/RapidOCRSchedule.Excel/ExportRow.cs b/RapidOCRSchedule.Excel/ExportRow.cs new file mode 100644 index 0000000..b474621 --- /dev/null +++ b/RapidOCRSchedule.Excel/ExportRow.cs @@ -0,0 +1,50 @@ +namespace RapidOCRSchedule.Excel; + +/// +/// Represents one row in an exported workday schedule. +/// +/// The calendar date for the row. +/// The weekday text displayed in Excel. +/// The name or text for position one (leader). +/// The name or text for position two. +/// The name or text for position three. +/// The name or text for position four. +public sealed record ExportRow( + DateOnly Date, + string? Weekday, + string? PositionOneLeader, + string? PositionTwo, + string? PositionThree, + string? PositionFour) +{ + /// + /// Creates an export row and derives its Chinese weekday text from . + /// + public ExportRow( + DateOnly date, + string? positionOneLeader, + string? positionTwo, + string? positionThree, + string? positionFour) + : this( + date, + GetChineseWeekday(date.DayOfWeek), + positionOneLeader, + positionTwo, + positionThree, + positionFour) + { + } + + private static string GetChineseWeekday(DayOfWeek dayOfWeek) => dayOfWeek switch + { + DayOfWeek.Monday => "星期一", + DayOfWeek.Tuesday => "星期二", + DayOfWeek.Wednesday => "星期三", + DayOfWeek.Thursday => "星期四", + DayOfWeek.Friday => "星期五", + DayOfWeek.Saturday => "星期六", + DayOfWeek.Sunday => "星期日", + _ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek)), + }; +} diff --git a/RapidOCRSchedule.Excel/RapidOCRSchedule.Excel.csproj b/RapidOCRSchedule.Excel/RapidOCRSchedule.Excel.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/RapidOCRSchedule.Excel/RapidOCRSchedule.Excel.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/RapidOCRSchedule.Excel/ScheduleExcelExporter.cs b/RapidOCRSchedule.Excel/ScheduleExcelExporter.cs new file mode 100644 index 0000000..37c686b --- /dev/null +++ b/RapidOCRSchedule.Excel/ScheduleExcelExporter.cs @@ -0,0 +1,588 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Xml; + +namespace RapidOCRSchedule.Excel; + +/// +/// Writes simplified schedules as Office Open XML workbooks. +/// +public static class ScheduleExcelExporter +{ + private const int MaximumDataRowCount = 1_048_575; + private const string SpreadsheetNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string PackageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + private const string OfficeRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private const string XmlNamespace = "http://www.w3.org/XML/1998/namespace"; + + private static readonly DateOnly MinimumExcelDate = new(1900, 1, 1); + + private static readonly string[] Headers = + [ + "日期", + "星期", + "岗位一(队长)", + "岗位二", + "岗位三", + "岗位四", + ]; + + /// + /// Exports to an XLSX file, replacing an existing file at the same path. + /// Rows are sorted by date; weekend rows are retained. + /// + /// Destination path, including the .xlsx file name. + /// Schedule rows to export. + public static void Export(string filePath, IEnumerable rows) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + IReadOnlyList preparedRows = PrepareRows(rows); + string destinationPath = Path.GetFullPath(filePath); + string? directory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + throw new DirectoryNotFoundException($"The destination directory does not exist: {directory}"); + } + + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(destinationPath)}.{Guid.NewGuid():N}.tmp"); + try + { + using (FileStream stream = new(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + WritePackage(stream, preparedRows, leaveOpen: true); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, destinationPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Exports to a writable stream as an XLSX workbook. + /// Rows are sorted by date; weekend rows are retained. + /// + /// Writable destination stream. + /// Schedule rows to export. + /// + /// to keep open after export; + /// otherwise, . + /// + public static void Export(Stream destination, IEnumerable rows, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(destination); + + if (!destination.CanWrite) + { + throw new ArgumentException("The destination stream must be writable.", nameof(destination)); + } + + if (destination.CanSeek) + { + destination.Position = 0; + destination.SetLength(0); + } + + IReadOnlyList preparedRows = PrepareRows(rows); + WritePackage(destination, preparedRows, leaveOpen); + } + + private static IReadOnlyList PrepareRows(IEnumerable rows) + { + ArgumentNullException.ThrowIfNull(rows); + + List result = []; + HashSet dates = []; + int sourceIndex = 0; + + foreach (ExportRow? row in rows) + { + if (row is null) + { + throw new ArgumentException($"Row at index {sourceIndex} is null.", nameof(rows)); + } + + sourceIndex++; + + if (row.Date < MinimumExcelDate) + { + throw new ArgumentOutOfRangeException( + nameof(rows), + row.Date, + $"Excel dates must be on or after {MinimumExcelDate:yyyy-MM-dd}."); + } + + if (!dates.Add(row.Date)) + { + throw new ArgumentException( + $"More than one row was supplied for {row.Date:yyyy-MM-dd}.", + nameof(rows)); + } + + ValidateCellText(row.Weekday, sourceIndex - 1, nameof(row.Weekday)); + ValidateCellText(row.PositionOneLeader, sourceIndex - 1, nameof(row.PositionOneLeader)); + ValidateCellText(row.PositionTwo, sourceIndex - 1, nameof(row.PositionTwo)); + ValidateCellText(row.PositionThree, sourceIndex - 1, nameof(row.PositionThree)); + ValidateCellText(row.PositionFour, sourceIndex - 1, nameof(row.PositionFour)); + + result.Add(row); + + if (result.Count > MaximumDataRowCount) + { + throw new ArgumentException( + $"An Excel worksheet can contain at most {MaximumDataRowCount:N0} data rows when a header row is present.", + nameof(rows)); + } + } + + result.Sort(static (left, right) => left.Date.CompareTo(right.Date)); + return result; + } + + private static void ValidateCellText(string? value, int rowIndex, string propertyName) + { + if (value is null) + { + return; + } + + if (value.Length > 32_767) + { + throw new ArgumentException( + $"{propertyName} at row index {rowIndex} exceeds Excel's 32,767-character cell limit.", + "rows"); + } + + try + { + XmlConvert.VerifyXmlChars(value); + } + catch (XmlException exception) + { + throw new ArgumentException( + $"{propertyName} at row index {rowIndex} contains a character that is not valid in XML.", + "rows", + exception); + } + } + + private static void WritePackage(Stream destination, IReadOnlyList rows, bool leaveOpen) + { + using ZipArchive archive = new(destination, ZipArchiveMode.Create, leaveOpen); + + WriteContentTypes(archive); + WriteRootRelationships(archive); + WriteWorkbook(archive); + WriteWorkbookRelationships(archive); + WriteStyles(archive); + WriteWorksheet(archive, rows); + } + + private static void WriteContentTypes(ZipArchive archive) + { + using XmlWriter writer = CreateXmlWriter(archive, "[Content_Types].xml"); + + writer.WriteStartDocument(); + writer.WriteStartElement("Types", "http://schemas.openxmlformats.org/package/2006/content-types"); + WriteContentTypeDefault(writer, "rels", "application/vnd.openxmlformats-package.relationships+xml"); + WriteContentTypeDefault(writer, "xml", "application/xml"); + WriteContentTypeOverride(writer, "/xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"); + WriteContentTypeOverride(writer, "/xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"); + WriteContentTypeOverride(writer, "/xl/styles.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteContentTypeDefault(XmlWriter writer, string extension, string contentType) + { + writer.WriteStartElement("Default"); + writer.WriteAttributeString("Extension", extension); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + private static void WriteContentTypeOverride(XmlWriter writer, string partName, string contentType) + { + writer.WriteStartElement("Override"); + writer.WriteAttributeString("PartName", partName); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + private static void WriteRootRelationships(ZipArchive archive) + { + using XmlWriter writer = CreateXmlWriter(archive, "_rels/.rels"); + + writer.WriteStartDocument(); + writer.WriteStartElement("Relationships", PackageRelationshipsNamespace); + WriteRelationship( + writer, + "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + "xl/workbook.xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteWorkbook(ZipArchive archive) + { + using XmlWriter writer = CreateXmlWriter(archive, "xl/workbook.xml"); + + writer.WriteStartDocument(); + writer.WriteStartElement("workbook", SpreadsheetNamespace); + writer.WriteAttributeString("xmlns", "r", null, OfficeRelationshipsNamespace); + writer.WriteStartElement("bookViews"); + writer.WriteStartElement("workbookView"); + writer.WriteAttributeString("activeTab", "0"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteStartElement("sheets"); + writer.WriteStartElement("sheet"); + writer.WriteAttributeString("name", "排班表"); + writer.WriteAttributeString("sheetId", "1"); + writer.WriteAttributeString("r", "id", OfficeRelationshipsNamespace, "rId1"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteWorkbookRelationships(ZipArchive archive) + { + using XmlWriter writer = CreateXmlWriter(archive, "xl/_rels/workbook.xml.rels"); + + writer.WriteStartDocument(); + writer.WriteStartElement("Relationships", PackageRelationshipsNamespace); + WriteRelationship( + writer, + "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet1.xml"); + WriteRelationship( + writer, + "rId2", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + "styles.xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteRelationship(XmlWriter writer, string id, string type, string target) + { + writer.WriteStartElement("Relationship"); + writer.WriteAttributeString("Id", id); + writer.WriteAttributeString("Type", type); + writer.WriteAttributeString("Target", target); + writer.WriteEndElement(); + } + + private static void WriteStyles(ZipArchive archive) + { + using XmlWriter writer = CreateXmlWriter(archive, "xl/styles.xml"); + + writer.WriteStartDocument(); + writer.WriteStartElement("styleSheet", SpreadsheetNamespace); + + writer.WriteStartElement("numFmts"); + writer.WriteAttributeString("count", "1"); + writer.WriteStartElement("numFmt"); + writer.WriteAttributeString("numFmtId", "164"); + writer.WriteAttributeString("formatCode", "m/d"); + writer.WriteEndElement(); + writer.WriteEndElement(); + + writer.WriteStartElement("fonts"); + writer.WriteAttributeString("count", "2"); + WriteFont(writer, bold: false); + WriteFont(writer, bold: true); + writer.WriteEndElement(); + + writer.WriteStartElement("fills"); + writer.WriteAttributeString("count", "2"); + WritePatternFill(writer, "none"); + WritePatternFill(writer, "gray125"); + writer.WriteEndElement(); + + writer.WriteStartElement("borders"); + writer.WriteAttributeString("count", "1"); + writer.WriteStartElement("border"); + writer.WriteElementString("left", string.Empty); + writer.WriteElementString("right", string.Empty); + writer.WriteElementString("top", string.Empty); + writer.WriteElementString("bottom", string.Empty); + writer.WriteElementString("diagonal", string.Empty); + writer.WriteEndElement(); + writer.WriteEndElement(); + + writer.WriteStartElement("cellStyleXfs"); + writer.WriteAttributeString("count", "1"); + WriteXf(writer, numberFormatId: 0, fontId: 0, applyNumberFormat: false, applyFont: false, center: false, includeXfId: false); + writer.WriteEndElement(); + + writer.WriteStartElement("cellXfs"); + writer.WriteAttributeString("count", "3"); + WriteXf(writer, numberFormatId: 0, fontId: 0, applyNumberFormat: false, applyFont: false, center: false); + WriteXf(writer, numberFormatId: 164, fontId: 0, applyNumberFormat: true, applyFont: false, center: false); + WriteXf(writer, numberFormatId: 0, fontId: 1, applyNumberFormat: false, applyFont: true, center: true); + writer.WriteEndElement(); + + writer.WriteStartElement("cellStyles"); + writer.WriteAttributeString("count", "1"); + writer.WriteStartElement("cellStyle"); + writer.WriteAttributeString("name", "Normal"); + writer.WriteAttributeString("xfId", "0"); + writer.WriteAttributeString("builtinId", "0"); + writer.WriteEndElement(); + writer.WriteEndElement(); + + writer.WriteStartElement("dxfs"); + writer.WriteAttributeString("count", "0"); + writer.WriteEndElement(); + writer.WriteStartElement("tableStyles"); + writer.WriteAttributeString("count", "0"); + writer.WriteAttributeString("defaultTableStyle", "TableStyleMedium2"); + writer.WriteAttributeString("defaultPivotStyle", "PivotStyleLight16"); + writer.WriteEndElement(); + + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteFont(XmlWriter writer, bool bold) + { + writer.WriteStartElement("font"); + if (bold) + { + writer.WriteElementString("b", string.Empty); + } + + writer.WriteStartElement("sz"); + writer.WriteAttributeString("val", "11"); + writer.WriteEndElement(); + writer.WriteStartElement("name"); + writer.WriteAttributeString("val", "Calibri"); + writer.WriteEndElement(); + writer.WriteStartElement("family"); + writer.WriteAttributeString("val", "2"); + writer.WriteEndElement(); + writer.WriteStartElement("scheme"); + writer.WriteAttributeString("val", "minor"); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WritePatternFill(XmlWriter writer, string patternType) + { + writer.WriteStartElement("fill"); + writer.WriteStartElement("patternFill"); + writer.WriteAttributeString("patternType", patternType); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteXf( + XmlWriter writer, + int numberFormatId, + int fontId, + bool applyNumberFormat, + bool applyFont, + bool center, + bool includeXfId = true) + { + writer.WriteStartElement("xf"); + writer.WriteAttributeString("numFmtId", numberFormatId.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("fontId", fontId.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("fillId", "0"); + writer.WriteAttributeString("borderId", "0"); + if (includeXfId) + { + writer.WriteAttributeString("xfId", "0"); + } + + if (applyNumberFormat) + { + writer.WriteAttributeString("applyNumberFormat", "1"); + } + + if (applyFont) + { + writer.WriteAttributeString("applyFont", "1"); + } + + if (center) + { + writer.WriteAttributeString("applyAlignment", "1"); + writer.WriteStartElement("alignment"); + writer.WriteAttributeString("horizontal", "center"); + writer.WriteAttributeString("vertical", "center"); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + } + + private static void WriteWorksheet(ZipArchive archive, IReadOnlyList rows) + { + using XmlWriter writer = CreateXmlWriter(archive, "xl/worksheets/sheet1.xml"); + + int lastRowNumber = rows.Count + 1; + + writer.WriteStartDocument(); + writer.WriteStartElement("worksheet", SpreadsheetNamespace); + writer.WriteStartElement("dimension"); + writer.WriteAttributeString("ref", $"A1:F{lastRowNumber}"); + writer.WriteEndElement(); + + writer.WriteStartElement("sheetViews"); + writer.WriteStartElement("sheetView"); + writer.WriteAttributeString("workbookViewId", "0"); + writer.WriteStartElement("pane"); + writer.WriteAttributeString("ySplit", "1"); + writer.WriteAttributeString("topLeftCell", "A2"); + writer.WriteAttributeString("activePane", "bottomLeft"); + writer.WriteAttributeString("state", "frozen"); + writer.WriteEndElement(); + writer.WriteStartElement("selection"); + writer.WriteAttributeString("pane", "bottomLeft"); + writer.WriteAttributeString("activeCell", "A2"); + writer.WriteAttributeString("sqref", "A2"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + + writer.WriteStartElement("sheetFormatPr"); + writer.WriteAttributeString("defaultRowHeight", "15"); + writer.WriteEndElement(); + WriteColumns(writer); + + writer.WriteStartElement("sheetData"); + WriteHeaderRow(writer); + for (int index = 0; index < rows.Count; index++) + { + WriteDataRow(writer, index + 2, rows[index]); + } + + writer.WriteEndElement(); + + writer.WriteStartElement("autoFilter"); + writer.WriteAttributeString("ref", $"A1:F{lastRowNumber}"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteColumns(XmlWriter writer) + { + writer.WriteStartElement("cols"); + WriteColumn(writer, 1, 12); + WriteColumn(writer, 2, 12); + WriteColumn(writer, 3, 18); + WriteColumn(writer, 4, 16); + WriteColumn(writer, 5, 16); + WriteColumn(writer, 6, 16); + writer.WriteEndElement(); + } + + private static void WriteColumn(XmlWriter writer, int index, int width) + { + writer.WriteStartElement("col"); + writer.WriteAttributeString("min", index.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("max", index.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("width", width.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("customWidth", "1"); + writer.WriteEndElement(); + } + + private static void WriteHeaderRow(XmlWriter writer) + { + writer.WriteStartElement("row"); + writer.WriteAttributeString("r", "1"); + writer.WriteAttributeString("ht", "22"); + writer.WriteAttributeString("customHeight", "1"); + + for (int columnIndex = 0; columnIndex < Headers.Length; columnIndex++) + { + WriteInlineStringCell(writer, $"{GetColumnName(columnIndex)}1", Headers[columnIndex], styleIndex: 2); + } + + writer.WriteEndElement(); + } + + private static void WriteDataRow(XmlWriter writer, int rowNumber, ExportRow row) + { + writer.WriteStartElement("row"); + writer.WriteAttributeString("r", rowNumber.ToString(CultureInfo.InvariantCulture)); + + WriteNumberCell(writer, $"A{rowNumber}", GetExcelSerialNumber(row.Date), styleIndex: 1); + WriteInlineStringCell(writer, $"B{rowNumber}", row.Weekday); + WriteInlineStringCell(writer, $"C{rowNumber}", row.PositionOneLeader); + WriteInlineStringCell(writer, $"D{rowNumber}", row.PositionTwo); + WriteInlineStringCell(writer, $"E{rowNumber}", row.PositionThree); + WriteInlineStringCell(writer, $"F{rowNumber}", row.PositionFour); + + writer.WriteEndElement(); + } + + private static void WriteNumberCell(XmlWriter writer, string reference, double value, int styleIndex) + { + writer.WriteStartElement("c"); + writer.WriteAttributeString("r", reference); + writer.WriteAttributeString("s", styleIndex.ToString(CultureInfo.InvariantCulture)); + writer.WriteElementString("v", value.ToString("0.##########", CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + } + + private static void WriteInlineStringCell(XmlWriter writer, string reference, string? value, int? styleIndex = null) + { + writer.WriteStartElement("c"); + writer.WriteAttributeString("r", reference); + writer.WriteAttributeString("t", "inlineStr"); + if (styleIndex.HasValue) + { + writer.WriteAttributeString("s", styleIndex.Value.ToString(CultureInfo.InvariantCulture)); + } + + writer.WriteStartElement("is"); + writer.WriteStartElement("t"); + writer.WriteAttributeString("xml", "space", XmlNamespace, "preserve"); + writer.WriteString(value ?? string.Empty); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static double GetExcelSerialNumber(DateOnly date) + { + double serialNumber = date.ToDateTime(TimeOnly.MinValue).ToOADate(); + + // OLE Automation and Excel differ before Excel's fictitious 1900-02-29. + return date < new DateOnly(1900, 3, 1) ? serialNumber - 1 : serialNumber; + } + + private static string GetColumnName(int zeroBasedIndex) => ((char)('A' + zeroBasedIndex)).ToString(); + + private static XmlWriter CreateXmlWriter(ZipArchive archive, string entryName) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + Stream stream = entry.Open(); + return XmlWriter.Create( + stream, + new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + CloseOutput = true, + Indent = false, + OmitXmlDeclaration = false, + }); + } +} diff --git a/RapidOCRSchedule.Tests/CoreScheduleTests.cs b/RapidOCRSchedule.Tests/CoreScheduleTests.cs new file mode 100644 index 0000000..581f71b --- /dev/null +++ b/RapidOCRSchedule.Tests/CoreScheduleTests.cs @@ -0,0 +1,718 @@ +using RapidOCRSchedule.Core; + +namespace RapidOCRSchedule.Tests; + +public sealed class CoreScheduleTests +{ + private const int TargetYear = 2026; + private const int TargetMonth = 8; + + [Theory] + [InlineData("8/6")] + [InlineData("8-6")] + [InlineData("8.6")] + [InlineData("8月6日")] + [InlineData("8/6")] + [InlineData("8-6")] + [InlineData("8月6日")] + public void DateParser_AcceptsChineseAndFullWidthMonthDay(string text) + { + Assert.True(DateTextParser.TryParseMonthDay(text, out int month, out int day)); + Assert.Equal(8, month); + Assert.Equal(6, day); + + Assert.True(DateTextParser.TryCreateDate(TargetYear, text, out DateOnly date)); + Assert.Equal(new DateOnly(TargetYear, 8, 6), date); + } + + [Theory] + [InlineData(2026, "")] + [InlineData(2026, "8/32")] + [InlineData(2026, "13月1日")] + [InlineData(2026, "2月30日")] + [InlineData(2025, "2月29日")] + public void DateParser_RejectsInvalidDates(int year, string text) + { + Assert.False(DateTextParser.TryCreateDate(year, text, out _)); + } + + [Theory] + [InlineData("星期一", DayOfWeek.Monday)] + [InlineData(" 周一 ", DayOfWeek.Monday)] + [InlineData("礼拜五", DayOfWeek.Friday)] + [InlineData("星期天", DayOfWeek.Sunday)] + [InlineData("周日", DayOfWeek.Sunday)] + public void WeekdayParser_AcceptsCommonChineseForms(string text, DayOfWeek expected) + { + Assert.True(WeekdayTextParser.TryParse(text, out DayOfWeek actual)); + Assert.Equal(expected, actual); + Assert.StartsWith("星期", WeekdayTextParser.ToChinese(actual), StringComparison.Ordinal); + } + + [Fact] + public void WeekdayParser_RejectsUnknownWeekday() + { + Assert.False(WeekdayTextParser.TryParse("礼拜八", out _)); + } + + [Fact] + public void Build_FourFourDaySeries_IsOrderIndependentSortedAndComplete() + { + ScheduleCandidate[] ordered = CreateFourDaySeries().ToArray(); + ScheduleCandidate[] shuffled = ordered + .OrderBy(static candidate => StableShuffleKey(candidate.RawDateText)) + .ToArray(); + ScheduleService service = new(); + + ScheduleBuildResult first = service.Build(TargetYear, TargetMonth, ordered); + ScheduleBuildResult second = service.Build(TargetYear, TargetMonth, shuffled); + + DateOnly[] expectedDates = DatesInTargetMonth().ToArray(); + Assert.Equal(expectedDates, first.Rows.Select(static row => row.Date!.Value)); + Assert.Equal(expectedDates, second.Rows.Select(static row => row.Date!.Value)); + Assert.Equal( + first.Rows.Select(static row => (row.Date, row.Role1, row.Role2, row.Role3, row.Role4)), + second.Rows.Select(static row => (row.Date, row.Role1, row.Role2, row.Role3, row.Role4))); + Assert.All(first.Rows, static row => Assert.False(row.IsMissingPlaceholder)); + Assert.Empty(first.UnlocatedCandidates); + } + + [Fact] + public void Build_WhenWeekendIgnoringIsDisabled_IncludesWeekendCandidatesInOutput() + { + ScheduleBuildResult result = new ScheduleService().Build( + TargetYear, + TargetMonth, + CreateFourDaySeries(), + ignoreWeekends: false); + + Assert.Contains( + result.Rows, + static row => row.Date!.Value.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + Assert.Equal(31, result.Rows.Count); + Assert.Empty(result.WeekendCandidates); + } + + [Fact] + public void Build_WhenWeekendIgnoringIsEnabled_ExcludesWeekendRowsAndCapturesCandidates() + { + ScheduleBuildResult result = new ScheduleService().Build( + TargetYear, + TargetMonth, + CreateFourDaySeries(), + ignoreWeekends: true); + + Assert.DoesNotContain( + result.Rows, + static row => row.Date!.Value.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + Assert.Equal(21, result.Rows.Count); + Assert.Equal(10, result.WeekendCandidates.Count); + } + + [Fact] + public void Build_CreatesPlaceholderForEveryMissingWorkday() + { + ScheduleCandidate[] candidates = CreateFourDaySeries() + .Where(static candidate => candidate.RawDateText != "8/5") + .ToArray(); + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, candidates); + ScheduleRow missing = Assert.Single( + result.Rows, + static row => row.Date == new DateOnly(TargetYear, TargetMonth, 5)); + + Assert.True(missing.IsMissingPlaceholder); + Assert.Contains(missing.Issues, static issue => issue.Code == ScheduleIssueCode.MissingWorkday); + } + + [Fact] + public void Build_RecoversSlashReadAsOneUsingRecognizedWeekday() + { + ScheduleCandidate candidate = CreateCandidate(7, "3.png", role1: "陆俊杰"); + candidate.RawDateText = "817"; + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + ScheduleRow row = Assert.Single( + result.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 7)); + + Assert.Equal("8/7", candidate.RawDateText); + Assert.Equal("陆俊杰", row.Role1); + Assert.False(row.IsMissingPlaceholder); + Assert.Empty(result.UnlocatedCandidates); + } + + [Fact] + public void Build_DoesNotGuessAmbiguousCompactDateWithoutRecognizedWeekday() + { + ScheduleCandidate candidate = CreateCandidate(7); + candidate.RawDateText = "817"; + candidate.RawWeekdayText = "星期"; + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + + Assert.Single(result.UnlocatedCandidates); + Assert.Equal("817", candidate.RawDateText); + } + + [Fact] + public void Build_RecoversCompactDateWhenOnlyOneInterpretationIsValid() + { + ScheduleCandidate candidate = CreateCandidate(3); + candidate.RawDateText = "83"; + candidate.RawWeekdayText = "星期"; + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + ScheduleRow row = Assert.Single( + result.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 3)); + + Assert.Equal("8/3", candidate.RawDateText); + Assert.False(row.IsMissingPlaceholder); + Assert.Empty(result.UnlocatedCandidates); + } + + [Fact] + public void Build_OcrCalendarAlignment_CorrectsRecognizedDatesAndWeekdaysFromTargetMonth() + { + List candidates = CreateFourDaySeries().ToList(); + ScheduleCandidate augustThird = Assert.Single( + candidates, + static candidate => candidate.RawDateText == "8/3"); + augustThird.RawDateText = "1"; + augustThird.RawWeekdayText = "8/2"; + + ScheduleCandidate augustSeventh = Assert.Single( + candidates, + static candidate => candidate.RawDateText == "8/7"); + augustSeventh.RawDateText = "817"; + augustSeventh.RawWeekdayText = "星期"; + augustSeventh.Role1 = "陆俊杰"; + + ScheduleBuildResult result = new ScheduleService().Build( + TargetYear, + TargetMonth, + candidates.OrderBy(static candidate => StableShuffleKey($"{candidate.SourceImageId}:{candidate.RowCenterY}")), + alignOcrRowsToCalendar: true); + + Assert.Equal(31, result.Rows.Count); + Assert.Empty(result.UnlocatedCandidates); + Assert.DoesNotContain(result.Rows, static row => row.IsMissingPlaceholder); + Assert.All( + result.Rows, + static row => Assert.Equal( + WeekdayTextParser.ToChinese(row.Date!.Value.DayOfWeek), + row.Weekday)); + + ScheduleRow correctedThird = Assert.Single( + result.Rows, + static row => row.Date == new DateOnly(TargetYear, TargetMonth, 3)); + ScheduleRow correctedSeventh = Assert.Single( + result.Rows, + static row => row.Date == new DateOnly(TargetYear, TargetMonth, 7)); + Assert.Equal("星期一", correctedThird.Weekday); + Assert.Equal("星期五", correctedSeventh.Weekday); + Assert.Equal("陆俊杰", correctedSeventh.Role1); + Assert.DoesNotContain( + correctedThird.Issues.Concat(correctedSeventh.Issues), + static issue => issue.Code is ScheduleIssueCode.InvalidDate or + ScheduleIssueCode.InvalidWeekday or + ScheduleIssueCode.WeekdayMismatch); + + // Raw OCR diagnostics remain unchanged; only the final schedule is corrected. + Assert.Equal("1", augustThird.RawDateText); + Assert.Equal("817", augustSeventh.RawDateText); + } + + [Fact] + public void Build_OcrCalendarAlignment_RecomputesWeekdaysWhenTargetYearChanges() + { + const int changedYear = 2027; + ScheduleBuildResult result = new ScheduleService().Build( + changedYear, + TargetMonth, + CreateFourDaySeries(), + alignOcrRowsToCalendar: true); + + ScheduleRow augustThird = Assert.Single( + result.Rows, + static row => row.Date == new DateOnly(changedYear, TargetMonth, 3)); + Assert.Equal("星期二", augustThird.Weekday); + Assert.DoesNotContain(result.Rows, static row => row.IsMissingPlaceholder); + } + + [Fact] + public void Build_OcrCalendarAlignment_DoesNotGuessWhenImageSeriesAreAmbiguous() + { + ScheduleCandidate[] candidates = CreateFourDaySeries().ToArray(); + foreach (ScheduleCandidate candidate in candidates) + { + candidate.RawDateText = string.Empty; + candidate.RawWeekdayText = string.Empty; + } + + ScheduleBuildResult result = new ScheduleService().Build( + TargetYear, + TargetMonth, + candidates, + alignOcrRowsToCalendar: true); + + Assert.Equal(candidates.Length, result.UnlocatedCandidates.Count); + Assert.All(result.Rows, static row => Assert.True(row.IsMissingPlaceholder)); + } + + [Fact] + public void Build_FlagsDuplicateDateAndKeepsOneOutputRow() + { + List candidates = CreateFourDaySeries().ToList(); + candidates.Add(CreateCandidate(3, sourceImageId: "duplicate.png", role1: "另一位队长")); + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, candidates); + ScheduleRow row = Assert.Single( + result.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 3)); + + Assert.Contains(row.Issues, static issue => issue.Code == ScheduleIssueCode.DuplicateDate); + Assert.Contains("duplicate.png", row.SourceImageId, StringComparison.Ordinal); + Assert.Equal(2, result.DuplicateCandidates.Count); + } + + [Fact] + public void Build_FlagsMultipleMonthsAndKeepsOutOfMonthCandidateForReview() + { + ScheduleCandidate august = CreateCandidate(3); + ScheduleCandidate september = CreateCandidate(1); + september.RawDateText = "9/1"; + + ScheduleBuildResult result = new ScheduleService().Build( + TargetYear, + TargetMonth, + [august, september]); + + Assert.Contains(result.Issues, static issue => issue.Code == ScheduleIssueCode.MultipleMonths); + Assert.Contains(september, result.UnlocatedCandidates); + Assert.Contains(september.Issues, static issue => issue.Code == ScheduleIssueCode.OutOfTargetMonth); + } + + [Fact] + public void Build_WhenTargetMonthChanges_DoesNotKeepStaleOutOfMonthIssue() + { + ScheduleCandidate september = CreateCandidate(1); + september.RawDateText = "9/1"; + september.RawWeekdayText = WeekdayTextParser.ToChinese(new DateOnly(TargetYear, 9, 1).DayOfWeek); + ScheduleService service = new(); + + service.Build(TargetYear, 8, [september]); + Assert.Contains(september.Issues, static issue => issue.Code == ScheduleIssueCode.OutOfTargetMonth); + + ScheduleBuildResult septemberResult = service.Build(TargetYear, 9, [september]); + ScheduleRow row = Assert.Single( + septemberResult.Rows, + static value => value.Date == new DateOnly(TargetYear, 9, 1)); + Assert.DoesNotContain(row.Issues, static issue => issue.Code == ScheduleIssueCode.OutOfTargetMonth); + Assert.DoesNotContain(september.Issues, static issue => issue.Code == ScheduleIssueCode.OutOfTargetMonth); + } + + [Fact] + public void Build_FlagsWeekdayMismatch() + { + ScheduleCandidate candidate = CreateCandidate(3); + candidate.RawWeekdayText = "星期二"; // 2026-08-03 is Monday. + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + ScheduleRow row = Assert.Single( + result.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 3)); + + Assert.Contains(row.Issues, static issue => issue.Code == ScheduleIssueCode.WeekdayMismatch); + } + + [Fact] + public void Build_FlagsEachEmptyRole() + { + ScheduleCandidate candidate = CreateCandidate(3); + candidate.Role1 = ""; + candidate.Role2 = " "; + candidate.Role3 = ""; + candidate.Role4 = ""; + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + ScheduleRow row = Assert.Single( + result.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 3)); + + ScheduleIssueCode[] expected = + [ + ScheduleIssueCode.EmptyRole1, + ScheduleIssueCode.EmptyRole2, + ScheduleIssueCode.EmptyRole3, + ScheduleIssueCode.EmptyRole4, + ]; + Assert.All(expected, code => Assert.Contains(row.Issues, issue => issue.Code == code)); + } + + [Fact] + public void Build_InvalidDateCandidate_AlsoReportsInvalidWeekdayAndEmptyRoles() + { + ScheduleCandidate candidate = new() + { + RawDateText = "8/32", + RawWeekdayText = "不清楚", + SourceImageId = "damaged.png", + }; + + ScheduleBuildResult result = new ScheduleService().Build(TargetYear, TargetMonth, [candidate]); + ScheduleCandidate unlocated = Assert.Single(result.UnlocatedCandidates); + ScheduleIssueCode[] expected = + [ + ScheduleIssueCode.InvalidDate, + ScheduleIssueCode.InvalidWeekday, + ScheduleIssueCode.EmptyRole1, + ScheduleIssueCode.EmptyRole2, + ScheduleIssueCode.EmptyRole3, + ScheduleIssueCode.EmptyRole4, + ]; + + Assert.All(expected, code => Assert.Contains(unlocated.Issues, issue => issue.Code == code)); + } + + [Fact] + public void Revalidate_PreservesPersistentOcrAndDuplicateFindings() + { + ScheduleCandidate first = CreateCandidate(3, "first.png"); + first.Issues.Add(new ScheduleIssue(ScheduleIssueCode.LowOcrConfidence, "低置信度")); + ScheduleCandidate duplicate = CreateCandidate(3, "second.png"); + ScheduleService service = new(); + + ScheduleBuildResult initial = service.Build(TargetYear, TargetMonth, [first, duplicate]); + ScheduleBuildResult revalidated = service.Revalidate(TargetYear, TargetMonth, initial.Rows); + ScheduleRow row = Assert.Single( + revalidated.Rows, + static value => value.Date == new DateOnly(TargetYear, TargetMonth, 3)); + + Assert.Contains(row.Issues, static issue => issue.Code == ScheduleIssueCode.LowOcrConfidence); + Assert.Contains(row.Issues, static issue => issue.Code == ScheduleIssueCode.DuplicateDate); + } + + [Fact] + public void Revalidate_WhenUnlocatedRowIsIncluded_KeepsCrossMonthFinding() + { + ScheduleRow crossMonth = new() + { + RawDateText = "9/1", + Weekday = "星期二", + Role1 = "甲", + Role2 = "乙", + Role3 = "丙", + Role4 = "丁", + SourceImageId = "september.png", + }; + + ScheduleBuildResult result = new ScheduleService().Revalidate( + TargetYear, + TargetMonth, + [crossMonth]); + + ScheduleCandidate unlocated = Assert.Single(result.UnlocatedCandidates); + Assert.Contains(unlocated.Issues, static issue => issue.Code == ScheduleIssueCode.OutOfTargetMonth); + } + + [Fact] + public void Revalidate_BuildResult_RetainsUnlocatedAndAllDuplicateCandidates() + { + ScheduleCandidate first = CreateCandidate(3, "first.png"); + ScheduleCandidate duplicate = CreateCandidate(3, "second.png"); + ScheduleCandidate september = CreateCandidate(1, "september.png"); + september.RawDateText = "9/1"; + ScheduleService service = new(); + ScheduleBuildResult initial = service.Build( + TargetYear, + TargetMonth, + [first, duplicate, september]); + + ScheduleBuildResult revalidated = service.Revalidate(TargetYear, TargetMonth, initial); + + Assert.Equal(2, revalidated.DuplicateCandidates.Count); + Assert.Single(revalidated.UnlocatedCandidates); + Assert.Contains(revalidated.Issues, static issue => issue.Code == ScheduleIssueCode.MultipleMonths); + } + + [Fact] + public void Extract_UnorderedEightColumnTokens_UsesFirstFourAndLastTwoColumns() + { + OcrToken[] tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false).ToArray(); + OcrToken[] shuffled = tokens + .OrderBy(static token => StableShuffleKey($"{token.Text}:{token.Bounds.Left}:{token.Bounds.Top}")) + .ToArray(); + ScheduleExtractor extractor = new(); + + ScheduleExtractionResult ordered = extractor.Extract(tokens); + ScheduleExtractionResult unordered = extractor.Extract(shuffled); + + AssertCandidateTablesEqual(ordered.Candidates, unordered.Candidates); + ScheduleCandidate first = Assert.Single( + unordered.Candidates, + static candidate => candidate.RawDateText == "8/3"); + Assert.Equal("星期一", first.RawWeekdayText); + Assert.Equal("队长3", first.Role1); + Assert.Equal("岗位二3", first.Role2); + Assert.Equal("右一3", first.Role3); + Assert.Equal("右二3", first.Role4); + Assert.DoesNotContain("忽略", first.Role3, StringComparison.Ordinal); + Assert.DoesNotContain("忽略", first.Role4, StringComparison.Ordinal); + } + + [Fact] + public void Extract_MultipleTokensInOneCell_JoinsByHorizontalPosition() + { + TableLayoutProfile profile = new() { CellTextSeparator = string.Empty }; + OcrToken[] shuffled = CreateOcrTableTokens(splitRoleOneOnFirstRow: true) + .OrderByDescending(static token => token.Bounds.Left) + .ToArray(); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract(shuffled, profile); + ScheduleCandidate first = Assert.Single( + result.Candidates, + static candidate => candidate.RawDateText == "8/3"); + + Assert.Equal("张三", first.Role1); + } + + [Fact] + public void Extract_TargetColumnPopulatedOnOnlyOneRow_DoesNotShiftLaterColumns() + { + OcrToken[] sparse = CreateOcrTableTokens(splitRoleOneOnFirstRow: false) + .Where(static token => !token.Text.StartsWith("右一", StringComparison.Ordinal) || token.Text == "右一3") + .ToArray(); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract(sparse); + ScheduleCandidate first = Assert.Single(result.Candidates, static candidate => candidate.RawDateText == "8/3"); + ScheduleCandidate second = Assert.Single(result.Candidates, static candidate => candidate.RawDateText == "8/4"); + + Assert.Equal("右一3", first.Role3); + Assert.Equal("右二3", first.Role4); + Assert.Equal(string.Empty, second.Role3); + Assert.Equal("右二4", second.Role4); + } + + [Fact] + public void Extract_RowWithMissingDateAndOneKnownTargetCell_RemainsEditableCandidate() + { + OcrToken[] tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false) + .Where(static token => + Math.Abs(token.Bounds.CenterY - 0.30F) > 0.001F || + token.Text == "队长4") + .ToArray(); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract(tokens); + ScheduleCandidate candidate = Assert.Single( + result.Candidates, + static value => value.Role1 == "队长4"); + + Assert.Equal(string.Empty, candidate.RawDateText); + Assert.Equal(string.Empty, candidate.Role2); + Assert.Contains(candidate.Issues, static issue => issue.Code == ScheduleIssueCode.InvalidDate); + } + + [Fact] + public void Extract_HandDrawnLikeSingletonNoise_DoesNotBecomeOrShiftTargetColumn() + { + List tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false).ToList(); + tokens.Add(Token("table.png", "圈", 0.71F, 0.20F, width: 0.006F)); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract(tokens); + ScheduleCandidate first = Assert.Single(result.Candidates, static candidate => candidate.RawDateText == "8/3"); + + Assert.Equal("右一3", first.Role3); + Assert.Equal("右二3", first.Role4); + Assert.Contains(result.UnassignedTokens, static token => token.Text == "圈"); + } + + [Fact] + public void Extract_CellSplitBeyondClusteringTolerance_RejoinsWithoutShiftingColumns() + { + List tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false) + .Where(static token => token.Text != "队长3") + .ToList(); + tokens.Add(Token("table.png", "张", 0.255F, 0.20F, width: 0.006F)); + tokens.Add(Token("table.png", "三", 0.305F, 0.20F, width: 0.006F)); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract( + tokens, + new TableLayoutProfile { CellTextSeparator = string.Empty }); + ScheduleCandidate first = Assert.Single(result.Candidates, static candidate => candidate.RawDateText == "8/3"); + + Assert.Equal("张三", first.Role1); + Assert.Equal("岗位二3", first.Role2); + Assert.Equal("右一3", first.Role3); + Assert.Equal("右二3", first.Role4); + } + + [Fact] + public void Extract_DateSplitIntoThreeTokens_StillAnchorsAndParsesRow() + { + List tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false) + .Where(static token => token.Text != "8/3") + .ToList(); + tokens.Add(Token("table.png", "8", 0.052F, 0.20F, width: 0.005F)); + tokens.Add(Token("table.png", "/", 0.060F, 0.20F, width: 0.003F)); + tokens.Add(Token("table.png", "3", 0.068F, 0.20F, width: 0.005F)); + + ScheduleExtractionResult result = new ScheduleExtractor().Extract(tokens); + ScheduleCandidate first = Assert.Single(result.Candidates, static candidate => candidate.RawDateText == "8/3"); + + Assert.Equal("队长3", first.Role1); + Assert.DoesNotContain(first.Issues, static issue => issue.Code == ScheduleIssueCode.InvalidDate); + } + + [Fact] + public void Extract_ExcelColumnLabelsAndRowNumbers_AreIgnoredWithoutShiftingDataColumns() + { + List tokens = CreateOcrTableTokens(splitRoleOneOnFirstRow: false).ToList(); + float[] centers = [0.06F, 0.17F, 0.28F, 0.39F, 0.52F, 0.64F, 0.78F, 0.91F]; + string[] labels = ["A", "B", "C", "D", "E", "F", "G", "H"]; + for (int index = 0; index < labels.Length; index++) + { + tokens.Add(Token("table.png", labels[index], centers[index], 0.05F)); + } + + for (int row = 0; row < 3; row++) + { + tokens.Add(Token("table.png", (row + 1).ToString(), 0.01F, 0.20F + row * 0.10F)); + } + + ScheduleExtractionResult result = new ScheduleExtractor().Extract( + tokens.OrderBy(static token => StableShuffleKey($"{token.Text}:{token.Bounds.Left}:{token.Bounds.Top}"))); + + Assert.Equal(3, result.Candidates.Count); + ScheduleCandidate first = Assert.Single( + result.Candidates, + static candidate => candidate.RawDateText == "8/3"); + Assert.Equal("星期一", first.RawWeekdayText); + Assert.Equal("队长3", first.Role1); + Assert.Equal("岗位二3", first.Role2); + Assert.Equal("右一3", first.Role3); + Assert.Equal("右二3", first.Role4); + Assert.DoesNotContain(result.Candidates, static candidate => candidate.RawDateText == "1"); + Assert.DoesNotContain(result.Candidates, static candidate => candidate.RawWeekdayText == "A"); + } + + private static IEnumerable CreateFourDaySeries() + { + for (int seriesOffset = 0; seriesOffset < 4; seriesOffset++) + { + for (int day = seriesOffset + 1; day <= 31; day += 4) + { + yield return CreateCandidate(day, $"source-{seriesOffset + 1}.png"); + } + } + } + + private static ScheduleCandidate CreateCandidate( + int day, + string sourceImageId = "source.png", + string? role1 = null) + { + DateOnly date = new(TargetYear, TargetMonth, day); + return new ScheduleCandidate + { + RawDateText = $"{TargetMonth}/{day}", + RawWeekdayText = WeekdayTextParser.ToChinese(date.DayOfWeek), + Role1 = role1 ?? $"队长{day}", + Role2 = $"岗位二{day}", + Role3 = $"岗位三{day}", + Role4 = $"岗位四{day}", + SourceImageId = sourceImageId, + RowCenterY = day / 100F, + }; + } + + private static IEnumerable DatesInTargetMonth() + { + int days = DateTime.DaysInMonth(TargetYear, TargetMonth); + for (int day = 1; day <= days; day++) + { + yield return new DateOnly(TargetYear, TargetMonth, day); + } + } + + private static IEnumerable CreateOcrTableTokens(bool splitRoleOneOnFirstRow) + { + string source = "table.png"; + float[] centers = [0.06F, 0.17F, 0.28F, 0.39F, 0.52F, 0.64F, 0.78F, 0.91F]; + int[] days = [3, 4, 5]; + for (int row = 0; row < days.Length; row++) + { + int day = days[row]; + float y = 0.20F + row * 0.10F; + yield return Token(source, $"8/{day}", centers[0], y); + yield return Token(source, WeekdayTextParser.ToChinese(new DateOnly(TargetYear, TargetMonth, day).DayOfWeek), centers[1], y); + + if (row == 0 && splitRoleOneOnFirstRow) + { + yield return Token(source, "张", centers[2] - 0.004F, y, width: 0.006F); + yield return Token(source, "三", centers[2] + 0.004F, y, width: 0.006F); + } + else + { + yield return Token(source, $"队长{day}", centers[2], y); + } + + yield return Token(source, $"岗位二{day}", centers[3], y); + yield return Token(source, $"忽略甲{day}", centers[4], y); + yield return Token(source, $"忽略乙{day}", centers[5], y); + yield return Token(source, $"右一{day}", centers[6], y); + yield return Token(source, $"右二{day}", centers[7], y); + } + } + + private static OcrToken Token( + string source, + string text, + float centerX, + float centerY, + float width = 0.02F, + float height = 0.02F) => + new( + source, + text, + OcrBounds.FromLTRB( + centerX - width / 2F, + centerY - height / 2F, + centerX + width / 2F, + centerY + height / 2F), + 0.99F); + + private static int StableShuffleKey(string value) + { + unchecked + { + int hash = 17; + foreach (char character in value) + { + hash = hash * 31 + character; + } + + return hash; + } + } + + private static void AssertCandidateTablesEqual( + IEnumerable expected, + IEnumerable actual) + { + static IEnumerable<(string Date, string Weekday, string Role1, string Role2, string Role3, string Role4)> Project( + IEnumerable candidates) => + candidates + .OrderBy(static candidate => candidate.RawDateText, StringComparer.Ordinal) + .Select(static candidate => + (candidate.RawDateText, + candidate.RawWeekdayText, + candidate.Role1, + candidate.Role2, + candidate.Role3, + candidate.Role4)); + + Assert.Equal(Project(expected), Project(actual)); + } +} diff --git a/RapidOCRSchedule.Tests/EmployeeNameResolverTests.cs b/RapidOCRSchedule.Tests/EmployeeNameResolverTests.cs new file mode 100644 index 0000000..8f1a0c0 --- /dev/null +++ b/RapidOCRSchedule.Tests/EmployeeNameResolverTests.cs @@ -0,0 +1,75 @@ +using RapidOCRSchedule.Core; + +namespace RapidOCRSchedule.Tests; + +public sealed class EmployeeNameResolverTests +{ + [Fact] + public void Resolve_ConvertsTraditionalNameToConfiguredSimplifiedName() + { + EmployeeNameResolver resolver = new(["姚旭杰"]); + + string resolved = resolver.Resolve("姚旭傑"); + + Assert.Equal("姚旭杰", resolved); + } + + [Fact] + public void Resolve_UsesUniqueOneEditRosterMatch() + { + EmployeeNameResolver resolver = new(["姚旭杰"]); + + string resolved = resolver.Resolve("姚旭桀"); + + Assert.Equal("姚旭杰", resolved); + } + + [Fact] + public void Resolve_DoesNotGuessWhenOneEditMatchIsAmbiguous() + { + EmployeeNameResolver resolver = new(["姚旭杰", "姚旭洁"]); + + string resolved = resolver.Resolve("姚旭桀"); + + Assert.Equal("姚旭桀", resolved); + } + + [Fact] + public void Resolve_DoesNotFuzzyMatchTwoCharacterName() + { + EmployeeNameResolver resolver = new(["张三"]); + + string resolved = resolver.Resolve("张山"); + + Assert.Equal("张山", resolved); + } + + [Fact] + public void Resolve_KeepsTraditionalCharactersWhenConversionIsDisabled() + { + EmployeeNameResolver resolver = new([], convertTraditionalToSimplified: false); + + string resolved = resolver.Resolve("姚旭傑"); + + Assert.Equal("姚旭傑", resolved); + } + + [Fact] + public void NormalizeEmployeeNames_SimplifiesTrimsAndRemovesDuplicates() + { + IReadOnlyList names = EmployeeNameResolver.NormalizeEmployeeNames( + [" 姚旭傑 ", "姚旭杰", "", " 张三 "]); + + Assert.Equal(["姚旭杰", "张三"], names); + } + + [Fact] + public void NormalizeEmployeeNames_KeepsTraditionalCharactersWhenConversionIsDisabled() + { + IReadOnlyList names = EmployeeNameResolver.NormalizeEmployeeNames( + ["姚旭傑", "姚旭杰"], + convertTraditionalToSimplified: false); + + Assert.Equal(["姚旭傑", "姚旭杰"], names); + } +} diff --git a/RapidOCRSchedule.Tests/RapidOCRSchedule.Tests.csproj b/RapidOCRSchedule.Tests/RapidOCRSchedule.Tests.csproj new file mode 100644 index 0000000..e6920bd --- /dev/null +++ b/RapidOCRSchedule.Tests/RapidOCRSchedule.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/RapidOCRSchedule.Tests/ScheduleExcelExporterTests.cs b/RapidOCRSchedule.Tests/ScheduleExcelExporterTests.cs new file mode 100644 index 0000000..c4c6469 --- /dev/null +++ b/RapidOCRSchedule.Tests/ScheduleExcelExporterTests.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.IO.Compression; +using System.Xml.Linq; +using RapidOCRSchedule.Excel; + +namespace RapidOCRSchedule.Tests; + +public sealed class ScheduleExcelExporterTests +{ + private static readonly XNamespace SpreadsheetNamespace = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + [Fact] + public void Export_CreatesRequiredOoxmlPackageParts() + { + using MemoryStream destination = ExportToOpenStream(); + using ZipArchive archive = new(destination, ZipArchiveMode.Read); + + string[] expectedParts = + [ + "[Content_Types].xml", + "_rels/.rels", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/workbook.xml", + "xl/worksheets/sheet1.xml", + ]; + + string[] actualParts = archive.Entries + .Select(entry => entry.FullName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + expectedParts.OrderBy(name => name, StringComparer.Ordinal), + actualParts); + } + + [Fact] + public void Export_WritesHeadersNumericDateWithMonthDayStyleAndSafeUnicodeText() + { + DateOnly date = new(2026, 8, 3); + ExportRow row = new( + date, + "星期一 & <早班>", + " 张三 & <队长> ", + "李四 \"二号\"", + "王五 / 研发", + "赵六 😀"); + + using MemoryStream destination = ExportToOpenStream(row); + XDocument worksheet = LoadPart(destination, "xl/worksheets/sheet1.xml"); + XDocument styles = LoadPart(destination, "xl/styles.xml"); + + XElement headerRow = worksheet + .Descendants(SpreadsheetNamespace + "row") + .Single(element => (string?)element.Attribute("r") == "1"); + string[] headers = headerRow + .Elements(SpreadsheetNamespace + "c") + .Select(ReadInlineText) + .ToArray(); + + Assert.Equal( + ["日期", "星期", "岗位一(队长)", "岗位二", "岗位三", "岗位四"], + headers); + + XElement dateCell = GetCell(worksheet, "A2"); + Assert.Null(dateCell.Attribute("t")); + Assert.Equal("1", (string?)dateCell.Attribute("s")); + double serialNumber = double.Parse( + dateCell.Element(SpreadsheetNamespace + "v")!.Value, + CultureInfo.InvariantCulture); + Assert.Equal(date.ToDateTime(TimeOnly.MinValue).ToOADate(), serialNumber); + + XElement numberFormat = styles + .Descendants(SpreadsheetNamespace + "numFmt") + .Single(element => (string?)element.Attribute("numFmtId") == "164"); + Assert.Equal("m/d", (string?)numberFormat.Attribute("formatCode")); + + XElement[] cellFormats = styles.Root! + .Element(SpreadsheetNamespace + "cellXfs")! + .Elements(SpreadsheetNamespace + "xf") + .ToArray(); + XElement dateFormat = cellFormats[int.Parse((string)dateCell.Attribute("s")!, CultureInfo.InvariantCulture)]; + Assert.Equal("164", (string?)dateFormat.Attribute("numFmtId")); + Assert.Equal("1", (string?)dateFormat.Attribute("applyNumberFormat")); + + Assert.Equal("星期一 & <早班>", ReadInlineText(GetCell(worksheet, "B2"))); + Assert.Equal(" 张三 & <队长> ", ReadInlineText(GetCell(worksheet, "C2"))); + Assert.Equal("李四 \"二号\"", ReadInlineText(GetCell(worksheet, "D2"))); + Assert.Equal("王五 / 研发", ReadInlineText(GetCell(worksheet, "E2"))); + Assert.Equal("赵六 😀", ReadInlineText(GetCell(worksheet, "F2"))); + Assert.Equal( + "preserve", + (string?)GetCell(worksheet, "C2") + .Descendants(SpreadsheetNamespace + "t") + .Single() + .Attribute(XNamespace.Xml + "space")); + } + + [Fact] + public void Export_RetainsWeekendsAndSortsRowsByDate() + { + ExportRow[] rows = + [ + CreateRow(new DateOnly(2026, 8, 2), "Sunday"), + CreateRow(new DateOnly(2026, 8, 7), "Friday"), + CreateRow(new DateOnly(2026, 8, 1), "Saturday"), + CreateRow(new DateOnly(2026, 8, 4), "Tuesday"), + CreateRow(new DateOnly(2026, 8, 3), "Monday"), + ]; + + using MemoryStream destination = ExportToOpenStream(rows); + XDocument worksheet = LoadPart(destination, "xl/worksheets/sheet1.xml"); + XElement[] dataRows = worksheet + .Descendants(SpreadsheetNamespace + "row") + .Where(element => (string?)element.Attribute("r") != "1") + .ToArray(); + + DateOnly[] exportedDates = dataRows + .Select(row => ReadDate(row.Elements(SpreadsheetNamespace + "c").Single( + cell => ((string?)cell.Attribute("r"))?.StartsWith('A') == true))) + .ToArray(); + string[] exportedNames = dataRows + .Select(row => ReadInlineText(row.Elements(SpreadsheetNamespace + "c").Single( + cell => ((string?)cell.Attribute("r"))?.StartsWith('C') == true))) + .ToArray(); + + Assert.Equal( + [ + new DateOnly(2026, 8, 1), + new DateOnly(2026, 8, 2), + new DateOnly(2026, 8, 3), + new DateOnly(2026, 8, 4), + new DateOnly(2026, 8, 7), + ], + exportedDates); + Assert.Equal(["Saturday", "Sunday", "Monday", "Tuesday", "Friday"], exportedNames); + } + + [Fact] + public void Export_ThrowsWhenADateIsDuplicated() + { + DateOnly duplicateDate = new(2026, 8, 3); + ExportRow[] rows = + [ + CreateRow(duplicateDate, "first"), + CreateRow(duplicateDate, "second"), + ]; + using MemoryStream destination = new(); + + ArgumentException exception = Assert.Throws( + () => ScheduleExcelExporter.Export(destination, rows, leaveOpen: true)); + + Assert.Equal("rows", exception.ParamName); + Assert.Contains("2026-08-03", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Export_WithLeaveOpenTrue_KeepsDestinationUsable() + { + using MemoryStream destination = new(); + + ScheduleExcelExporter.Export(destination, Array.Empty(), leaveOpen: true); + + Assert.True(destination.CanWrite); + destination.Position = 0; + using ZipArchive archive = new(destination, ZipArchiveMode.Read, leaveOpen: true); + Assert.NotNull(archive.GetEntry("xl/worksheets/sheet1.xml")); + } + + [Fact] + public void Export_ToPrefilledSeekableStream_TruncatesOldContentAndCreatesValidPackage() + { + using MemoryStream destination = new(new byte[200_000], writable: true); + destination.Position = 0; + + ScheduleExcelExporter.Export(destination, [CreateRow(new DateOnly(2026, 8, 3), "张三")], leaveOpen: true); + + Assert.True(destination.Length < 200_000); + destination.Position = 0; + using ZipArchive archive = new(destination, ZipArchiveMode.Read, leaveOpen: true); + Assert.NotNull(archive.GetEntry("xl/worksheets/sheet1.xml")); + } + + [Fact] + public void Export_WritesCorrectExcelSerialsAcross1900LeapYearBoundary() + { + ExportRow[] rows = + [ + CreateRow(new DateOnly(1900, 3, 1), "March"), + CreateRow(new DateOnly(1900, 1, 1), "January"), + CreateRow(new DateOnly(1900, 2, 28), "February"), + ]; + + using MemoryStream destination = ExportToOpenStream(rows); + XDocument worksheet = LoadPart(destination, "xl/worksheets/sheet1.xml"); + + Assert.Equal("1", GetCell(worksheet, "A2").Element(SpreadsheetNamespace + "v")!.Value); + Assert.Equal("59", GetCell(worksheet, "A3").Element(SpreadsheetNamespace + "v")!.Value); + Assert.Equal("61", GetCell(worksheet, "A4").Element(SpreadsheetNamespace + "v")!.Value); + } + + [Fact] + public void Export_StyleXfOmitsCellFormatReference() + { + using MemoryStream destination = ExportToOpenStream(); + XDocument styles = LoadPart(destination, "xl/styles.xml"); + + XElement styleXf = styles.Root! + .Element(SpreadsheetNamespace + "cellStyleXfs")! + .Element(SpreadsheetNamespace + "xf")!; + Assert.Null(styleXf.Attribute("xfId")); + } + + [Fact] + public void Export_WithDefaultLeaveOpenFalse_DisposesDestination() + { + MemoryStream destination = new(); + + ScheduleExcelExporter.Export(destination, Array.Empty()); + + Assert.False(destination.CanWrite); + Assert.Throws(() => destination.WriteByte(0)); + } + + private static MemoryStream ExportToOpenStream(params ExportRow[] rows) + { + MemoryStream destination = new(); + ScheduleExcelExporter.Export(destination, rows, leaveOpen: true); + destination.Position = 0; + return destination; + } + + private static XDocument LoadPart(Stream workbook, string partName) + { + workbook.Position = 0; + using ZipArchive archive = new(workbook, ZipArchiveMode.Read, leaveOpen: true); + ZipArchiveEntry? entry = archive.GetEntry(partName); + Assert.NotNull(entry); + using Stream part = entry.Open(); + return XDocument.Load(part); + } + + private static XElement GetCell(XDocument worksheet, string reference) => + worksheet + .Descendants(SpreadsheetNamespace + "c") + .Single(element => (string?)element.Attribute("r") == reference); + + private static string ReadInlineText(XElement cell) => + cell + .Element(SpreadsheetNamespace + "is")! + .Element(SpreadsheetNamespace + "t")! + .Value; + + private static DateOnly ReadDate(XElement cell) + { + double serialNumber = double.Parse( + cell.Element(SpreadsheetNamespace + "v")!.Value, + CultureInfo.InvariantCulture); + return DateOnly.FromDateTime(DateTime.FromOADate(serialNumber)); + } + + private static ExportRow CreateRow(DateOnly date, string positionOne) => + new(date, positionOne, null, null, null); +} diff --git a/RapidOCRWinform/AppSettings.cs b/RapidOCRWinform/AppSettings.cs new file mode 100644 index 0000000..f89fe2b --- /dev/null +++ b/RapidOCRWinform/AppSettings.cs @@ -0,0 +1,56 @@ +using System.Text.Json; + +namespace RapidOCRWinform; + +internal sealed class AppSettings +{ + public int Role3Column { get; set; } = -2; + + public int Role4Column { get; set; } = -1; + + public bool ConvertTraditionalToSimplified { get; set; } = true; + + public bool IgnoreWeekends { get; set; } = true; + + public List EmployeeNames { get; set; } = []; + + public static AppSettings Load() + { + try + { + string path = GetSettingsPath(); + return File.Exists(path) + ? JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new AppSettings() + : new AppSettings(); + } + catch + { + return new AppSettings(); + } + } + + public void Save() + { + _ = TrySave(out _); + } + + public bool TrySave(out string? errorMessage) + { + try + { + string path = GetSettingsPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + errorMessage = null; + return true; + } + catch (Exception exception) + { + errorMessage = exception.Message; + return false; + } + } + + private static string GetSettingsPath() + => Path.Combine(AppContext.BaseDirectory, "config", "settings.json"); +} diff --git a/RapidOCRWinform/EmbeddedModelProvider.cs b/RapidOCRWinform/EmbeddedModelProvider.cs new file mode 100644 index 0000000..b580523 --- /dev/null +++ b/RapidOCRWinform/EmbeddedModelProvider.cs @@ -0,0 +1,181 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace RapidOCRWinform; + +/// +/// Materializes the OCR models embedded in the executable into a versioned user cache. +/// OcrLite/ONNX Runtime require file-system paths, so the embedded files cannot be used +/// directly as streams. The cache is an implementation detail and is not a deployment +/// dependency: a clean machine only needs the executable. +/// +internal static class EmbeddedModelProvider +{ + private const string ResourcePrefix = "RapidOCRWinform.EmbeddedModels."; + + private static readonly string[] ModelFileNames = + [ + "ch_PP-OCRv5_mobile_det.onnx", + "ch_ppocr_mobile_v2.0_cls_infer.onnx", + "ch_PP-OCRv5_rec_mobile_infer.onnx", + "ppocrv5_dict.txt", + ]; + + public static string EnsureExtracted() + { + Assembly assembly = typeof(EmbeddedModelProvider).Assembly; + ModelManifest manifest = ReadManifest(assembly); + List errors = []; + + foreach (string root in GetCandidateRoots()) + { + try + { + return EnsureCache(assembly, manifest, root); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new IOException($"无法写入模型缓存目录:{root}", exception)); + } + } + + throw new InvalidOperationException( + "用户数据目录和临时目录均不可写,请检查磁盘空间及目录权限。", + new AggregateException(errors)); + } + + private static ModelManifest ReadManifest(Assembly assembly) + { + using IncrementalHash manifestHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + List resources = []; + + foreach (string fileName in ModelFileNames) + { + string resourceName = ResourcePrefix + fileName; + using Stream stream = OpenResource(assembly, resourceName); + long length = stream.Length; + byte[] contentHash = SHA256.HashData(stream); + + manifestHash.AppendData(Encoding.UTF8.GetBytes(fileName)); + manifestHash.AppendData([0]); + manifestHash.AppendData(contentHash); + resources.Add(new ModelResource(fileName, resourceName, length, contentHash)); + } + + string version = Convert.ToHexString(manifestHash.GetHashAndReset()) + .ToLowerInvariant()[..16]; + return new ModelManifest(version, resources); + } + + private static IEnumerable GetCandidateRoots() + { + string localData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string? localCacheRoot = null; + if (!string.IsNullOrWhiteSpace(localData)) + { + localCacheRoot = Path.Combine(localData, "RapidOCRSchedule", "model-cache"); + yield return localCacheRoot; + } + + string tempRoot = Path.Combine(Path.GetTempPath(), "RapidOCRSchedule", "model-cache"); + if (localCacheRoot is null || !PathsEqual(tempRoot, localCacheRoot)) + { + yield return tempRoot; + } + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + + private static string EnsureCache(Assembly assembly, ModelManifest manifest, string root) + { + string cacheDirectory = Path.Combine(root, manifest.Version); + Directory.CreateDirectory(cacheDirectory); + + foreach (ModelResource resource in manifest.Resources) + { + string targetPath = Path.Combine(cacheDirectory, resource.FileName); + if (FileMatches(targetPath, resource)) + { + continue; + } + + string temporaryPath = targetPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (Stream source = OpenResource(assembly, resource.ResourceName)) + using (FileStream destination = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1024 * 1024, + FileOptions.SequentialScan)) + { + source.CopyTo(destination); + destination.Flush(flushToDisk: true); + } + + if (!FileMatches(temporaryPath, resource)) + { + throw new IOException($"内置模型释放后校验失败:{resource.FileName}"); + } + + // Another process may have completed the same cache while this process + // was extracting. Keep its valid file; otherwise replace atomically. + if (!FileMatches(targetPath, resource)) + { + File.Move(temporaryPath, targetPath, overwrite: true); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + return cacheDirectory; + } + + private static bool FileMatches(string path, ModelResource resource) + { + try + { + FileInfo file = new(path); + if (!file.Exists || file.Length != resource.Length) + { + return false; + } + + using FileStream stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + return CryptographicOperations.FixedTimeEquals(SHA256.HashData(stream), resource.ContentHash); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static Stream OpenResource(Assembly assembly, string resourceName) => + assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"程序缺少内置资源:{resourceName}"); + + private sealed record ModelManifest(string Version, IReadOnlyList Resources); + + private sealed record ModelResource( + string FileName, + string ResourceName, + long Length, + byte[] ContentHash); +} diff --git a/RapidOCRWinform/EmployeeRosterDialog.cs b/RapidOCRWinform/EmployeeRosterDialog.cs new file mode 100644 index 0000000..251fd8d --- /dev/null +++ b/RapidOCRWinform/EmployeeRosterDialog.cs @@ -0,0 +1,125 @@ +using RapidOCRSchedule.Core; + +namespace RapidOCRWinform; + +internal sealed class EmployeeRosterDialog : Form +{ + private readonly TextBox _namesTextBox = new(); + private readonly Label _countLabel = new(); + private readonly bool _convertTraditionalToSimplified; + + public EmployeeRosterDialog( + IEnumerable employeeNames, + bool convertTraditionalToSimplified) + { + _convertTraditionalToSimplified = convertTraditionalToSimplified; + EmployeeNames = EmployeeNameResolver.NormalizeEmployeeNames(employeeNames, convertTraditionalToSimplified); + InitializeDialog(); + _namesTextBox.Lines = EmployeeNames.ToArray(); + UpdateCount(); + } + + public IReadOnlyList EmployeeNames { get; private set; } + + private void InitializeDialog() + { + Text = "员工名单配置"; + StartPosition = FormStartPosition.CenterParent; + ClientSize = new Size(520, 520); + MinimumSize = new Size(440, 400); + Font = new Font("Microsoft YaHei UI", 9F); + ShowIcon = false; + ShowInTaskbar = false; + + TableLayoutPanel layout = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(16), + ColumnCount = 1, + RowCount = 4, + }; + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + Label instructions = new() + { + AutoSize = true, + Dock = DockStyle.Fill, + Margin = new Padding(0, 0, 0, 10), + Text = _convertTraditionalToSimplified + ? "每行填写一个员工姓名,当前会自动保存为简体。\n识别结果只有在名单中存在唯一匹配时才会自动纠正。" + : "每行填写一个员工姓名,当前会保留输入字形。\n识别结果只有在名单中存在唯一匹配时才会自动纠正。", + }; + + _namesTextBox.AcceptsReturn = true; + _namesTextBox.AcceptsTab = false; + _namesTextBox.Dock = DockStyle.Fill; + _namesTextBox.Multiline = true; + _namesTextBox.ScrollBars = ScrollBars.Vertical; + _namesTextBox.WordWrap = false; + _namesTextBox.Font = new Font("Microsoft YaHei UI", 11F); + _namesTextBox.TextChanged += (_, _) => UpdateCount(); + + _countLabel.AutoSize = true; + _countLabel.Dock = DockStyle.Fill; + _countLabel.ForeColor = Color.DimGray; + _countLabel.Margin = new Padding(0, 8, 0, 8); + + FlowLayoutPanel buttons = new() + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + WrapContents = false, + Margin = Padding.Empty, + }; + + Button saveButton = new() + { + Text = "保存", + Size = new Size(104, 36), + Margin = new Padding(8, 0, 0, 0), + }; + saveButton.Click += SaveButton_Click; + + Button cancelButton = new() + { + Text = "取消", + DialogResult = DialogResult.Cancel, + Size = new Size(104, 36), + Margin = Padding.Empty, + }; + + buttons.Controls.Add(saveButton); + buttons.Controls.Add(cancelButton); + layout.Controls.Add(instructions, 0, 0); + layout.Controls.Add(_namesTextBox, 0, 1); + layout.Controls.Add(_countLabel, 0, 2); + layout.Controls.Add(buttons, 0, 3); + Controls.Add(layout); + + AcceptButton = saveButton; + CancelButton = cancelButton; + } + + private void SaveButton_Click(object? sender, EventArgs e) + { + EmployeeNames = ParseEmployeeNames(); + DialogResult = DialogResult.OK; + Close(); + } + + private void UpdateCount() + { + int rawCount = _namesTextBox.Lines.Count(static line => !string.IsNullOrWhiteSpace(line)); + int normalizedCount = ParseEmployeeNames().Count; + _countLabel.Text = rawCount == normalizedCount + ? $"共 {normalizedCount} 人;保存后关闭软件仍会保留。" + : $"有效姓名 {normalizedCount} 人;已自动忽略空行或重复姓名。"; + } + + private IReadOnlyList ParseEmployeeNames() => + EmployeeNameResolver.NormalizeEmployeeNames(_namesTextBox.Lines, _convertTraditionalToSimplified); +} diff --git a/RapidOCRWinform/FormOcr.Designer.cs b/RapidOCRWinform/FormOcr.Designer.cs index 7f5b7fe..fbe5273 100644 --- a/RapidOCRWinform/FormOcr.Designer.cs +++ b/RapidOCRWinform/FormOcr.Designer.cs @@ -1,632 +1,794 @@ -namespace RapidOCRWinform +#nullable disable + +namespace RapidOCRWinform; + +partial class FormOcr { - partial class FormOcr - { - /// - /// 必需的设计器变量。 - /// - private System.ComponentModel.IContainer components = null; + private System.ComponentModel.IContainer components = null; - /// - /// 清理所有正在使用的资源。 - /// - /// 如果应释放托管资源,为 true;否则为 false。 - protected override void Dispose(bool disposing) + protected override void Dispose(bool disposing) + { + if (disposing) { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); + _ocrEngine?.Dispose(); + previewPictureBox.Image?.Dispose(); + components?.Dispose(); } - #region Windows 窗体设计器生成的代码 + base.Dispose(disposing); + } - /// - /// 设计器支持所需的方法 - 不要修改 - /// 使用代码编辑器修改此方法的内容。 - /// - private void InitializeComponent() - { - label8 = new Label(); - tableLayoutPanel2 = new TableLayoutPanel(); - modelsTextBox = new TextBox(); - label9 = new Label(); - modelsBtn = new Button(); - pathTextBox = new TextBox(); - openBtn = new Button(); - label1 = new Label(); - numThreadNumeric = new NumericUpDown(); - label6 = new Label(); - detNameTextBox = new TextBox(); - label10 = new Label(); - clsNameTextBox = new TextBox(); - label11 = new Label(); - recNameTextBox = new TextBox(); - label12 = new Label(); - keysNameTextBox = new TextBox(); - label5 = new Label(); - boxThreshNumeric = new NumericUpDown(); - label7 = new Label(); - unClipRatioNumeric = new NumericUpDown(); - doAngleCheckBox = new CheckBox(); - mostAngleCheckBox = new CheckBox(); - label4 = new Label(); - imgResizeNumeric = new NumericUpDown(); - label3 = new Label(); - paddingNumeric = new NumericUpDown(); - label2 = new Label(); - boxScoreThreshNumeric = new NumericUpDown(); - tableLayoutPanel3 = new TableLayoutPanel(); - strRestTextBox = new TextBox(); - tableLayoutPanel1 = new TableLayoutPanel(); - partImgCheckBox = new CheckBox(); - debugCheckBox = new CheckBox(); - panel1 = new Panel(); - pictureBox = new PictureBox(); - detectBtn = new Button(); - initBtn = new Button(); - ocrResultTextBox = new TextBox(); - tableLayoutPanel2.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)numThreadNumeric).BeginInit(); - ((System.ComponentModel.ISupportInitialize)boxThreshNumeric).BeginInit(); - ((System.ComponentModel.ISupportInitialize)unClipRatioNumeric).BeginInit(); - ((System.ComponentModel.ISupportInitialize)imgResizeNumeric).BeginInit(); - ((System.ComponentModel.ISupportInitialize)paddingNumeric).BeginInit(); - ((System.ComponentModel.ISupportInitialize)boxScoreThreshNumeric).BeginInit(); - tableLayoutPanel3.SuspendLayout(); - tableLayoutPanel1.SuspendLayout(); - panel1.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)pictureBox).BeginInit(); - SuspendLayout(); - // - // label8 - // - label8.AutoSize = true; - label8.Location = new Point(4, 306); - label8.Margin = new Padding(4, 0, 4, 0); - label8.Name = "label8"; - label8.Size = new Size(62, 20); - label8.TabIndex = 8; - label8.Text = "ImgFile"; - // - // tableLayoutPanel2 - // - tableLayoutPanel2.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - tableLayoutPanel2.ColumnCount = 3; - tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150F)); - tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); - tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 120F)); - tableLayoutPanel2.Controls.Add(modelsTextBox, 1, 0); - tableLayoutPanel2.Controls.Add(label9, 0, 0); - tableLayoutPanel2.Controls.Add(modelsBtn, 2, 0); - tableLayoutPanel2.Controls.Add(label8, 0, 6); - tableLayoutPanel2.Controls.Add(pathTextBox, 1, 6); - tableLayoutPanel2.Controls.Add(openBtn, 2, 6); - tableLayoutPanel2.Controls.Add(label1, 0, 5); - tableLayoutPanel2.Controls.Add(numThreadNumeric, 1, 5); - tableLayoutPanel2.Controls.Add(label6, 0, 1); - tableLayoutPanel2.Controls.Add(detNameTextBox, 1, 1); - tableLayoutPanel2.Controls.Add(label10, 0, 2); - tableLayoutPanel2.Controls.Add(clsNameTextBox, 1, 2); - tableLayoutPanel2.Controls.Add(label11, 0, 3); - tableLayoutPanel2.Controls.Add(recNameTextBox, 1, 3); - tableLayoutPanel2.Controls.Add(label12, 0, 4); - tableLayoutPanel2.Controls.Add(keysNameTextBox, 1, 4); - tableLayoutPanel2.Location = new Point(21, 20); - tableLayoutPanel2.Margin = new Padding(4, 5, 4, 5); - tableLayoutPanel2.Name = "tableLayoutPanel2"; - tableLayoutPanel2.RowCount = 7; - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel2.Size = new Size(1012, 352); - tableLayoutPanel2.TabIndex = 14; - // - // modelsTextBox - // - modelsTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - modelsTextBox.Location = new Point(154, 5); - modelsTextBox.Margin = new Padding(4, 5, 4, 5); - modelsTextBox.Name = "modelsTextBox"; - modelsTextBox.Size = new Size(734, 27); - modelsTextBox.TabIndex = 1; - // - // label9 - // - label9.AutoSize = true; - label9.Location = new Point(4, 0); - label9.Margin = new Padding(4, 0, 4, 0); - label9.Name = "label9"; - label9.Size = new Size(64, 20); - label9.TabIndex = 9; - label9.Text = "Models"; - // - // modelsBtn - // - modelsBtn.Location = new Point(896, 5); - modelsBtn.Margin = new Padding(4, 5, 4, 5); - modelsBtn.Name = "modelsBtn"; - modelsBtn.Size = new Size(111, 39); - modelsBtn.TabIndex = 2; - modelsBtn.Text = "Models"; - modelsBtn.UseVisualStyleBackColor = true; - modelsBtn.Click += modelsBtn_Click; - // - // pathTextBox - // - pathTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - pathTextBox.Location = new Point(154, 311); - pathTextBox.Margin = new Padding(4, 5, 4, 5); - pathTextBox.Name = "pathTextBox"; - pathTextBox.Size = new Size(734, 27); - pathTextBox.TabIndex = 6; - // - // openBtn - // - openBtn.Location = new Point(896, 311); - openBtn.Margin = new Padding(4, 5, 4, 5); - openBtn.Name = "openBtn"; - openBtn.Size = new Size(111, 39); - openBtn.TabIndex = 0; - openBtn.Text = "Open"; - openBtn.UseVisualStyleBackColor = true; - openBtn.Click += openBtn_Click; - // - // label1 - // - label1.AutoSize = true; - label1.Location = new Point(4, 255); - label1.Margin = new Padding(4, 0, 4, 0); - label1.Name = "label1"; - label1.Size = new Size(92, 20); - label1.TabIndex = 3; - label1.Text = "numThread"; - // - // numThreadNumeric - // - numThreadNumeric.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - numThreadNumeric.Location = new Point(154, 260); - numThreadNumeric.Margin = new Padding(4, 5, 4, 5); - numThreadNumeric.Maximum = new decimal(new int[] { 128, 0, 0, 0 }); - numThreadNumeric.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); - numThreadNumeric.Name = "numThreadNumeric"; - numThreadNumeric.Size = new Size(734, 27); - numThreadNumeric.TabIndex = 4; - numThreadNumeric.Value = new decimal(new int[] { 4, 0, 0, 0 }); - // - // label6 - // - label6.AutoSize = true; - label6.Location = new Point(4, 51); - label6.Margin = new Padding(4, 0, 4, 0); - label6.Name = "label6"; - label6.Size = new Size(34, 20); - label6.TabIndex = 11; - label6.Text = "det"; - // - // detNameTextBox - // - detNameTextBox.Location = new Point(154, 56); - detNameTextBox.Margin = new Padding(4, 5, 4, 5); - detNameTextBox.Name = "detNameTextBox"; - detNameTextBox.Size = new Size(732, 27); - detNameTextBox.TabIndex = 12; - detNameTextBox.Text = "ch_PP-OCRv5_mobile_det.onnx"; - // - // label10 - // - label10.AutoSize = true; - label10.Location = new Point(4, 102); - label10.Margin = new Padding(4, 0, 4, 0); - label10.Name = "label10"; - label10.Size = new Size(28, 20); - label10.TabIndex = 13; - label10.Text = "cls"; - // - // clsNameTextBox - // - clsNameTextBox.Location = new Point(154, 107); - clsNameTextBox.Margin = new Padding(4, 5, 4, 5); - clsNameTextBox.Name = "clsNameTextBox"; - clsNameTextBox.Size = new Size(732, 27); - clsNameTextBox.TabIndex = 14; - clsNameTextBox.Text = "ch_ppocr_mobile_v2.0_cls_infer.onnx"; - // - // label11 - // - label11.AutoSize = true; - label11.Location = new Point(4, 153); - label11.Margin = new Padding(4, 0, 4, 0); - label11.Name = "label11"; - label11.Size = new Size(32, 20); - label11.TabIndex = 15; - label11.Text = "rec"; - // - // recNameTextBox - // - recNameTextBox.Location = new Point(154, 158); - recNameTextBox.Margin = new Padding(4, 5, 4, 5); - recNameTextBox.Name = "recNameTextBox"; - recNameTextBox.Size = new Size(732, 27); - recNameTextBox.TabIndex = 16; - recNameTextBox.Text = "ch_PP-OCRv5_rec_mobile_infer.onnx"; - // - // label12 - // - label12.AutoSize = true; - label12.Location = new Point(4, 204); - label12.Margin = new Padding(4, 0, 4, 0); - label12.Name = "label12"; - label12.Size = new Size(41, 20); - label12.TabIndex = 17; - label12.Text = "keys"; - // - // keysNameTextBox - // - keysNameTextBox.Location = new Point(154, 209); - keysNameTextBox.Margin = new Padding(4, 5, 4, 5); - keysNameTextBox.Name = "keysNameTextBox"; - keysNameTextBox.Size = new Size(732, 27); - keysNameTextBox.TabIndex = 18; - keysNameTextBox.Text = "ppocrv5_dict.txt"; - // - // label5 - // - label5.AutoSize = true; - label5.Location = new Point(6, 161); - label5.Margin = new Padding(4, 0, 4, 0); - label5.Name = "label5"; - label5.Size = new Size(86, 20); - label5.TabIndex = 14; - label5.Text = "boxThresh"; - // - // boxThreshNumeric - // - boxThreshNumeric.DecimalPlaces = 3; - boxThreshNumeric.Increment = new decimal(new int[] { 1, 0, 0, 65536 }); - boxThreshNumeric.Location = new Point(128, 166); - boxThreshNumeric.Margin = new Padding(4, 5, 4, 5); - boxThreshNumeric.Maximum = new decimal(new int[] { 1, 0, 0, 0 }); - boxThreshNumeric.Minimum = new decimal(new int[] { 1, 0, 0, 131072 }); - boxThreshNumeric.Name = "boxThreshNumeric"; - boxThreshNumeric.Size = new Size(111, 27); - boxThreshNumeric.TabIndex = 15; - boxThreshNumeric.Value = new decimal(new int[] { 3, 0, 0, 65536 }); - // - // label7 - // - label7.AutoSize = true; - label7.Location = new Point(6, 214); - label7.Margin = new Padding(4, 0, 4, 0); - label7.Name = "label7"; - label7.Size = new Size(93, 20); - label7.TabIndex = 21; - label7.Text = "unClipRatio"; - // - // unClipRatioNumeric - // - unClipRatioNumeric.DecimalPlaces = 1; - unClipRatioNumeric.Increment = new decimal(new int[] { 1, 0, 0, 65536 }); - unClipRatioNumeric.Location = new Point(128, 219); - unClipRatioNumeric.Margin = new Padding(4, 5, 4, 5); - unClipRatioNumeric.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); - unClipRatioNumeric.Minimum = new decimal(new int[] { 1, 0, 0, 65536 }); - unClipRatioNumeric.Name = "unClipRatioNumeric"; - unClipRatioNumeric.Size = new Size(111, 27); - unClipRatioNumeric.TabIndex = 22; - unClipRatioNumeric.Value = new decimal(new int[] { 16, 0, 0, 65536 }); - // - // doAngleCheckBox - // - doAngleCheckBox.AutoSize = true; - doAngleCheckBox.Checked = true; - doAngleCheckBox.CheckState = CheckState.Checked; - doAngleCheckBox.Location = new Point(6, 272); - doAngleCheckBox.Margin = new Padding(4, 5, 4, 5); - doAngleCheckBox.Name = "doAngleCheckBox"; - doAngleCheckBox.Size = new Size(94, 24); - doAngleCheckBox.TabIndex = 26; - doAngleCheckBox.Text = "doAngle"; - doAngleCheckBox.UseVisualStyleBackColor = true; - // - // mostAngleCheckBox - // - mostAngleCheckBox.AutoSize = true; - mostAngleCheckBox.Location = new Point(128, 272); - mostAngleCheckBox.Margin = new Padding(4, 5, 4, 5); - mostAngleCheckBox.Name = "mostAngleCheckBox"; - mostAngleCheckBox.Size = new Size(111, 24); - mostAngleCheckBox.TabIndex = 27; - mostAngleCheckBox.Text = "mostAngle"; - mostAngleCheckBox.UseVisualStyleBackColor = true; - // - // label4 - // - label4.AutoSize = true; - label4.Location = new Point(6, 108); - label4.Margin = new Padding(4, 0, 4, 0); - label4.Name = "label4"; - label4.Size = new Size(112, 40); - label4.TabIndex = 12; - label4.Text = "boxScoreThresh"; - // - // imgResizeNumeric - // - imgResizeNumeric.Location = new Point(128, 60); - imgResizeNumeric.Margin = new Padding(4, 5, 4, 5); - imgResizeNumeric.Maximum = new decimal(new int[] { 99999, 0, 0, 0 }); - imgResizeNumeric.Name = "imgResizeNumeric"; - imgResizeNumeric.Size = new Size(111, 27); - imgResizeNumeric.TabIndex = 10; - imgResizeNumeric.Value = new decimal(new int[] { 1024, 0, 0, 0 }); - // - // label3 - // - label3.AutoSize = true; - label3.Location = new Point(6, 55); - label3.Margin = new Padding(4, 0, 4, 0); - label3.Name = "label3"; - label3.Size = new Size(97, 20); - label3.TabIndex = 9; - label3.Text = "maxSideLen"; - // - // paddingNumeric - // - paddingNumeric.Location = new Point(128, 7); - paddingNumeric.Margin = new Padding(4, 5, 4, 5); - paddingNumeric.Maximum = new decimal(new int[] { 200, 0, 0, 0 }); - paddingNumeric.Name = "paddingNumeric"; - paddingNumeric.Size = new Size(111, 27); - paddingNumeric.TabIndex = 7; - paddingNumeric.Value = new decimal(new int[] { 50, 0, 0, 0 }); - // - // label2 - // - label2.AutoSize = true; - label2.Location = new Point(6, 2); - label2.Margin = new Padding(4, 0, 4, 0); - label2.Name = "label2"; - label2.Size = new Size(70, 20); - label2.TabIndex = 6; - label2.Text = "padding"; - // - // boxScoreThreshNumeric - // - boxScoreThreshNumeric.DecimalPlaces = 3; - boxScoreThreshNumeric.Increment = new decimal(new int[] { 1, 0, 0, 65536 }); - boxScoreThreshNumeric.Location = new Point(128, 113); - boxScoreThreshNumeric.Margin = new Padding(4, 5, 4, 5); - boxScoreThreshNumeric.Maximum = new decimal(new int[] { 1, 0, 0, 0 }); - boxScoreThreshNumeric.Minimum = new decimal(new int[] { 1, 0, 0, 131072 }); - boxScoreThreshNumeric.Name = "boxScoreThreshNumeric"; - boxScoreThreshNumeric.Size = new Size(111, 27); - boxScoreThreshNumeric.TabIndex = 13; - boxScoreThreshNumeric.Value = new decimal(new int[] { 5, 0, 0, 65536 }); - // - // tableLayoutPanel3 - // - tableLayoutPanel3.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - tableLayoutPanel3.ColumnCount = 3; - tableLayoutPanel3.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 255F)); - tableLayoutPanel3.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); - tableLayoutPanel3.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); - tableLayoutPanel3.Controls.Add(strRestTextBox, 2, 0); - tableLayoutPanel3.Controls.Add(tableLayoutPanel1, 0, 0); - tableLayoutPanel3.Controls.Add(panel1, 1, 0); - tableLayoutPanel3.Location = new Point(21, 381); - tableLayoutPanel3.Margin = new Padding(4, 5, 4, 5); - tableLayoutPanel3.Name = "tableLayoutPanel3"; - tableLayoutPanel3.RowCount = 1; - tableLayoutPanel3.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); - tableLayoutPanel3.Size = new Size(1135, 447); - tableLayoutPanel3.TabIndex = 16; - // - // strRestTextBox - // - strRestTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; - strRestTextBox.Location = new Point(699, 5); - strRestTextBox.Margin = new Padding(4, 5, 4, 5); - strRestTextBox.Multiline = true; - strRestTextBox.Name = "strRestTextBox"; - strRestTextBox.ScrollBars = ScrollBars.Vertical; - strRestTextBox.Size = new Size(432, 431); - strRestTextBox.TabIndex = 0; - // - // tableLayoutPanel1 - // - tableLayoutPanel1.CellBorderStyle = TableLayoutPanelCellBorderStyle.Outset; - tableLayoutPanel1.ColumnCount = 2; - tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 120F)); - tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150F)); - tableLayoutPanel1.Controls.Add(boxScoreThreshNumeric, 1, 2); - tableLayoutPanel1.Controls.Add(label4, 0, 2); - tableLayoutPanel1.Controls.Add(imgResizeNumeric, 1, 1); - tableLayoutPanel1.Controls.Add(label3, 0, 1); - tableLayoutPanel1.Controls.Add(paddingNumeric, 1, 0); - tableLayoutPanel1.Controls.Add(label2, 0, 0); - tableLayoutPanel1.Controls.Add(label5, 0, 3); - tableLayoutPanel1.Controls.Add(boxThreshNumeric, 1, 3); - tableLayoutPanel1.Controls.Add(label7, 0, 4); - tableLayoutPanel1.Controls.Add(unClipRatioNumeric, 1, 4); - tableLayoutPanel1.Controls.Add(doAngleCheckBox, 0, 5); - tableLayoutPanel1.Controls.Add(mostAngleCheckBox, 1, 5); - tableLayoutPanel1.Controls.Add(partImgCheckBox, 0, 6); - tableLayoutPanel1.Controls.Add(debugCheckBox, 1, 6); - tableLayoutPanel1.Location = new Point(4, 5); - tableLayoutPanel1.Margin = new Padding(4, 5, 4, 5); - tableLayoutPanel1.Name = "tableLayoutPanel1"; - tableLayoutPanel1.RowCount = 8; - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 51F)); - tableLayoutPanel1.Size = new Size(246, 433); - tableLayoutPanel1.TabIndex = 2; - // - // partImgCheckBox - // - partImgCheckBox.AutoSize = true; - partImgCheckBox.Location = new Point(6, 325); - partImgCheckBox.Margin = new Padding(4, 5, 4, 5); - partImgCheckBox.Name = "partImgCheckBox"; - partImgCheckBox.Size = new Size(88, 24); - partImgCheckBox.TabIndex = 28; - partImgCheckBox.Text = "PartImg"; - partImgCheckBox.UseVisualStyleBackColor = true; - partImgCheckBox.CheckedChanged += partImgCheckBox_CheckedChanged; - // - // debugCheckBox - // - debugCheckBox.AutoSize = true; - debugCheckBox.Location = new Point(128, 325); - debugCheckBox.Margin = new Padding(4, 5, 4, 5); - debugCheckBox.Name = "debugCheckBox"; - debugCheckBox.Size = new Size(108, 24); - debugCheckBox.TabIndex = 29; - debugCheckBox.Text = "DebugImg"; - debugCheckBox.UseVisualStyleBackColor = true; - debugCheckBox.CheckedChanged += debugCheckBox_CheckedChanged; - // - // panel1 - // - panel1.AutoScroll = true; - panel1.Controls.Add(pictureBox); - panel1.Dock = DockStyle.Fill; - panel1.Location = new Point(258, 3); - panel1.Name = "panel1"; - panel1.Size = new Size(434, 441); - panel1.TabIndex = 3; - // - // pictureBox - // - pictureBox.BorderStyle = BorderStyle.FixedSingle; - pictureBox.Location = new Point(1, 5); - pictureBox.Margin = new Padding(4, 5, 4, 5); - pictureBox.Name = "pictureBox"; - pictureBox.Size = new Size(432, 431); - pictureBox.SizeMode = PictureBoxSizeMode.Zoom; - pictureBox.TabIndex = 4; - pictureBox.TabStop = false; - pictureBox.MouseDoubleClick += pictureBox_MouseDoubleClick; - // - // detectBtn - // - detectBtn.Anchor = AnchorStyles.Top | AnchorStyles.Right; - detectBtn.Location = new Point(1045, 20); - detectBtn.Margin = new Padding(4, 5, 4, 5); - detectBtn.Name = "detectBtn"; - detectBtn.Size = new Size(111, 189); - detectBtn.TabIndex = 12; - detectBtn.Text = "Detect"; - detectBtn.UseVisualStyleBackColor = true; - detectBtn.Click += detectBtn_Click; - // - // initBtn - // - initBtn.Location = new Point(1045, 220); - initBtn.Margin = new Padding(4, 5, 4, 5); - initBtn.Name = "initBtn"; - initBtn.Size = new Size(111, 152); - initBtn.TabIndex = 15; - initBtn.Text = "重新初始化"; - initBtn.UseVisualStyleBackColor = true; - initBtn.Click += initBtn_Click; - // - // ocrResultTextBox - // - ocrResultTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; - ocrResultTextBox.Font = new Font("宋体", 9F); - ocrResultTextBox.Location = new Point(21, 839); - ocrResultTextBox.Margin = new Padding(4, 5, 4, 5); - ocrResultTextBox.Multiline = true; - ocrResultTextBox.Name = "ocrResultTextBox"; - ocrResultTextBox.ScrollBars = ScrollBars.Vertical; - ocrResultTextBox.Size = new Size(1134, 407); - ocrResultTextBox.TabIndex = 13; - // - // FormOcr - // - AutoScaleDimensions = new SizeF(9F, 20F); - AutoScaleMode = AutoScaleMode.Font; - ClientSize = new Size(1176, 1268); - Controls.Add(tableLayoutPanel2); - Controls.Add(tableLayoutPanel3); - Controls.Add(detectBtn); - Controls.Add(initBtn); - Controls.Add(ocrResultTextBox); - Margin = new Padding(4, 5, 4, 5); - Name = "FormOcr"; - Text = "RapidPCRSharp"; - Load += Form1_Load; - tableLayoutPanel2.ResumeLayout(false); - tableLayoutPanel2.PerformLayout(); - ((System.ComponentModel.ISupportInitialize)numThreadNumeric).EndInit(); - ((System.ComponentModel.ISupportInitialize)boxThreshNumeric).EndInit(); - ((System.ComponentModel.ISupportInitialize)unClipRatioNumeric).EndInit(); - ((System.ComponentModel.ISupportInitialize)imgResizeNumeric).EndInit(); - ((System.ComponentModel.ISupportInitialize)paddingNumeric).EndInit(); - ((System.ComponentModel.ISupportInitialize)boxScoreThreshNumeric).EndInit(); - tableLayoutPanel3.ResumeLayout(false); - tableLayoutPanel3.PerformLayout(); - tableLayoutPanel1.ResumeLayout(false); - tableLayoutPanel1.PerformLayout(); - panel1.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)pictureBox).EndInit(); - ResumeLayout(false); - PerformLayout(); + #region Windows Form Designer generated code - } + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + rootLayout = new TableLayoutPanel(); + headerPanel = new Panel(); + modelStateLabel = new Label(); + subtitleLabel = new Label(); + titleLabel = new Label(); + mainSplit = new SplitContainer(); + leftPanel = new Panel(); + processButton = new Button(); + modelGroupBox = new GroupBox(); + modelStatusLabel = new Label(); + initializeModelButton = new Button(); + settingsGroupBox = new GroupBox(); + ignoreWeekendsCheckBox = new CheckBox(); + convertTraditionalToSimplifiedCheckBox = new CheckBox(); + employeeRosterStatusLabel = new Label(); + employeeRosterButton = new Button(); + layoutHintLabel = new Label(); + role4ColumnNumeric = new NumericUpDown(); + role4ColumnLabel = new Label(); + role3ColumnNumeric = new NumericUpDown(); + role3ColumnLabel = new Label(); + monthPicker = new DateTimePicker(); + targetMonthLabel = new Label(); + imagesGroupBox = new GroupBox(); + clearImagesButton = new Button(); + selectImagesButton = new Button(); + imageListView = new ListView(); + imageNumberColumn = new ColumnHeader(); + imageNameColumn = new ColumnHeader(); + imageStatusColumn = new ColumnHeader(); + outputTabs = new TabControl(); + resultTabPage = new TabPage(); + resultLayout = new TableLayoutPanel(); + resultToolbarPanel = new Panel(); + exportButton = new Button(); + revalidateButton = new Button(); + resultSummaryLabel = new Label(); + scheduleGrid = new DataGridView(); + dateColumn = new DataGridViewTextBoxColumn(); + weekdayColumn = new DataGridViewTextBoxColumn(); + role1Column = new DataGridViewTextBoxColumn(); + role2Column = new DataGridViewTextBoxColumn(); + role3Column = new DataGridViewTextBoxColumn(); + role4Column = new DataGridViewTextBoxColumn(); + issueColumn = new DataGridViewTextBoxColumn(); + sourceTabPage = new TabPage(); + sourceSplit = new SplitContainer(); + previewPictureBox = new PictureBox(); + diagnosticsTextBox = new TextBox(); + statusStrip = new StatusStrip(); + progressBar = new ToolStripProgressBar(); + statusLabel = new ToolStripStatusLabel(); + rootLayout.SuspendLayout(); + headerPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)mainSplit).BeginInit(); + mainSplit.Panel1.SuspendLayout(); + mainSplit.Panel2.SuspendLayout(); + mainSplit.SuspendLayout(); + leftPanel.SuspendLayout(); + modelGroupBox.SuspendLayout(); + settingsGroupBox.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)role4ColumnNumeric).BeginInit(); + ((System.ComponentModel.ISupportInitialize)role3ColumnNumeric).BeginInit(); + imagesGroupBox.SuspendLayout(); + outputTabs.SuspendLayout(); + resultTabPage.SuspendLayout(); + resultLayout.SuspendLayout(); + resultToolbarPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)scheduleGrid).BeginInit(); + sourceTabPage.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)sourceSplit).BeginInit(); + sourceSplit.Panel1.SuspendLayout(); + sourceSplit.Panel2.SuspendLayout(); + sourceSplit.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)previewPictureBox).BeginInit(); + statusStrip.SuspendLayout(); + SuspendLayout(); + // + // rootLayout + // + rootLayout.ColumnCount = 1; + rootLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + rootLayout.Controls.Add(headerPanel, 0, 0); + rootLayout.Controls.Add(mainSplit, 0, 1); + rootLayout.Controls.Add(statusStrip, 0, 2); + rootLayout.Dock = DockStyle.Fill; + rootLayout.Location = new Point(0, 0); + rootLayout.Margin = new Padding(0); + rootLayout.Name = "rootLayout"; + rootLayout.RowCount = 3; + rootLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 78F)); + rootLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + rootLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 26F)); + rootLayout.Size = new Size(1380, 860); + rootLayout.TabIndex = 0; + // + // headerPanel + // + headerPanel.BackColor = Color.FromArgb(35, 61, 92); + headerPanel.Controls.Add(modelStateLabel); + headerPanel.Controls.Add(subtitleLabel); + headerPanel.Controls.Add(titleLabel); + headerPanel.Dock = DockStyle.Fill; + headerPanel.Location = new Point(0, 0); + headerPanel.Margin = new Padding(0); + headerPanel.Name = "headerPanel"; + headerPanel.Size = new Size(1380, 78); + headerPanel.TabIndex = 0; + // + // modelStateLabel + // + modelStateLabel.Anchor = AnchorStyles.Top | AnchorStyles.Right; + modelStateLabel.AutoSize = true; + modelStateLabel.ForeColor = Color.FromArgb(255, 214, 102); + modelStateLabel.Location = new Point(1185, 29); + modelStateLabel.Name = "modelStateLabel"; + modelStateLabel.Size = new Size(147, 20); + modelStateLabel.TabIndex = 2; + modelStateLabel.Text = "● OCR 模型未初始化"; + // + // subtitleLabel + // + subtitleLabel.AutoSize = true; + subtitleLabel.ForeColor = Color.FromArgb(205, 220, 235); + subtitleLabel.Location = new Point(28, 48); + subtitleLabel.Name = "subtitleLabel"; + subtitleLabel.Size = new Size(417, 20); + subtitleLabel.TabIndex = 1; + subtitleLabel.Text = "导入 4 张排班图片,按日期穿插合并、标记异常并导出 Excel"; + // + // titleLabel + // + titleLabel.AutoSize = true; + titleLabel.Font = new Font("Microsoft YaHei UI", 18F, FontStyle.Bold); + titleLabel.ForeColor = Color.White; + titleLabel.Location = new Point(24, 9); + titleLabel.Name = "titleLabel"; + titleLabel.Size = new Size(258, 39); + titleLabel.TabIndex = 0; + titleLabel.Text = "本地排班图片整理"; + // + // mainSplit + // + mainSplit.Dock = DockStyle.Fill; + mainSplit.FixedPanel = FixedPanel.Panel1; + mainSplit.Location = new Point(0, 78); + mainSplit.Margin = new Padding(0); + mainSplit.Name = "mainSplit"; + // + // mainSplit.Panel1 + // + mainSplit.Panel1.Controls.Add(leftPanel); + mainSplit.Panel1MinSize = 380; + // + // mainSplit.Panel2 + // + mainSplit.Panel2.Controls.Add(outputTabs); + mainSplit.Panel2MinSize = 620; + mainSplit.Size = new Size(1380, 756); + mainSplit.SplitterDistance = 380; + mainSplit.TabIndex = 1; + // + // leftPanel + // + leftPanel.AutoScroll = true; + leftPanel.BackColor = Color.FromArgb(245, 247, 250); + leftPanel.Controls.Add(processButton); + leftPanel.Controls.Add(modelGroupBox); + leftPanel.Controls.Add(settingsGroupBox); + leftPanel.Controls.Add(imagesGroupBox); + leftPanel.Dock = DockStyle.Fill; + leftPanel.Location = new Point(0, 0); + leftPanel.Name = "leftPanel"; + leftPanel.Padding = new Padding(12); + leftPanel.Size = new Size(380, 756); + leftPanel.TabIndex = 0; + // + // processButton + // + processButton.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + processButton.BackColor = Color.FromArgb(28, 112, 191); + processButton.Enabled = false; + processButton.FlatAppearance.BorderSize = 0; + processButton.FlatStyle = FlatStyle.Flat; + processButton.Font = new Font("Microsoft YaHei UI", 11F, FontStyle.Bold); + processButton.ForeColor = Color.White; + processButton.Location = new Point(15, 699); + processButton.Name = "processButton"; + processButton.Size = new Size(348, 48); + processButton.TabIndex = 3; + processButton.Text = "识别并整理"; + processButton.UseVisualStyleBackColor = false; + processButton.Click += processButton_Click; + // + // modelGroupBox + // + modelGroupBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + modelGroupBox.Controls.Add(modelStatusLabel); + modelGroupBox.Controls.Add(initializeModelButton); + modelGroupBox.Location = new Point(15, 601); + modelGroupBox.Name = "modelGroupBox"; + modelGroupBox.Size = new Size(348, 84); + modelGroupBox.TabIndex = 2; + modelGroupBox.TabStop = false; + modelGroupBox.Text = "内置 OCR 模型"; + // + // modelStatusLabel + // + modelStatusLabel.AutoEllipsis = true; + modelStatusLabel.ForeColor = Color.DimGray; + modelStatusLabel.Location = new Point(12, 34); + modelStatusLabel.Name = "modelStatusLabel"; + modelStatusLabel.Size = new Size(208, 25); + modelStatusLabel.TabIndex = 0; + modelStatusLabel.Text = "正在准备内置模型…"; + // + // initializeModelButton + // + initializeModelButton.Location = new Point(228, 27); + initializeModelButton.Name = "initializeModelButton"; + initializeModelButton.Size = new Size(108, 34); + initializeModelButton.TabIndex = 1; + initializeModelButton.Text = "重新初始化"; + initializeModelButton.UseVisualStyleBackColor = true; + initializeModelButton.Click += initializeModelButton_Click; + // + // settingsGroupBox + // + settingsGroupBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + settingsGroupBox.Controls.Add(ignoreWeekendsCheckBox); + settingsGroupBox.Controls.Add(convertTraditionalToSimplifiedCheckBox); + settingsGroupBox.Controls.Add(employeeRosterStatusLabel); + settingsGroupBox.Controls.Add(employeeRosterButton); + settingsGroupBox.Controls.Add(layoutHintLabel); + settingsGroupBox.Controls.Add(role4ColumnNumeric); + settingsGroupBox.Controls.Add(role4ColumnLabel); + settingsGroupBox.Controls.Add(role3ColumnNumeric); + settingsGroupBox.Controls.Add(role3ColumnLabel); + settingsGroupBox.Controls.Add(monthPicker); + settingsGroupBox.Controls.Add(targetMonthLabel); + settingsGroupBox.Location = new Point(15, 294); + settingsGroupBox.Name = "settingsGroupBox"; + settingsGroupBox.Size = new Size(348, 293); + settingsGroupBox.TabIndex = 1; + settingsGroupBox.TabStop = false; + settingsGroupBox.Text = "整理设置"; + // + // ignoreWeekendsCheckBox + // + ignoreWeekendsCheckBox.AutoSize = true; + ignoreWeekendsCheckBox.Checked = true; + ignoreWeekendsCheckBox.CheckState = CheckState.Checked; + ignoreWeekendsCheckBox.Location = new Point(14, 181); + ignoreWeekendsCheckBox.Name = "ignoreWeekendsCheckBox"; + ignoreWeekendsCheckBox.Size = new Size(166, 24); + ignoreWeekendsCheckBox.TabIndex = 7; + ignoreWeekendsCheckBox.Text = "忽略周六和周日"; + ignoreWeekendsCheckBox.UseVisualStyleBackColor = true; + ignoreWeekendsCheckBox.CheckedChanged += ignoreWeekendsCheckBox_CheckedChanged; + // + // convertTraditionalToSimplifiedCheckBox + // + convertTraditionalToSimplifiedCheckBox.AutoSize = true; + convertTraditionalToSimplifiedCheckBox.Checked = true; + convertTraditionalToSimplifiedCheckBox.CheckState = CheckState.Checked; + convertTraditionalToSimplifiedCheckBox.Location = new Point(14, 211); + convertTraditionalToSimplifiedCheckBox.Name = "convertTraditionalToSimplifiedCheckBox"; + convertTraditionalToSimplifiedCheckBox.Size = new Size(211, 24); + convertTraditionalToSimplifiedCheckBox.TabIndex = 8; + convertTraditionalToSimplifiedCheckBox.Text = "自动将繁体转换为简体"; + convertTraditionalToSimplifiedCheckBox.UseVisualStyleBackColor = true; + convertTraditionalToSimplifiedCheckBox.CheckedChanged += convertTraditionalToSimplifiedCheckBox_CheckedChanged; + // + // employeeRosterStatusLabel + // + employeeRosterStatusLabel.AutoEllipsis = true; + employeeRosterStatusLabel.ForeColor = Color.DimGray; + employeeRosterStatusLabel.Location = new Point(14, 252); + employeeRosterStatusLabel.Name = "employeeRosterStatusLabel"; + employeeRosterStatusLabel.Size = new Size(205, 25); + employeeRosterStatusLabel.TabIndex = 9; + employeeRosterStatusLabel.Text = "员工名单:尚未配置"; + employeeRosterStatusLabel.TextAlign = ContentAlignment.MiddleLeft; + // + // employeeRosterButton + // + employeeRosterButton.Location = new Point(228, 245); + employeeRosterButton.Name = "employeeRosterButton"; + employeeRosterButton.Size = new Size(104, 34); + employeeRosterButton.TabIndex = 10; + employeeRosterButton.Text = "配置名单…"; + employeeRosterButton.UseVisualStyleBackColor = true; + employeeRosterButton.Click += employeeRosterButton_Click; + // + // layoutHintLabel + // + layoutHintLabel.ForeColor = Color.DimGray; + layoutHintLabel.Location = new Point(14, 129); + layoutHintLabel.Name = "layoutHintLabel"; + layoutHintLabel.Size = new Size(318, 41); + layoutHintLabel.TabIndex = 6; + layoutHintLabel.Text = "列序号从 1 开始;-1 表示最后一列,-2 表示倒数第二列。前四列固定为日期、星期、岗位一、岗位二。"; + // + // role4ColumnNumeric + // + role4ColumnNumeric.Location = new Point(255, 88); + role4ColumnNumeric.Maximum = new decimal(new int[] { 50, 0, 0, 0 }); + role4ColumnNumeric.Minimum = new decimal(new int[] { 50, 0, 0, int.MinValue }); + role4ColumnNumeric.Name = "role4ColumnNumeric"; + role4ColumnNumeric.Size = new Size(77, 27); + role4ColumnNumeric.TabIndex = 5; + role4ColumnNumeric.Value = new decimal(new int[] { 1, 0, 0, int.MinValue }); + role4ColumnNumeric.ValueChanged += layoutColumnNumeric_ValueChanged; + // + // role4ColumnLabel + // + role4ColumnLabel.AutoSize = true; + role4ColumnLabel.Location = new Point(181, 91); + role4ColumnLabel.Name = "role4ColumnLabel"; + role4ColumnLabel.Size = new Size(69, 20); + role4ColumnLabel.TabIndex = 4; + role4ColumnLabel.Text = "岗位四列"; + // + // role3ColumnNumeric + // + role3ColumnNumeric.Location = new Point(91, 88); + role3ColumnNumeric.Maximum = new decimal(new int[] { 50, 0, 0, 0 }); + role3ColumnNumeric.Minimum = new decimal(new int[] { 50, 0, 0, int.MinValue }); + role3ColumnNumeric.Name = "role3ColumnNumeric"; + role3ColumnNumeric.Size = new Size(77, 27); + role3ColumnNumeric.TabIndex = 3; + role3ColumnNumeric.Value = new decimal(new int[] { 2, 0, 0, int.MinValue }); + role3ColumnNumeric.ValueChanged += layoutColumnNumeric_ValueChanged; + // + // role3ColumnLabel + // + role3ColumnLabel.AutoSize = true; + role3ColumnLabel.Location = new Point(14, 91); + role3ColumnLabel.Name = "role3ColumnLabel"; + role3ColumnLabel.Size = new Size(69, 20); + role3ColumnLabel.TabIndex = 2; + role3ColumnLabel.Text = "岗位三列"; + // + // monthPicker + // + monthPicker.CustomFormat = "yyyy 年 M 月"; + monthPicker.Format = DateTimePickerFormat.Custom; + monthPicker.Location = new Point(91, 39); + monthPicker.Name = "monthPicker"; + monthPicker.ShowUpDown = true; + monthPicker.Size = new Size(241, 27); + monthPicker.TabIndex = 1; + monthPicker.ValueChanged += monthPicker_ValueChanged; + // + // targetMonthLabel + // + targetMonthLabel.AutoSize = true; + targetMonthLabel.Location = new Point(14, 42); + targetMonthLabel.Name = "targetMonthLabel"; + targetMonthLabel.Size = new Size(69, 20); + targetMonthLabel.TabIndex = 0; + targetMonthLabel.Text = "目标月份"; + // + // imagesGroupBox + // + imagesGroupBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + imagesGroupBox.Controls.Add(clearImagesButton); + imagesGroupBox.Controls.Add(selectImagesButton); + imagesGroupBox.Controls.Add(imageListView); + imagesGroupBox.Location = new Point(15, 12); + imagesGroupBox.Name = "imagesGroupBox"; + imagesGroupBox.Size = new Size(348, 268); + imagesGroupBox.TabIndex = 0; + imagesGroupBox.TabStop = false; + imagesGroupBox.Text = "排班图片(必须正好 4 张)"; + // + // clearImagesButton + // + clearImagesButton.Location = new Point(228, 220); + clearImagesButton.Name = "clearImagesButton"; + clearImagesButton.Size = new Size(108, 34); + clearImagesButton.TabIndex = 2; + clearImagesButton.Text = "清空"; + clearImagesButton.UseVisualStyleBackColor = true; + clearImagesButton.Click += clearImagesButton_Click; + // + // selectImagesButton + // + selectImagesButton.Location = new Point(12, 220); + selectImagesButton.Name = "selectImagesButton"; + selectImagesButton.Size = new Size(208, 34); + selectImagesButton.TabIndex = 1; + selectImagesButton.Text = "选择 4 张图片…"; + selectImagesButton.UseVisualStyleBackColor = true; + selectImagesButton.Click += selectImagesButton_Click; + // + // imageListView + // + imageListView.Columns.AddRange(new ColumnHeader[] { imageNumberColumn, imageNameColumn, imageStatusColumn }); + imageListView.FullRowSelect = true; + imageListView.GridLines = true; + imageListView.HeaderStyle = ColumnHeaderStyle.Nonclickable; + imageListView.Location = new Point(12, 29); + imageListView.MultiSelect = false; + imageListView.Name = "imageListView"; + imageListView.ShowItemToolTips = true; + imageListView.Size = new Size(324, 181); + imageListView.TabIndex = 0; + imageListView.UseCompatibleStateImageBehavior = false; + imageListView.View = View.Details; + imageListView.SelectedIndexChanged += imageListView_SelectedIndexChanged; + // + // imageNumberColumn + // + imageNumberColumn.Text = "#"; + imageNumberColumn.Width = 32; + // + // imageNameColumn + // + imageNameColumn.Text = "文件"; + imageNameColumn.Width = 174; + // + // imageStatusColumn + // + imageStatusColumn.Text = "状态"; + imageStatusColumn.Width = 112; + // + // outputTabs + // + outputTabs.Controls.Add(resultTabPage); + outputTabs.Controls.Add(sourceTabPage); + outputTabs.Dock = DockStyle.Fill; + outputTabs.Location = new Point(0, 0); + outputTabs.Name = "outputTabs"; + outputTabs.SelectedIndex = 0; + outputTabs.Size = new Size(996, 756); + outputTabs.TabIndex = 0; + // + // resultTabPage + // + resultTabPage.Controls.Add(resultLayout); + resultTabPage.Location = new Point(4, 29); + resultTabPage.Name = "resultTabPage"; + resultTabPage.Padding = new Padding(8); + resultTabPage.Size = new Size(988, 723); + resultTabPage.TabIndex = 0; + resultTabPage.Text = "整理结果 / 可编辑"; + resultTabPage.UseVisualStyleBackColor = true; + // + // resultLayout + // + resultLayout.ColumnCount = 1; + resultLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + resultLayout.Controls.Add(resultToolbarPanel, 0, 0); + resultLayout.Controls.Add(scheduleGrid, 0, 1); + resultLayout.Dock = DockStyle.Fill; + resultLayout.Location = new Point(8, 8); + resultLayout.Name = "resultLayout"; + resultLayout.RowCount = 2; + resultLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 58F)); + resultLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + resultLayout.Size = new Size(972, 707); + resultLayout.TabIndex = 0; + // + // resultToolbarPanel + // + resultToolbarPanel.Controls.Add(exportButton); + resultToolbarPanel.Controls.Add(revalidateButton); + resultToolbarPanel.Controls.Add(resultSummaryLabel); + resultToolbarPanel.Dock = DockStyle.Fill; + resultToolbarPanel.Location = new Point(0, 0); + resultToolbarPanel.Margin = new Padding(0); + resultToolbarPanel.Name = "resultToolbarPanel"; + resultToolbarPanel.Size = new Size(972, 58); + resultToolbarPanel.TabIndex = 0; + // + // exportButton + // + exportButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + exportButton.BackColor = Color.FromArgb(42, 139, 87); + exportButton.Enabled = false; + exportButton.FlatAppearance.BorderSize = 0; + exportButton.FlatStyle = FlatStyle.Flat; + exportButton.ForeColor = Color.White; + exportButton.Location = new Point(836, 11); + exportButton.Name = "exportButton"; + exportButton.Size = new Size(126, 36); + exportButton.TabIndex = 2; + exportButton.Text = "导出 Excel…"; + exportButton.UseVisualStyleBackColor = false; + exportButton.Click += exportButton_Click; + // + // revalidateButton + // + revalidateButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + revalidateButton.Enabled = false; + revalidateButton.Location = new Point(704, 11); + revalidateButton.Name = "revalidateButton"; + revalidateButton.Size = new Size(126, 36); + revalidateButton.TabIndex = 1; + revalidateButton.Text = "重新校验"; + revalidateButton.UseVisualStyleBackColor = true; + revalidateButton.Click += revalidateButton_Click; + // + // resultSummaryLabel + // + resultSummaryLabel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + resultSummaryLabel.AutoEllipsis = true; + resultSummaryLabel.Location = new Point(8, 11); + resultSummaryLabel.Name = "resultSummaryLabel"; + resultSummaryLabel.Size = new Size(682, 37); + resultSummaryLabel.TabIndex = 0; + resultSummaryLabel.Text = "尚未整理。异常单元格将用浅红色标记,可直接双击修改。"; + resultSummaryLabel.TextAlign = ContentAlignment.MiddleLeft; + // + // scheduleGrid + // + scheduleGrid.AllowUserToAddRows = false; + scheduleGrid.AllowUserToDeleteRows = false; + scheduleGrid.AutoGenerateColumns = false; + scheduleGrid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; + scheduleGrid.BackgroundColor = Color.White; + scheduleGrid.ColumnHeadersHeight = 38; + scheduleGrid.Columns.AddRange(new DataGridViewColumn[] { dateColumn, weekdayColumn, role1Column, role2Column, role3Column, role4Column, issueColumn }); + scheduleGrid.Dock = DockStyle.Fill; + scheduleGrid.EditMode = DataGridViewEditMode.EditOnKeystrokeOrF2; + scheduleGrid.Location = new Point(3, 61); + scheduleGrid.MultiSelect = false; + scheduleGrid.Name = "scheduleGrid"; + scheduleGrid.RowHeadersWidth = 48; + scheduleGrid.RowTemplate.Height = 32; + scheduleGrid.SelectionMode = DataGridViewSelectionMode.CellSelect; + scheduleGrid.Size = new Size(966, 643); + scheduleGrid.TabIndex = 1; + scheduleGrid.CellEndEdit += scheduleGrid_CellEndEdit; + scheduleGrid.CellFormatting += scheduleGrid_CellFormatting; + scheduleGrid.DataError += scheduleGrid_DataError; + // + // dateColumn + // + dateColumn.DataPropertyName = "DateText"; + dateColumn.FillWeight = 70F; + dateColumn.HeaderText = "日期"; + dateColumn.MinimumWidth = 72; + dateColumn.Name = "dateColumn"; + // + // weekdayColumn + // + weekdayColumn.DataPropertyName = "Weekday"; + weekdayColumn.FillWeight = 78F; + weekdayColumn.HeaderText = "星期"; + weekdayColumn.MinimumWidth = 78; + weekdayColumn.Name = "weekdayColumn"; + // + // role1Column + // + role1Column.DataPropertyName = "Role1"; + role1Column.HeaderText = "岗位一(队长)"; + role1Column.MinimumWidth = 115; + role1Column.Name = "role1Column"; + // + // role2Column + // + role2Column.DataPropertyName = "Role2"; + role2Column.HeaderText = "岗位二"; + role2Column.MinimumWidth = 90; + role2Column.Name = "role2Column"; + // + // role3Column + // + role3Column.DataPropertyName = "Role3"; + role3Column.HeaderText = "岗位三"; + role3Column.MinimumWidth = 90; + role3Column.Name = "role3Column"; + // + // role4Column + // + role4Column.DataPropertyName = "Role4"; + role4Column.HeaderText = "岗位四"; + role4Column.MinimumWidth = 90; + role4Column.Name = "role4Column"; + // + // issueColumn + // + issueColumn.DataPropertyName = "IssueText"; + issueColumn.FillWeight = 160F; + issueColumn.HeaderText = "异常 / 待检查"; + issueColumn.MinimumWidth = 180; + issueColumn.Name = "issueColumn"; + issueColumn.ReadOnly = true; + // + // sourceTabPage + // + sourceTabPage.Controls.Add(sourceSplit); + sourceTabPage.Location = new Point(4, 29); + sourceTabPage.Name = "sourceTabPage"; + sourceTabPage.Padding = new Padding(8); + sourceTabPage.Size = new Size(988, 723); + sourceTabPage.TabIndex = 1; + sourceTabPage.Text = "原图与识别诊断"; + sourceTabPage.UseVisualStyleBackColor = true; + // + // sourceSplit + // + sourceSplit.Dock = DockStyle.Fill; + sourceSplit.Location = new Point(8, 8); + sourceSplit.Name = "sourceSplit"; + sourceSplit.Orientation = Orientation.Horizontal; + // + // sourceSplit.Panel1 + // + sourceSplit.Panel1.Controls.Add(previewPictureBox); + sourceSplit.Panel1MinSize = 260; + // + // sourceSplit.Panel2 + // + sourceSplit.Panel2.Controls.Add(diagnosticsTextBox); + sourceSplit.Panel2MinSize = 110; + sourceSplit.Size = new Size(972, 707); + sourceSplit.SplitterDistance = 493; + sourceSplit.TabIndex = 0; + // + // previewPictureBox + // + previewPictureBox.BackColor = Color.FromArgb(35, 35, 35); + previewPictureBox.Dock = DockStyle.Fill; + previewPictureBox.Location = new Point(0, 0); + previewPictureBox.Name = "previewPictureBox"; + previewPictureBox.Size = new Size(972, 493); + previewPictureBox.SizeMode = PictureBoxSizeMode.Zoom; + previewPictureBox.TabIndex = 0; + previewPictureBox.TabStop = false; + // + // diagnosticsTextBox + // + diagnosticsTextBox.BackColor = Color.White; + diagnosticsTextBox.Dock = DockStyle.Fill; + diagnosticsTextBox.Font = new Font("Consolas", 9F); + diagnosticsTextBox.Location = new Point(0, 0); + diagnosticsTextBox.Multiline = true; + diagnosticsTextBox.Name = "diagnosticsTextBox"; + diagnosticsTextBox.ReadOnly = true; + diagnosticsTextBox.ScrollBars = ScrollBars.Both; + diagnosticsTextBox.Size = new Size(972, 210); + diagnosticsTextBox.TabIndex = 0; + diagnosticsTextBox.WordWrap = false; + // + // statusStrip + // + statusStrip.Items.AddRange(new ToolStripItem[] { progressBar, statusLabel }); + statusStrip.Location = new Point(0, 834); + statusStrip.Name = "statusStrip"; + statusStrip.Size = new Size(1380, 26); + statusStrip.SizingGrip = false; + statusStrip.TabIndex = 2; + // + // progressBar + // + progressBar.Name = "progressBar"; + progressBar.Size = new Size(160, 18); + progressBar.Visible = false; + // + // statusLabel + // + statusLabel.Name = "statusLabel"; + statusLabel.Size = new Size(69, 20); + statusLabel.Text = "准备就绪"; + // + // FormOcr + // + AutoScaleDimensions = new SizeF(9F, 20F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1380, 860); + Controls.Add(rootLayout); + Font = new Font("Microsoft YaHei UI", 9F); + MinimumSize = new Size(1120, 720); + Name = "FormOcr"; + StartPosition = FormStartPosition.CenterScreen; + Text = "本地排班图片整理 - RapidOCR"; + FormClosing += FormOcr_FormClosing; + Load += FormOcr_Load; + rootLayout.ResumeLayout(false); + rootLayout.PerformLayout(); + headerPanel.ResumeLayout(false); + headerPanel.PerformLayout(); + mainSplit.Panel1.ResumeLayout(false); + mainSplit.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)mainSplit).EndInit(); + mainSplit.ResumeLayout(false); + leftPanel.ResumeLayout(false); + modelGroupBox.ResumeLayout(false); + modelGroupBox.PerformLayout(); + settingsGroupBox.ResumeLayout(false); + settingsGroupBox.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)role4ColumnNumeric).EndInit(); + ((System.ComponentModel.ISupportInitialize)role3ColumnNumeric).EndInit(); + imagesGroupBox.ResumeLayout(false); + outputTabs.ResumeLayout(false); + resultTabPage.ResumeLayout(false); + resultLayout.ResumeLayout(false); + resultToolbarPanel.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)scheduleGrid).EndInit(); + sourceTabPage.ResumeLayout(false); + sourceSplit.Panel1.ResumeLayout(false); + sourceSplit.Panel2.ResumeLayout(false); + sourceSplit.Panel2.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)sourceSplit).EndInit(); + sourceSplit.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)previewPictureBox).EndInit(); + statusStrip.ResumeLayout(false); + statusStrip.PerformLayout(); + ResumeLayout(false); + } - #endregion + #endregion - private System.Windows.Forms.Label label8; - private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; - private System.Windows.Forms.TextBox modelsTextBox; - private System.Windows.Forms.Label label9; - private System.Windows.Forms.Button modelsBtn; - private System.Windows.Forms.TextBox pathTextBox; - private System.Windows.Forms.Button openBtn; - private System.Windows.Forms.Label label1; - private System.Windows.Forms.NumericUpDown numThreadNumeric; - private System.Windows.Forms.Label label6; - private System.Windows.Forms.TextBox detNameTextBox; - private System.Windows.Forms.Label label10; - private System.Windows.Forms.TextBox clsNameTextBox; - private System.Windows.Forms.Label label11; - private System.Windows.Forms.TextBox recNameTextBox; - private System.Windows.Forms.Label label12; - private System.Windows.Forms.TextBox keysNameTextBox; - private System.Windows.Forms.Label label5; - private System.Windows.Forms.NumericUpDown boxThreshNumeric; - private System.Windows.Forms.Label label7; - private System.Windows.Forms.NumericUpDown unClipRatioNumeric; - private System.Windows.Forms.CheckBox doAngleCheckBox; - private System.Windows.Forms.CheckBox mostAngleCheckBox; - private System.Windows.Forms.Label label4; - private System.Windows.Forms.NumericUpDown imgResizeNumeric; - private System.Windows.Forms.Label label3; - private System.Windows.Forms.NumericUpDown paddingNumeric; - private System.Windows.Forms.Label label2; - private System.Windows.Forms.NumericUpDown boxScoreThreshNumeric; - private System.Windows.Forms.TableLayoutPanel tableLayoutPanel3; - private System.Windows.Forms.TextBox strRestTextBox; - private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; - private System.Windows.Forms.CheckBox partImgCheckBox; - private System.Windows.Forms.CheckBox debugCheckBox; - private System.Windows.Forms.Button detectBtn; - private System.Windows.Forms.Button initBtn; - private System.Windows.Forms.TextBox ocrResultTextBox; - private Panel panel1; - private PictureBox pictureBox; - } + private TableLayoutPanel rootLayout; + private Panel headerPanel; + private Label modelStateLabel; + private Label subtitleLabel; + private Label titleLabel; + private SplitContainer mainSplit; + private Panel leftPanel; + private GroupBox imagesGroupBox; + private ListView imageListView; + private ColumnHeader imageNumberColumn; + private ColumnHeader imageNameColumn; + private ColumnHeader imageStatusColumn; + private Button selectImagesButton; + private Button clearImagesButton; + private GroupBox settingsGroupBox; + private DateTimePicker monthPicker; + private Label targetMonthLabel; + private NumericUpDown role3ColumnNumeric; + private Label role3ColumnLabel; + private NumericUpDown role4ColumnNumeric; + private Label role4ColumnLabel; + private Label layoutHintLabel; + private CheckBox ignoreWeekendsCheckBox; + private CheckBox convertTraditionalToSimplifiedCheckBox; + private Button employeeRosterButton; + private Label employeeRosterStatusLabel; + private GroupBox modelGroupBox; + private Button initializeModelButton; + private Label modelStatusLabel; + private Button processButton; + private TabControl outputTabs; + private TabPage resultTabPage; + private TableLayoutPanel resultLayout; + private Panel resultToolbarPanel; + private Label resultSummaryLabel; + private Button revalidateButton; + private Button exportButton; + private DataGridView scheduleGrid; + private DataGridViewTextBoxColumn dateColumn; + private DataGridViewTextBoxColumn weekdayColumn; + private DataGridViewTextBoxColumn role1Column; + private DataGridViewTextBoxColumn role2Column; + private DataGridViewTextBoxColumn role3Column; + private DataGridViewTextBoxColumn role4Column; + private DataGridViewTextBoxColumn issueColumn; + private TabPage sourceTabPage; + private SplitContainer sourceSplit; + private PictureBox previewPictureBox; + private TextBox diagnosticsTextBox; + private StatusStrip statusStrip; + private ToolStripProgressBar progressBar; + private ToolStripStatusLabel statusLabel; } - diff --git a/RapidOCRWinform/FormOcr.cs b/RapidOCRWinform/FormOcr.cs index 1bbfc18..871ecc9 100644 --- a/RapidOCRWinform/FormOcr.cs +++ b/RapidOCRWinform/FormOcr.cs @@ -1,187 +1,1221 @@ -using Emgu.CV; -using Emgu.CV.CvEnum; using RapidOCRLib; using RapidOCRLib.Models; -using System; -using System.Collections.Generic; +using RapidOCRSchedule.Core; +using RapidOCRSchedule.Excel; using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; -using System.Windows.Forms; -using System.IO; -namespace RapidOCRWinform +namespace RapidOCRWinform; + +public partial class FormOcr : Form { - public partial class FormOcr : Form + internal const int OcrPadding = 30; + internal const int OcrMaximumSideLength = 2400; + internal const float OcrBoxScoreThreshold = 0.45F; + internal const float OcrBoxThreshold = 0.30F; + internal const float OcrUnclipRatio = 1.60F; + + private static readonly string[] ModelFileNames = + [ + "ch_PP-OCRv5_mobile_det.onnx", + "ch_ppocr_mobile_v2.0_cls_infer.onnx", + "ch_PP-OCRv5_rec_mobile_infer.onnx", + "ppocrv5_dict.txt", + ]; + + private readonly AppSettings _settings; + private readonly List _images = []; + private readonly BindingList _editableRows = []; + private readonly BindingSource _rowBinding = new(); + private readonly ScheduleExtractor _extractor = new(); + private readonly ScheduleService _scheduleService = new(); + private readonly List _duplicateCandidatesForReview = []; + private readonly List _employeeNameCorrections = []; + + private OcrLite? _ocrEngine; + private ScheduleExtractionResult? _lastExtraction; + private ScheduleBuildResult? _lastBuild; + private string _batchDiagnostics = string.Empty; + private DateTime _appliedMonth; + private int _appliedRole3Column = -2; + private int _appliedRole4Column = -1; + private bool _ocrReady; + private bool _busy; + private bool _loaded; + private bool _hasManualEdits; + private bool _suppressSettingEvents; + + public FormOcr() + { + _settings = AppSettings.Load(); + InitializeComponent(); + _rowBinding.DataSource = _editableRows; + scheduleGrid.DataSource = _rowBinding; + } + + private async void FormOcr_Load(object? sender, EventArgs e) { - private OcrLite? ocrEngin; + monthPicker.MinDate = new DateTime(1900, 1, 1); + monthPicker.Value = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + role3ColumnNumeric.Value = NormalizeColumnSetting(_settings.Role3Column, -2); + role4ColumnNumeric.Value = NormalizeColumnSetting(_settings.Role4Column, -1); + ignoreWeekendsCheckBox.Checked = _settings.IgnoreWeekends; + convertTraditionalToSimplifiedCheckBox.Checked = _settings.ConvertTraditionalToSimplified; + _settings.EmployeeNames = EmployeeNameResolver + .NormalizeEmployeeNames( + _settings.EmployeeNames ?? [], + _settings.ConvertTraditionalToSimplified) + .ToList(); + UpdateEmployeeRosterStatus(); + RememberAppliedSettings(); + _loaded = true; + UpdateActionState(); + await InitializeOcrAsync(showSuccess: false); + } - public FormOcr() + private async void initializeModelButton_Click(object? sender, EventArgs e) => + await InitializeOcrAsync(showSuccess: true); + + private async Task InitializeOcrAsync(bool showSuccess) + { + if (_busy) { - InitializeComponent(); + return; } - private async void Form1_Load(object sender, EventArgs e) + try { - string appPath = AppDomain.CurrentDomain.BaseDirectory; - string appDir = Directory.GetParent(appPath)!.FullName; - string modelsDir = appPath + "models"; - modelsTextBox.Text = modelsDir; - string detPath = modelsDir + "\\" + detNameTextBox.Text; - string clsPath = modelsDir + "\\" + clsNameTextBox.Text; - string recPath = modelsDir + "\\" + recNameTextBox.Text; - string keysPath = modelsDir + "\\" + keysNameTextBox.Text; - bool isDetExists = File.Exists(detPath); - if (!isDetExists) + SetBusy(true, "正在准备内置 OCR 模型…"); + SetModelState(false, "正在准备内置模型…"); + string modelDirectory = await Task.Run(EmbeddedModelProvider.EnsureExtracted); + OcrLite engine = new(); + int threadCount = Math.Max(1, (int)Math.Ceiling(Environment.ProcessorCount * 0.7)); + try { - MessageBox.Show("模型文件不存在:" + detPath); + await Task.Run(async () => + await engine.InitModels( + Path.Combine(modelDirectory, ModelFileNames[0]), + Path.Combine(modelDirectory, ModelFileNames[1]), + Path.Combine(modelDirectory, ModelFileNames[2]), + Path.Combine(modelDirectory, ModelFileNames[3]), + threadCount)); } - bool isClsExists = File.Exists(clsPath); - if (!isClsExists) + catch { - MessageBox.Show("模型文件不存在:" + clsPath); + engine.Dispose(); + throw; } - bool isRecExists = File.Exists(recPath); - if (!isRecExists) + + OcrLite? previousEngine = _ocrEngine; + _ocrEngine = engine; + previousEngine?.Dispose(); + _ocrReady = true; + SetModelState(true, $"内置模型已就绪 · {threadCount} 线程"); + SetStatus("内置 OCR 模型初始化完成"); + if (showSuccess) { - MessageBox.Show("模型文件不存在:" + recPath); + MessageBox.Show(this, "内置 OCR 模型初始化成功。", "模型就绪", MessageBoxButtons.OK, MessageBoxIcon.Information); } - bool isKeysExists = File.Exists(recPath); - if (!isKeysExists) + } + catch (Exception exception) + { + _ocrEngine?.Dispose(); + _ocrEngine = null; + _ocrReady = false; + SetModelState(false, "内置模型初始化失败,可重试"); + MessageBox.Show( + this, + $"无法准备程序内置的 OCR 模型:\n{exception.Message}\n\n请检查磁盘空间和用户目录权限后重试。", + "初始化失败", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + finally + { + SetBusy(false); + } + } + + private void FormOcr_FormClosing(object? sender, FormClosingEventArgs e) + { + if (!_busy) + { + return; + } + + e.Cancel = true; + MessageBox.Show( + this, + "OCR 正在处理图片或初始化模型,请等待当前操作完成后再关闭窗口。", + "正在处理", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + } + + private void selectImagesButton_Click(object? sender, EventArgs e) + { + using OpenFileDialog dialog = new() + { + Multiselect = true, + Title = "请选择正好 4 张排班表图片", + Filter = "图片文件|*.jpg;*.jpeg;*.png;*.bmp;*.tif;*.tiff|所有文件|*.*", + CheckFileExists = true, + }; + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + string[] distinctFiles = dialog.FileNames + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (distinctFiles.Length != 4) + { + MessageBox.Show( + this, + $"必须一次选择正好 4 张不同的图片;当前选择了 {distinctFiles.Length} 张。", + "图片数量不正确", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + _images.Clear(); + _images.AddRange(distinctFiles.Select(path => new ImageWorkItem { FilePath = path })); + ClearResults(); + RefreshImageList(); + if (imageListView.Items.Count > 0) + { + imageListView.Items[0].Selected = true; + } + + SetStatus("已选择 4 张图片,等待识别"); + UpdateActionState(); + } + + private void clearImagesButton_Click(object? sender, EventArgs e) + { + _images.Clear(); + imageListView.Items.Clear(); + ReplacePreviewImage(null); + diagnosticsTextBox.Clear(); + ClearResults(); + SetStatus("已清空图片"); + UpdateActionState(); + } + + private async void processButton_Click(object? sender, EventArgs e) + { + if (_images.Count != 4) + { + MessageBox.Show(this, "请先选择正好 4 张排班图片。", "无法开始", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (!_ocrReady || _ocrEngine is null) + { + MessageBox.Show(this, "内置 OCR 模型尚未就绪,请重试初始化。", "无法开始", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + try + { + SetBusy(true, "开始识别 4 张图片…"); + ClearResults(); + for (int resetIndex = 0; resetIndex < _images.Count; resetIndex++) { - MessageBox.Show("Keys文件不存在:" + keysPath); + _images[resetIndex].Tokens.Clear(); + _images[resetIndex].Diagnostics = string.Empty; + _images[resetIndex].Status = "待识别"; + UpdateImageListStatus(resetIndex); + } + + progressBar.Maximum = _images.Count; + progressBar.Value = 0; + + for (int index = 0; index < _images.Count; index++) + { + ImageWorkItem image = _images[index]; + image.Tokens.Clear(); + image.Status = "识别中…"; + UpdateImageListStatus(index); + SetStatus($"正在识别第 {index + 1}/4 张:{image.DisplayName}"); + + OcrResult? ocrResult = null; + try + { + Size imageSize = ReadImageSize(image.FilePath); + ocrResult = await _ocrEngine.DetectAsync( + image.FilePath, + OcrPadding, + OcrMaximumSideLength, + OcrBoxScoreThreshold, + OcrBoxThreshold, + OcrUnclipRatio, + doAngle: false, + mostAngle: false); + image.Tokens.AddRange(ConvertTokens(image.FilePath, imageSize, ocrResult)); + image.Diagnostics = FormatOcrDiagnostics(image, ocrResult); + image.Status = $"完成 · {image.Tokens.Count} 项"; + } + catch (Exception exception) + { + image.Status = "失败"; + image.Diagnostics = $"识别失败:{exception}"; + throw new InvalidOperationException($"{image.DisplayName} 识别失败:{exception.Message}", exception); + } + finally + { + ocrResult?.BoxImg?.Dispose(); + progressBar.Value = index + 1; + UpdateImageListStatus(index); + } } - if (isDetExists && isClsExists && isRecExists && isKeysExists) + + ExtractAndBind(); + outputTabs.SelectedTab = resultTabPage; + SetStatus($"整理完成:{_lastBuild?.Rows.Count ?? 0} 个日期,{_editableRows.Count(static row => row.IssueText.Length > 0)} 行待检查"); + } + catch (Exception exception) + { + MessageBox.Show( + this, + $"识别或整理失败:\n{exception.Message}", + "处理失败", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + SetStatus("处理失败,请检查原图和 OCR 状态"); + } + finally + { + SetBusy(false); + UpdateDiagnosticsForSelection(); + } + } + + private void ExtractAndBind() + { + TableLayoutProfile profile = CreateLayoutProfile(); + _lastExtraction = _extractor.Extract(_images.SelectMany(static image => image.Tokens), profile); + ApplyEmployeeNameCorrections(_lastExtraction.Candidates); + _lastBuild = _scheduleService.Build( + monthPicker.Value.Year, + monthPicker.Value.Month, + _lastExtraction.Candidates, + alignOcrRowsToCalendar: true, + ignoreWeekends: _settings.IgnoreWeekends); + CaptureDuplicateCandidates(_lastBuild, replace: true); + BindBuildResult(_lastBuild); + RememberAppliedSettings(); + _batchDiagnostics = FormatBatchDiagnostics(_lastExtraction, _lastBuild); + UpdateDiagnosticsForSelection(); + } + + private void employeeRosterButton_Click(object? sender, EventArgs e) + { + using EmployeeRosterDialog dialog = new( + _settings.EmployeeNames ?? [], + _settings.ConvertTraditionalToSimplified); + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + List previousNames = _settings.EmployeeNames ?? []; + List updatedNames = dialog.EmployeeNames.ToList(); + _settings.EmployeeNames = updatedNames; + if (!_settings.TrySave(out string? errorMessage)) + { + _settings.EmployeeNames = previousNames; + MessageBox.Show( + this, + $"员工名单无法保存,原配置未更改:\n{errorMessage}", + "保存失败", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + UpdateEmployeeRosterStatus(); + if (_lastExtraction is not null && !_hasManualEdits) + { + try { - ocrEngin = new OcrLite(); - await ocrEngin.InitModels(detPath, clsPath, recPath, keysPath, (int)numThreadNumeric.Value); + ExtractAndBind(); + SetStatus($"员工名单已保存({updatedNames.Count} 人),当前结果已重新匹配"); } - else + catch (Exception exception) { - MessageBox.Show("初始化失败,请确认模型文件夹和文件后,重新初始化!"); + SetStatus($"员工名单已保存({updatedNames.Count} 人),下次识别时生效"); + MessageBox.Show( + this, + $"名单已保存,但无法重新整理当前结果:\n{exception.Message}", + "重新匹配失败", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); } } + else + { + string suffix = _hasManualEdits ? ";为保留手工修改,将在下次识别时生效" : string.Empty; + SetStatus($"员工名单已保存({updatedNames.Count} 人){suffix}"); + } + } + + private void ApplyEmployeeNameCorrections(IEnumerable candidates) + { + EmployeeNameResolver resolver = new( + _settings.EmployeeNames ?? [], + _settings.ConvertTraditionalToSimplified); + _employeeNameCorrections.Clear(); + foreach (ScheduleCandidate candidate in candidates) + { + candidate.Role1 = ResolveEmployeeName(candidate, nameof(candidate.Role1), candidate.Role1, resolver); + candidate.Role2 = ResolveEmployeeName(candidate, nameof(candidate.Role2), candidate.Role2, resolver); + candidate.Role3 = ResolveEmployeeName(candidate, nameof(candidate.Role3), candidate.Role3, resolver); + candidate.Role4 = ResolveEmployeeName(candidate, nameof(candidate.Role4), candidate.Role4, resolver); + } + } + + private string ResolveEmployeeName( + ScheduleCandidate candidate, + string role, + string recognizedName, + EmployeeNameResolver resolver) + { + string correctedName = resolver.Resolve(recognizedName); + if (!string.Equals(recognizedName.Trim(), correctedName, StringComparison.Ordinal)) + { + _employeeNameCorrections.Add(new EmployeeNameCorrection( + candidate.SourceImageId, + candidate.RawDateText, + role, + recognizedName.Trim(), + correctedName)); + } + + return correctedName; + } + + private void UpdateEmployeeRosterStatus() + { + int count = _settings.EmployeeNames?.Count ?? 0; + employeeRosterStatusLabel.Text = count > 0 ? $"员工名单:已配置 {count} 人" : "员工名单:尚未配置"; + employeeRosterStatusLabel.ForeColor = count > 0 ? Color.DarkGreen : Color.DimGray; + } - private void initBtn_Click(object sender, EventArgs e) + private void convertTraditionalToSimplifiedCheckBox_CheckedChanged(object? sender, EventArgs e) + { + if (!_loaded || _suppressSettingEvents) { - string modelsDir = modelsTextBox.Text; - string detPath = modelsDir + "\\" + detNameTextBox.Text; - string clsPath = modelsDir + "\\" + clsNameTextBox.Text; - string recPath = modelsDir + "\\" + recNameTextBox.Text; - string keysPath = modelsDir + "\\" + keysNameTextBox.Text; - bool isDetExists = File.Exists(detPath); - if (!isDetExists) + return; + } + + bool previousValue = _settings.ConvertTraditionalToSimplified; + List previousNames = _settings.EmployeeNames ?? []; + _settings.ConvertTraditionalToSimplified = convertTraditionalToSimplifiedCheckBox.Checked; + _settings.EmployeeNames = EmployeeNameResolver + .NormalizeEmployeeNames(previousNames, _settings.ConvertTraditionalToSimplified) + .ToList(); + + if (!_settings.TrySave(out string? errorMessage)) + { + _settings.ConvertTraditionalToSimplified = previousValue; + _settings.EmployeeNames = previousNames; + _suppressSettingEvents = true; + convertTraditionalToSimplifiedCheckBox.Checked = previousValue; + _suppressSettingEvents = false; + MessageBox.Show( + this, + $"该选项无法保存,原配置未更改:\n{errorMessage}", + "保存失败", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + UpdateEmployeeRosterStatus(); + string stateText = _settings.ConvertTraditionalToSimplified ? "已开启" : "已关闭"; + if (_lastExtraction is not null && !_hasManualEdits) + { + try { - MessageBox.Show("模型文件不存在:" + detPath); + ExtractAndBind(); + SetStatus($"自动繁转简{stateText},当前结果已重新整理"); } - bool isClsExists = File.Exists(clsPath); - if (!isClsExists) + catch (Exception exception) { - MessageBox.Show("模型文件不存在:" + clsPath); + SetStatus($"自动繁转简{stateText},下次识别时生效"); + MessageBox.Show( + this, + $"选项已保存,但无法重新整理当前结果:\n{exception.Message}", + "重新整理失败", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); } - bool isRecExists = File.Exists(recPath); - if (!isRecExists) + } + else + { + string suffix = _hasManualEdits ? ";为保留手工修改,将在下次识别时生效" : string.Empty; + SetStatus($"自动繁转简{stateText}{suffix}"); + } + } + + private void ignoreWeekendsCheckBox_CheckedChanged(object? sender, EventArgs e) + { + if (!_loaded || _suppressSettingEvents) + { + return; + } + + bool previousValue = _settings.IgnoreWeekends; + if (_busy || (_lastExtraction is not null && !ConfirmDiscardManualEdits("更改周末忽略设置"))) + { + SetIgnoreWeekendsCheckBox(previousValue); + return; + } + + _settings.IgnoreWeekends = ignoreWeekendsCheckBox.Checked; + if (!_settings.TrySave(out string? errorMessage)) + { + _settings.IgnoreWeekends = previousValue; + SetIgnoreWeekendsCheckBox(previousValue); + MessageBox.Show( + this, + $"该选项无法保存,原配置未更改:\n{errorMessage}", + "保存失败", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + if (_lastExtraction is null) + { + SetStatus(_settings.IgnoreWeekends ? "已忽略周六和周日" : "已保留周六和周日"); + return; + } + + try + { + _lastBuild = _scheduleService.Build( + monthPicker.Value.Year, + monthPicker.Value.Month, + _lastExtraction.Candidates, + alignOcrRowsToCalendar: true, + ignoreWeekends: _settings.IgnoreWeekends); + CaptureDuplicateCandidates(_lastBuild, replace: true); + BindBuildResult(_lastBuild); + _batchDiagnostics = FormatBatchDiagnostics(_lastExtraction, _lastBuild); + UpdateDiagnosticsForSelection(); + SetStatus(_settings.IgnoreWeekends ? "已忽略周六和周日" : "已保留周六和周日"); + } + catch (Exception exception) + { + _settings.IgnoreWeekends = previousValue; + _settings.Save(); + SetIgnoreWeekendsCheckBox(previousValue); + MessageBox.Show( + this, + $"无法按新的周末设置重新整理:\n{exception.Message}", + "重新整理失败", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + + private void SetIgnoreWeekendsCheckBox(bool value) + { + _suppressSettingEvents = true; + ignoreWeekendsCheckBox.Checked = value; + _suppressSettingEvents = false; + } + + private TableLayoutProfile CreateLayoutProfile() => new() + { + Role3Column = Decimal.ToInt32(role3ColumnNumeric.Value), + Role4Column = Decimal.ToInt32(role4ColumnNumeric.Value), + }; + + internal static IEnumerable ConvertTokens(string sourcePath, Size imageSize, OcrResult result) + { + foreach (TextBlock block in result.TextBlocks) + { + if (string.IsNullOrWhiteSpace(block.Text) || block.BoxPoints.Count == 0) { - MessageBox.Show("模型文件不存在:" + recPath); + continue; } - bool isKeysExists = File.Exists(recPath); - if (!isKeysExists) + + float left = Math.Clamp(block.BoxPoints.Min(static point => point.X) - OcrPadding, 0, imageSize.Width); + float top = Math.Clamp(block.BoxPoints.Min(static point => point.Y) - OcrPadding, 0, imageSize.Height); + float right = Math.Clamp(block.BoxPoints.Max(static point => point.X) - OcrPadding, 0, imageSize.Width); + float bottom = Math.Clamp(block.BoxPoints.Max(static point => point.Y) - OcrPadding, 0, imageSize.Height); + if (right <= left || bottom <= top) { - MessageBox.Show("Keys文件不存在:" + keysPath); + continue; } - if (isDetExists && isClsExists && isRecExists && isKeysExists) + + OcrBounds bounds = OcrBounds.FromLTRB( + left / imageSize.Width, + top / imageSize.Height, + right / imageSize.Width, + bottom / imageSize.Height); + float characterConfidence = block.CharScores.Count > 0 + ? block.CharScores.Average() + : block.BoxScore; + float confidence = Math.Min(block.BoxScore, characterConfidence); + yield return new OcrToken(sourcePath, block.Text, bounds, confidence); + } + } + + private void BindBuildResult(ScheduleBuildResult build) + { + _editableRows.RaiseListChangedEvents = false; + _editableRows.Clear(); + foreach (ScheduleRow source in build.Rows) + { + _editableRows.Add(CreateEditableRow(source)); + } + + foreach (ScheduleCandidate candidate in build.UnlocatedCandidates) + { + _editableRows.Add(CreateEditableUnlocatedRow(candidate)); + } + + _editableRows.RaiseListChangedEvents = true; + _rowBinding.ResetBindings(false); + + int issueRows = _editableRows.Count(static row => row.IssueText.Length > 0); + int extractionIssues = _lastExtraction?.Issues.Count ?? 0; + int batchIssues = extractionIssues + build.Issues.Count + build.UnlocatedCandidates.Count; + resultSummaryLabel.Text = batchIssues > 0 + ? $"共 {build.Rows.Count} 个日期,另显示 {build.UnlocatedCandidates.Count} 条未定位记录;{issueRows} 行及 {extractionIssues + build.Issues.Count} 个批次异常待检查。" + : $"共 {build.Rows.Count} 个日期,{issueRows} 行待检查。可直接修改单元格,完成后导出 Excel。"; + revalidateButton.Enabled = _editableRows.Count > 0 && !_busy; + exportButton.Enabled = _editableRows.Count > 0 && !_busy; + scheduleGrid.ClearSelection(); + _hasManualEdits = false; + } + + private static EditableScheduleRow CreateEditableRow(ScheduleRow source) + { + EditableScheduleRow row = new() + { + DateText = source.RawDateText, + Weekday = source.Weekday, + Role1 = source.Role1, + Role2 = source.Role2, + Role3 = source.Role3, + Role4 = source.Role4, + SourceText = source.SourceImageId, + IsMissingPlaceholder = source.IsMissingPlaceholder, + IssueText = string.Join(";", source.Issues.Select(static issue => issue.Message).Distinct()), + }; + PopulateIssueMetadata(row, source.Issues); + if (source.Issues.Any(static issue => issue.Code == ScheduleIssueCode.MissingWorkday)) + { + row.InvalidProperties.UnionWith( + [nameof(EditableScheduleRow.DateText), nameof(EditableScheduleRow.Role1), nameof(EditableScheduleRow.Role2), nameof(EditableScheduleRow.Role3), nameof(EditableScheduleRow.Role4)]); + } + + return row; + } + + private static EditableScheduleRow CreateEditableUnlocatedRow(ScheduleCandidate source) + { + EditableScheduleRow row = new() + { + DateText = source.RawDateText, + Weekday = source.RawWeekdayText, + Role1 = source.Role1, + Role2 = source.Role2, + Role3 = source.Role3, + Role4 = source.Role4, + SourceText = source.SourceImageId, + IsUnlocatedCandidate = true, + IssueText = $"未定位记录:{string.Join(";", source.Issues.Select(static issue => issue.Message).Distinct())}", + }; + PopulateIssueMetadata(row, source.Issues); + row.InvalidProperties.Add(nameof(EditableScheduleRow.DateText)); + return row; + } + + private static void PopulateIssueMetadata(EditableScheduleRow row, IEnumerable issues) + { + foreach (ScheduleIssue issue in issues) + { + string? property = MapIssueProperty(issue.PropertyName); + if (property is not null) { - ocrEngin = new OcrLite(); - ocrEngin.InitModels(detPath, clsPath, recPath, keysPath, (int)numThreadNumeric.Value); + row.InvalidProperties.Add(property); } - else + + if (IsPersistentUiIssue(issue.Code)) { - MessageBox.Show("初始化失败,请确认模型文件夹和文件后,重新初始化!"); + row.PersistentIssues.Add(issue); } } + } + + private static bool IsPersistentUiIssue(ScheduleIssueCode code) => code is + ScheduleIssueCode.DuplicateDate or + ScheduleIssueCode.AmbiguousRow or + ScheduleIssueCode.AmbiguousColumn or + ScheduleIssueCode.LowOcrConfidence or + ScheduleIssueCode.InsufficientColumns; + + private void revalidateButton_Click(object? sender, EventArgs e) + { + RevalidateEditedRows(); + SetStatus("已根据当前表格内容重新排序并校验"); + } + + private void RevalidateEditedRows() + { + scheduleGrid.EndEdit(); + _rowBinding.EndEdit(); + List edited = []; + foreach (EditableScheduleRow row in _editableRows) + { + ScheduleRow coreRow = new() + { + RawDateText = row.DateText, + Weekday = row.Weekday, + Role1 = row.Role1, + Role2 = row.Role2, + Role3 = row.Role3, + Role4 = row.Role4, + SourceImageId = row.SourceText, + IsMissingPlaceholder = row.IsMissingPlaceholder, + }; + coreRow.Issues.AddRange(row.PersistentIssues); + edited.Add(coreRow); + } + _lastBuild = _scheduleService.Revalidate( + monthPicker.Value.Year, + monthPicker.Value.Month, + edited, + ignoreWeekends: _settings.IgnoreWeekends); + CaptureDuplicateCandidates(_lastBuild, replace: false); + BindBuildResult(_lastBuild); + if (_lastExtraction is not null) + { + _batchDiagnostics = FormatBatchDiagnostics(_lastExtraction, _lastBuild); + UpdateDiagnosticsForSelection(); + } + } - private void openBtn_Click(object sender, EventArgs e) + private void exportButton_Click(object? sender, EventArgs e) + { + try { - using (var dlg = new OpenFileDialog()) + RevalidateEditedRows(); + int issueRows = _editableRows.Count(static row => row.IssueText.Length > 0); + int batchIssues = (_lastExtraction?.Issues.Count ?? 0) + (_lastBuild?.Issues.Count ?? 0); + if (issueRows > 0 || batchIssues > 0) { - dlg.Multiselect = false; - dlg.Filter = "(*.JPG,*.PNG,*.JPEG,*.BMP,*.GIF)|*.JPG;*.PNG;*.JPEG;*.BMP;*.GIF|All files(*.*)|*.*"; - if (dlg.ShowDialog() == DialogResult.OK && !string.IsNullOrEmpty(dlg.FileName)) + DialogResult decision = MessageBox.Show( + this, + $"仍有 {issueRows} 行和 {batchIssues} 个批次级异常待检查。\n\n是否仍按当前可编辑结果导出?", + "存在待检查内容", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (decision != DialogResult.Yes) { - pathTextBox.Text = dlg.FileName; - //Mat src = CvInvoke.Imread(dlg.FileName, ImreadModes.ColorRgb); - //pictureBox.Image = src.ToImage(false); - using (Mat src = CvInvoke.Imread(dlg.FileName, ImreadModes.ColorRgb)) - { - pictureBox.Image = src.ToBitmap(); - } + return; } } - } - private void modelsBtn_Click(object sender, EventArgs e) - { - using (var dlg = new FolderBrowserDialog()) + List rows = []; + foreach (EditableScheduleRow row in _editableRows.Where(static row => !row.IsUnlocatedCandidate)) { - dlg.SelectedPath = Environment.CurrentDirectory + "\\models"; - if (dlg.ShowDialog() == DialogResult.OK && !string.IsNullOrEmpty(dlg.SelectedPath)) + if (!DateTextParser.TryCreateDate(monthPicker.Value.Year, row.DateText, out DateOnly date) || + date.Month != monthPicker.Value.Month) { - modelsTextBox.Text = dlg.SelectedPath; + throw new InvalidOperationException($"日期“{row.DateText}”无效或不属于目标月份,请先修正。 "); } + + rows.Add(new ExportRow(date, row.Role1, row.Role2, row.Role3, row.Role4)); + } + + using SaveFileDialog dialog = new() + { + Filter = "Excel 工作簿 (*.xlsx)|*.xlsx", + AddExtension = true, + DefaultExt = "xlsx", + FileName = $"排班表_{monthPicker.Value:yyyy_MM}.xlsx", + OverwritePrompt = true, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; } + + ScheduleExcelExporter.Export(dialog.FileName, rows); + SetStatus($"已导出:{dialog.FileName}"); + MessageBox.Show( + this, + $"Excel 已导出:\n{dialog.FileName}", + "导出完成", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + } + catch (Exception exception) + { + MessageBox.Show( + this, + $"导出失败:\n{exception.Message}", + "无法导出", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } + + private void scheduleGrid_CellEndEdit(object? sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 || e.RowIndex >= _editableRows.Count) + { + return; } - private void detectBtn_Click(object sender, EventArgs e) + EditableScheduleRow row = _editableRows[e.RowIndex]; + if (!row.IssueText.Contains("内容已修改", StringComparison.Ordinal)) + { + row.IssueText = string.IsNullOrWhiteSpace(row.IssueText) + ? "内容已修改,请重新校验" + : $"{row.IssueText};内容已修改,请重新校验"; + } + + _hasManualEdits = true; + scheduleGrid.InvalidateRow(e.RowIndex); + } + + private void scheduleGrid_CellFormatting(object? sender, DataGridViewCellFormattingEventArgs e) + { + if (e.RowIndex < 0 || e.RowIndex >= _editableRows.Count || e.ColumnIndex < 0) + { + return; + } + + EditableScheduleRow row = _editableRows[e.RowIndex]; + string propertyName = scheduleGrid.Columns[e.ColumnIndex].DataPropertyName; + if (row.InvalidProperties.Contains(propertyName)) + { + e.CellStyle.BackColor = Color.MistyRose; + e.CellStyle.ForeColor = Color.DarkRed; + } + else if (propertyName == nameof(EditableScheduleRow.IssueText) && row.IssueText.Length > 0) + { + e.CellStyle.BackColor = Color.LemonChiffon; + e.CellStyle.ForeColor = Color.FromArgb(128, 74, 0); + } + else if (row.IsMissingPlaceholder) + { + e.CellStyle.BackColor = Color.FromArgb(255, 250, 225); + } + else if (row.IsUnlocatedCandidate) + { + e.CellStyle.BackColor = Color.FromArgb(255, 235, 235); + } + } + + private void scheduleGrid_DataError(object? sender, DataGridViewDataErrorEventArgs e) + { + e.ThrowException = false; + SetStatus($"单元格内容无效:{e.Exception?.Message}"); + } + + private void imageListView_SelectedIndexChanged(object? sender, EventArgs e) + { + if (imageListView.SelectedItems.Count != 1 || imageListView.SelectedItems[0].Tag is not ImageWorkItem image) + { + return; + } + + try + { + ReplacePreviewImage(LoadBitmapWithoutLock(image.FilePath)); + } + catch (Exception exception) { - if (ocrEngin == null) + ReplacePreviewImage(null); + SetStatus($"原图预览失败:{exception.Message}"); + } + + UpdateDiagnosticsForSelection(); + } + + private void monthPicker_ValueChanged(object? sender, EventArgs e) + { + if (_suppressSettingEvents || !_loaded) + { + return; + } + + if (_lastExtraction is null) + { + _appliedMonth = FirstDayOfMonth(monthPicker.Value); + return; + } + + if (_busy || !ConfirmDiscardManualEdits("更改目标月份")) + { + RevertMonthSetting(); + return; + } + + try + { + _lastBuild = _scheduleService.Build( + monthPicker.Value.Year, + monthPicker.Value.Month, + _lastExtraction.Candidates, + alignOcrRowsToCalendar: true, + ignoreWeekends: _settings.IgnoreWeekends); + CaptureDuplicateCandidates(_lastBuild, replace: true); + BindBuildResult(_lastBuild); + _appliedMonth = FirstDayOfMonth(monthPicker.Value); + _batchDiagnostics = FormatBatchDiagnostics(_lastExtraction, _lastBuild); + UpdateDiagnosticsForSelection(); + SetStatus($"已按 {monthPicker.Value:yyyy 年 M 月} 重新整理"); + } + catch (Exception exception) + { + RevertMonthSetting(); + MessageBox.Show(this, $"无法切换目标月份:\n{exception.Message}", "月份无效", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void layoutColumnNumeric_ValueChanged(object? sender, EventArgs e) + { + if (_suppressSettingEvents) + { + return; + } + + if (sender is NumericUpDown numeric && numeric.Value == 0) + { + _suppressSettingEvents = true; + numeric.Value = ReferenceEquals(numeric, role3ColumnNumeric) ? -2 : -1; + _suppressSettingEvents = false; + return; + } + + if (!_loaded) + { + return; + } + + try + { + CreateLayoutProfile().Validate(); + } + catch (Exception exception) + { + RevertColumnSettings(); + MessageBox.Show(this, $"列配置无效:\n{exception.Message}", "无法应用列配置", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (!ConfirmDiscardManualEdits("更改目标列")) + { + RevertColumnSettings(); + return; + } + + if (!_busy && _images.Any(static image => image.Tokens.Count > 0)) + { + try { - MessageBox.Show("未初始化,无法执行!"); - return; + ExtractAndBind(); + SetStatus("已按新的右侧列序号重新整理,无需重复 OCR"); } - string targetImg = pathTextBox.Text; - if (!File.Exists(targetImg)) + catch (Exception exception) { - MessageBox.Show("目标图片不存在,请用Open按钮打开"); + RevertColumnSettings(); + ClearResults(); + MessageBox.Show(this, $"无法应用列映射:\n{exception.Message}", "列映射失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } - int padding = (int)paddingNumeric.Value; - int imgResize = (int)imgResizeNumeric.Value; - float boxScoreThresh = (float)boxScoreThreshNumeric.Value; - float boxThresh = (float)boxThreshNumeric.Value; - float unClipRatio = (float)unClipRatioNumeric.Value; - bool doAngle = doAngleCheckBox.Checked; - bool mostAngle = mostAngleCheckBox.Checked; - OcrResult ocrResult = ocrEngin.Detect(pathTextBox.Text, padding, imgResize, boxScoreThresh, boxThresh, unClipRatio, doAngle, mostAngle); - ocrResultTextBox.Text = ocrResult.ToString(); - strRestTextBox.Text = ocrResult.StrRes; - pictureBox.Image = ocrResult.BoxImg.ToBitmap(); } + else + { + _appliedRole3Column = Decimal.ToInt32(role3ColumnNumeric.Value); + _appliedRole4Column = Decimal.ToInt32(role4ColumnNumeric.Value); + } + + _settings.Role3Column = Decimal.ToInt32(role3ColumnNumeric.Value); + _settings.Role4Column = Decimal.ToInt32(role4ColumnNumeric.Value); + _settings.Save(); + } + + private void RefreshImageList() + { + imageListView.BeginUpdate(); + imageListView.Items.Clear(); + for (int index = 0; index < _images.Count; index++) + { + ImageWorkItem image = _images[index]; + ListViewItem item = new((index + 1).ToString()) { Tag = image, ToolTipText = image.FilePath }; + item.SubItems.Add(image.DisplayName); + item.SubItems.Add(image.Status); + imageListView.Items.Add(item); + } + + imageListView.EndUpdate(); + } - private void partImgCheckBox_CheckedChanged(object sender, EventArgs e) + private void UpdateImageListStatus(int index) + { + if (index >= 0 && index < imageListView.Items.Count) { - ocrEngin!.isPartImg = partImgCheckBox.Checked; + imageListView.Items[index].SubItems[2].Text = _images[index].Status; } + } - private void debugCheckBox_CheckedChanged(object sender, EventArgs e) + private void UpdateDiagnosticsForSelection() + { + ImageWorkItem? image = imageListView.SelectedItems.Count == 1 + ? imageListView.SelectedItems[0].Tag as ImageWorkItem + : null; + StringBuilder builder = new(); + if (image is not null) { - ocrEngin!.isDebugImg = debugCheckBox.Checked; + builder.AppendLine(image.FilePath); + builder.AppendLine(new string('=', 72)); + builder.AppendLine(string.IsNullOrWhiteSpace(image.Diagnostics) ? "尚未识别。" : image.Diagnostics); } - private void pictureBox_MouseDoubleClick(object sender, MouseEventArgs e) + if (!string.IsNullOrWhiteSpace(_batchDiagnostics)) { - if (pictureBox.Image == null) - return; - Mat mat = new Mat(); - using (var bmp = new Bitmap(pictureBox.Image)) + builder.AppendLine(); + builder.AppendLine("批次整理诊断"); + builder.AppendLine(new string('=', 72)); + builder.Append(_batchDiagnostics); + } + + diagnosticsTextBox.Text = builder.ToString(); + } + + private static string FormatOcrDiagnostics(ImageWorkItem image, OcrResult result) + { + StringBuilder builder = new(); + builder.AppendLine($"OCR 用时:{result.DetectTime:N0} ms;有效文本框:{image.Tokens.Count}"); + foreach (OcrToken token in image.Tokens.OrderBy(static token => token.Bounds.CenterY).ThenBy(static token => token.Bounds.CenterX)) + { + builder.AppendLine( + $"[{token.Confidence,5:P0}] x={token.Bounds.CenterX:F3}, y={token.Bounds.CenterY:F3} {token.Text}"); + } + + return builder.ToString(); + } + + private string FormatBatchDiagnostics(ScheduleExtractionResult extraction, ScheduleBuildResult build) + { + StringBuilder builder = new(); + builder.AppendLine($"候选记录:{extraction.Candidates.Count}"); + builder.AppendLine($"未分配 OCR 项:{extraction.UnassignedTokens.Count}"); + builder.AppendLine($"目标日期:{build.Rows.Count}"); + builder.AppendLine($"未定位/跨月记录:{build.UnlocatedCandidates.Count}"); + builder.AppendLine($"同日重复候选:{_duplicateCandidatesForReview.Count}"); + builder.AppendLine($"姓名自动规范:{_employeeNameCorrections.Count}"); + + foreach (EmployeeNameCorrection correction in _employeeNameCorrections) + { + builder.AppendLine( + $"[姓名规范] {Path.GetFileName(correction.SourceImageId)} {correction.RawDateText} {correction.Role}: “{correction.OriginalName}” → “{correction.CorrectedName}”"); + } + + IEnumerable batchIssues = extraction.Issues.Concat(build.Issues); + foreach (ScheduleIssue issue in batchIssues) + { + builder.AppendLine($"[批次异常/{issue.Code}] {issue.Message}"); + } + + foreach (ScheduleCandidate candidate in build.UnlocatedCandidates) + { + string issues = string.Join(";", candidate.Issues.Select(static issue => issue.Message).Distinct()); + builder.AppendLine( + $"[未定位] {Path.GetFileName(candidate.SourceImageId)} y={candidate.RowCenterY:F3} 日期“{candidate.RawDateText}” {issues}"); + } + + foreach (ScheduleCandidate candidate in _duplicateCandidatesForReview) + { + builder.AppendLine( + $"[重复候选 {candidate.RawDateText}] {Path.GetFileName(candidate.SourceImageId)} | {candidate.RawWeekdayText} | {candidate.Role1} | {candidate.Role2} | {candidate.Role3} | {candidate.Role4}"); + } + + foreach (ScheduleRow row in build.Rows.Where(static row => row.Issues.Count > 0)) + { + builder.AppendLine( + $"[{row.RawDateText}] {string.Join(";", row.Issues.Select(static issue => issue.Message).Distinct())}"); + } + + return builder.ToString(); + } + + private void CaptureDuplicateCandidates(ScheduleBuildResult build, bool replace) + { + if (replace) + { + _duplicateCandidatesForReview.Clear(); + } + + foreach (ScheduleCandidate candidate in build.DuplicateCandidates) + { + bool alreadyPresent = _duplicateCandidatesForReview.Any(existing => + string.Equals(existing.SourceImageId, candidate.SourceImageId, StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.RawDateText, candidate.RawDateText, StringComparison.Ordinal) && + string.Equals(existing.Role1, candidate.Role1, StringComparison.Ordinal) && + string.Equals(existing.Role2, candidate.Role2, StringComparison.Ordinal) && + string.Equals(existing.Role3, candidate.Role3, StringComparison.Ordinal) && + string.Equals(existing.Role4, candidate.Role4, StringComparison.Ordinal)); + if (!alreadyPresent) { - mat = bmp.ToMat(); - CvInvoke.NamedWindow("img", Emgu.CV.CvEnum.WindowFlags.Normal); - CvInvoke.Imshow("img", mat); + _duplicateCandidatesForReview.Add(candidate); } } } + + private void ClearResults() + { + _lastExtraction = null; + _lastBuild = null; + _batchDiagnostics = string.Empty; + _duplicateCandidatesForReview.Clear(); + _employeeNameCorrections.Clear(); + _editableRows.Clear(); + _rowBinding.ResetBindings(false); + resultSummaryLabel.Text = "尚未整理。异常单元格将用浅红色标记,可直接双击修改。"; + revalidateButton.Enabled = false; + exportButton.Enabled = false; + _hasManualEdits = false; + } + + private bool ConfirmDiscardManualEdits(string action) + { + if (!_hasManualEdits) + { + return true; + } + + return MessageBox.Show( + this, + $"当前表格包含尚未导出的手工修改。{action}会按 OCR 原始结果重新整理并覆盖这些修改。\n\n是否继续?", + "确认覆盖手工修改", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2) == DialogResult.Yes; + } + + private void RememberAppliedSettings() + { + _appliedMonth = FirstDayOfMonth(monthPicker.Value); + _appliedRole3Column = Decimal.ToInt32(role3ColumnNumeric.Value); + _appliedRole4Column = Decimal.ToInt32(role4ColumnNumeric.Value); + _hasManualEdits = false; + } + + private void RevertMonthSetting() + { + _suppressSettingEvents = true; + monthPicker.Value = _appliedMonth < monthPicker.MinDate ? monthPicker.MinDate : _appliedMonth; + _suppressSettingEvents = false; + } + + private void RevertColumnSettings() + { + _suppressSettingEvents = true; + role3ColumnNumeric.Value = _appliedRole3Column; + role4ColumnNumeric.Value = _appliedRole4Column; + _suppressSettingEvents = false; + } + + private static DateTime FirstDayOfMonth(DateTime value) => new(value.Year, value.Month, 1); + + private void SetBusy(bool busy, string? message = null) + { + _busy = busy; + UseWaitCursor = busy; + progressBar.Visible = busy; + selectImagesButton.Enabled = !busy; + clearImagesButton.Enabled = !busy && _images.Count > 0; + initializeModelButton.Enabled = !busy; + monthPicker.Enabled = !busy; + role3ColumnNumeric.Enabled = !busy; + role4ColumnNumeric.Enabled = !busy; + ignoreWeekendsCheckBox.Enabled = !busy; + convertTraditionalToSimplifiedCheckBox.Enabled = !busy; + employeeRosterButton.Enabled = !busy; + scheduleGrid.ReadOnly = busy; + if (message is not null) + { + SetStatus(message); + } + + UpdateActionState(); + } + + private void UpdateActionState() + { + processButton.Enabled = !_busy && _ocrReady && _images.Count == 4; + clearImagesButton.Enabled = !_busy && _images.Count > 0; + revalidateButton.Enabled = !_busy && _editableRows.Count > 0; + exportButton.Enabled = !_busy && _editableRows.Count > 0; + } + + private void SetModelState(bool ready, string text) + { + _ocrReady = ready; + modelStatusLabel.Text = text; + modelStatusLabel.ForeColor = ready ? Color.DarkGreen : Color.DarkRed; + modelStateLabel.Text = ready ? "● OCR 模型已就绪" : "● OCR 模型未就绪"; + modelStateLabel.ForeColor = ready ? Color.FromArgb(124, 225, 154) : Color.FromArgb(255, 214, 102); + UpdateActionState(); + } + + private void SetStatus(string text) => statusLabel.Text = text; + + private static decimal NormalizeColumnSetting(int value, int fallback) => + value is >= -50 and <= 50 && value != 0 ? value : fallback; + + private static string? MapIssueProperty(string? propertyName) => propertyName switch + { + nameof(ScheduleRow.RawDateText) => nameof(EditableScheduleRow.DateText), + nameof(ScheduleRow.Weekday) => nameof(EditableScheduleRow.Weekday), + nameof(ScheduleRow.Role1) => nameof(EditableScheduleRow.Role1), + nameof(ScheduleRow.Role2) => nameof(EditableScheduleRow.Role2), + nameof(ScheduleRow.Role3) => nameof(EditableScheduleRow.Role3), + nameof(ScheduleRow.Role4) => nameof(EditableScheduleRow.Role4), + _ => null, + }; + + private sealed record EmployeeNameCorrection( + string SourceImageId, + string RawDateText, + string Role, + string OriginalName, + string CorrectedName); + + internal static Size ReadImageSize(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using Image image = Image.FromStream(stream, useEmbeddedColorManagement: false, validateImageData: false); + return image.Size; + } + + private static Bitmap LoadBitmapWithoutLock(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using Image source = Image.FromStream(stream, useEmbeddedColorManagement: true, validateImageData: false); + return new Bitmap(source); + } + + private void ReplacePreviewImage(Image? nextImage) + { + Image? previous = previewPictureBox.Image; + previewPictureBox.Image = nextImage; + previous?.Dispose(); + } } diff --git a/RapidOCRWinform/Program.cs b/RapidOCRWinform/Program.cs index afd21bf..21c4c04 100644 --- a/RapidOCRWinform/Program.cs +++ b/RapidOCRWinform/Program.cs @@ -1,3 +1,8 @@ +using RapidOCRLib; +using RapidOCRLib.Models; +using RapidOCRSchedule.Core; +using System.Globalization; + namespace RapidOCRWinform { internal static class Program @@ -6,12 +11,167 @@ internal static class Program /// The main entry point for the application. /// [STAThread] - static void Main() + static int Main(string[] args) { // To customize application configuration such as set high DPI settings or default font, // see https://aka.ms/applicationconfiguration. ApplicationConfiguration.Initialize(); + + if (args.Any(argument => string.Equals(argument, "--self-test", StringComparison.OrdinalIgnoreCase))) + { + return RunSelfTestAsync().GetAwaiter().GetResult(); + } + + if (args.Length > 1 && string.Equals(args[0], "--diagnose-images", StringComparison.OrdinalIgnoreCase)) + { + return DiagnoseImagesAsync(args.Skip(1)).GetAwaiter().GetResult(); + } + Application.Run(new FormOcr()); + return 0; + } + + private static async Task RunSelfTestAsync() + { + try + { + string modelDirectory = EmbeddedModelProvider.EnsureExtracted(); + using OcrLite engine = new(); + int threadCount = Math.Max(1, (int)Math.Ceiling(Environment.ProcessorCount * 0.7)); + await engine.InitModels( + Path.Combine(modelDirectory, "ch_PP-OCRv5_mobile_det.onnx"), + Path.Combine(modelDirectory, "ch_ppocr_mobile_v2.0_cls_infer.onnx"), + Path.Combine(modelDirectory, "ch_PP-OCRv5_rec_mobile_infer.onnx"), + Path.Combine(modelDirectory, "ppocrv5_dict.txt"), + threadCount); + + using Bitmap sample = new(128, 64); + using (Graphics graphics = Graphics.FromImage(sample)) + { + graphics.Clear(Color.White); + } + + var result = engine.Detect( + sample, + padding: 0, + maxSideLen: 128, + doAngle: false); + try + { + return result is null ? 2 : 0; + } + finally + { + result?.BoxImg?.Dispose(); + } + } + catch + { + return 1; + } + } + + private static async Task DiagnoseImagesAsync(IEnumerable imagePaths) + { + try + { + string[] arguments = imagePaths.ToArray(); + string? targetArgument = arguments.FirstOrDefault(static argument => + argument.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)); + DateOnly? target = null; + if (targetArgument is not null) + { + string value = targetArgument[(targetArgument.IndexOf('=') + 1)..]; + if (!DateOnly.TryParseExact( + $"{value}-01", + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateOnly parsedTarget)) + { + throw new ArgumentException($"Invalid diagnostic target month: {value}"); + } + + target = parsedTarget; + } + + string[] paths = arguments + .Where(static argument => !argument.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFullPath) + .ToArray(); + string modelDirectory = EmbeddedModelProvider.EnsureExtracted(); + using OcrLite engine = new(); + int threadCount = Math.Max(1, (int)Math.Ceiling(Environment.ProcessorCount * 0.7)); + await engine.InitModels( + Path.Combine(modelDirectory, "ch_PP-OCRv5_mobile_det.onnx"), + Path.Combine(modelDirectory, "ch_ppocr_mobile_v2.0_cls_infer.onnx"), + Path.Combine(modelDirectory, "ch_PP-OCRv5_rec_mobile_infer.onnx"), + Path.Combine(modelDirectory, "ppocrv5_dict.txt"), + threadCount); + + List tokens = []; + foreach (string path in paths) + { + Size imageSize = FormOcr.ReadImageSize(path); + OcrResult? result = null; + try + { + result = await engine.DetectAsync( + path, + FormOcr.OcrPadding, + FormOcr.OcrMaximumSideLength, + FormOcr.OcrBoxScoreThreshold, + FormOcr.OcrBoxThreshold, + FormOcr.OcrUnclipRatio, + doAngle: false, + mostAngle: false); + OcrToken[] imageTokens = FormOcr.ConvertTokens(path, imageSize, result).ToArray(); + tokens.AddRange(imageTokens); + foreach (OcrToken token in imageTokens.OrderBy(static token => token.Bounds.CenterY).ThenBy(static token => token.Bounds.CenterX)) + { + Console.WriteLine( + $"TOKEN|{Path.GetFileName(path)}|{token.Bounds.CenterX:F4}|{token.Bounds.CenterY:F4}|{token.Confidence:F4}|{token.Text}"); + } + } + finally + { + result?.BoxImg?.Dispose(); + } + } + + ScheduleExtractionResult extraction = new ScheduleExtractor().Extract(tokens); + foreach (ScheduleCandidate candidate in extraction.Candidates.OrderBy(static candidate => candidate.SourceImageId).ThenBy(static candidate => candidate.RowCenterY)) + { + Console.WriteLine( + $"CANDIDATE|{Path.GetFileName(candidate.SourceImageId)}|{candidate.RowCenterY:F4}|{candidate.RawDateText}|{candidate.RawWeekdayText}|{candidate.Role1}|{candidate.Role2}|{candidate.Role3}|{candidate.Role4}"); + } + + if (target is DateOnly targetMonth) + { + ScheduleBuildResult build = new ScheduleService().Build( + targetMonth.Year, + targetMonth.Month, + extraction.Candidates, + alignOcrRowsToCalendar: true); + foreach (ScheduleRow row in build.Rows.OrderBy(static row => row.Date)) + { + Console.WriteLine( + $"ROW|{row.RawDateText}|{row.Weekday}|{row.Role1}|{row.Role2}|{row.Role3}|{row.Role4}|missing={row.IsMissingPlaceholder}"); + } + + Console.WriteLine( + $"BUILD|rows={build.Rows.Count}|unlocated={build.UnlocatedCandidates.Count}|weekendsExcluded={build.WeekendCandidates.Count}"); + } + + Console.WriteLine( + $"SUMMARY|tokens={tokens.Count}|candidates={extraction.Candidates.Count}|unassigned={extraction.UnassignedTokens.Count}"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } } } -} \ No newline at end of file +} diff --git a/RapidOCRWinform/Properties/PublishProfiles/StandaloneWinX64.pubxml b/RapidOCRWinform/Properties/PublishProfiles/StandaloneWinX64.pubxml new file mode 100644 index 0000000..c79bcd1 --- /dev/null +++ b/RapidOCRWinform/Properties/PublishProfiles/StandaloneWinX64.pubxml @@ -0,0 +1,18 @@ + + + + Release + Any CPU + win-x64 + true + true + true + false + true + false + false + None + false + false + + diff --git a/RapidOCRWinform/RapidOCRWinform.csproj b/RapidOCRWinform/RapidOCRWinform.csproj index 2bc9664..d123c33 100644 --- a/RapidOCRWinform/RapidOCRWinform.csproj +++ b/RapidOCRWinform/RapidOCRWinform.csproj @@ -6,10 +6,35 @@ enable true enable + 排班宝 + 排班宝 + 排班宝 + ..\Assets\mobao.ico + + - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/RapidOCRWinform/UiModels.cs b/RapidOCRWinform/UiModels.cs new file mode 100644 index 0000000..e631279 --- /dev/null +++ b/RapidOCRWinform/UiModels.cs @@ -0,0 +1,94 @@ +using RapidOCRSchedule.Core; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace RapidOCRWinform; + +internal sealed class ImageWorkItem +{ + public required string FilePath { get; init; } + + public string DisplayName => Path.GetFileName(FilePath); + + public string Status { get; set; } = "待识别"; + + public string Diagnostics { get; set; } = string.Empty; + + public List Tokens { get; } = []; +} + +internal sealed class EditableScheduleRow : INotifyPropertyChanged +{ + private string _dateText = string.Empty; + private string _weekday = string.Empty; + private string _role1 = string.Empty; + private string _role2 = string.Empty; + private string _role3 = string.Empty; + private string _role4 = string.Empty; + private string _issueText = string.Empty; + + public string DateText + { + get => _dateText; + set => SetField(ref _dateText, value?.Trim() ?? string.Empty); + } + + public string Weekday + { + get => _weekday; + set => SetField(ref _weekday, value?.Trim() ?? string.Empty); + } + + public string Role1 + { + get => _role1; + set => SetField(ref _role1, value?.Trim() ?? string.Empty); + } + + public string Role2 + { + get => _role2; + set => SetField(ref _role2, value?.Trim() ?? string.Empty); + } + + public string Role3 + { + get => _role3; + set => SetField(ref _role3, value?.Trim() ?? string.Empty); + } + + public string Role4 + { + get => _role4; + set => SetField(ref _role4, value?.Trim() ?? string.Empty); + } + + public string IssueText + { + get => _issueText; + set => SetField(ref _issueText, value ?? string.Empty); + } + + public string SourceText { get; set; } = string.Empty; + + public bool IsMissingPlaceholder { get; set; } + + public bool IsUnlocatedCandidate { get; set; } + + public HashSet InvalidProperties { get; } = new(StringComparer.OrdinalIgnoreCase); + + public List PersistentIssues { get; } = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void SetField(ref string field, string value, [CallerMemberName] string? propertyName = null) + { + if (string.Equals(field, value, StringComparison.Ordinal)) + { + return; + } + + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git "a/\351\234\200\346\261\202.md" "b/\351\234\200\346\261\202.md" new file mode 100644 index 0000000..6f81e3b --- /dev/null +++ "b/\351\234\200\346\261\202.md" @@ -0,0 +1,130 @@ +# 本地排班图片整理需求 + +## 一、目标 + +用户一次选择4张排班表图片,系统从每张图片中提取指定列,将4张图片中按4天间隔排列的日期记录合并,生成一张按工作日连续排列的简化排班表。 + +## 二、原始图片特点 + +1. 4张图片的表格结构相同,但日期和人员姓名不同。 +2. 每张图片只包含部分日期,同一张图片中的日期通常每隔4天出现一次。 +3. 4张图片合并后,可以覆盖整个月的日期。 +4. 每个日期占一整行,该日期对应的所有岗位人员都在同一行。 +5. 表格中存在大量无关岗位列,只需要提取指定的4个岗位。 +6. 图片中可能存在手绘圈线、标记等内容,这些内容不属于表格数据。 + +## 三、需要提取的字段 + +每个日期提取以下内容: + +| 输出字段 | 原始表格位置 | +| ------- | ---------- | +| 日期 | 第一列 | +| 星期 | 第二列 | +| 岗位一(队长) | 第三列 | +| 岗位二 | 第四列 | +| 岗位三 | 右侧指定区域的第一列 | +| 岗位四 | 右侧指定区域的第二列 | + +说明: + +* 岗位一就是队长。 +* 即使岗位一整列都是同一个人,也要在每天的记录中正常保留姓名。 +* 岗位二可能由两名或多名人员按照日期轮换,需要按每行实际内容提取。 +* 岗位三和岗位四也必须按照每个日期所在行分别提取。 +* 其余人员列全部忽略。 + +## 四、日期合并规则 + +不能按照图片顺序直接拼接数据。 + +需要先将4张图片中的每一行分别识别为一条独立记录,再根据日期合并排序。 + +例如,一张图片中可能包含: + +```text +8/2 +8/6 +8/10 +8/14 +8/18 +8/22 +8/26 +8/30 +``` + +其他图片会包含穿插在这些日期之间的日期。 + +合并后的处理顺序为: + +```text +读取4张图片中的全部日期记录 +→ 按日期合并 +→ 按日期从小到大排序 +→ 只保留工作日 +``` + +不依赖图片上传顺序,也不依赖人员姓名判断日期归属。 + +## 五、工作日规则 + +最终结果只保留星期一至星期五。 + +星期六和星期日的数据不输出。 + +例如8月份应整理为: + +```text +8月3日—8月7日 +8月10日—8月14日 +8月17日—8月21日 +8月24日—8月28日 +``` + +如果该月还有其他工作日,例如8月31日,也需要继续保留。 + +## 六、输出表格 + +最终生成一张简化表格,每个工作日一行: + +| 日期 | 星期 | 岗位一(队长) | 岗位二 | 岗位三 | 岗位四 | +| --- | --- | ------- | --- | --- | --- | +| 8/3 | 星期一 | 姓名 | 姓名 | 姓名 | 姓名 | +| 8/4 | 星期二 | 姓名 | 姓名 | 姓名 | 姓名 | +| 8/5 | 星期三 | 姓名 | 姓名 | 姓名 | 姓名 | +| 8/6 | 星期四 | 梁峰 | 沈飞 | 姚旭杰 | 张敏 | +| 8/7 | 星期五 | 姓名 | 姓名 | 姓名 | 姓名 | + +输出要求: + +1. 按日期升序排列。 +2. 一天只能有一行记录。 +3. 不保留原始表格颜色。 +4. 不保留无关人员和岗位。 +5. 不保留周末数据。 +6. 支持导出为Excel文件。 +7. 导出前显示整理结果,允许用户检查和修改姓名、日期及岗位内容。 + +## 七、异常处理 + +需要明确显示以下情况,不能静默忽略: + +* 某个工作日没有在4张图片中找到。 +* 同一个日期识别出多条记录。 +* 某个岗位姓名为空。 +* 日期或星期无法识别。 +* 同一行中的岗位内容无法确定。 +* 4张图片中的日期范围不属于同一个月份。 + +发现异常时,在结果中标记对应单元格或对应行,供用户手动检查。 + +## 八、验收标准 + +选择4张符合上述结构的图片后,应当能够: + +1. 正确提取每张图片中的日期和4个岗位。 +2. 正确将4天间隔的数据穿插合并。 +3. 正确按日期排序。 +4. 正确排除星期六和星期日。 +5. 保留岗位一至岗位四每天对应的实际人员。 +6. 输出一张连续、简洁、可修改并可导出的工作日排班表。