1401 lines
57 KiB
C#
1401 lines
57 KiB
C#
// ============================================================
|
||
// PdfViewerWindow:停靠窗口(中央文档区)—— PDF 查看 + 框选 OCR 扒图
|
||
// · pdfium 渲染;单画布自管理滚动偏移 + 双缓冲 → 拖动/滚动无闪烁
|
||
// · 框选(仿 CAD 窗选语义):
|
||
// 正向(左→右) = 逐条:每个识别文本一个彩色蒙版框、各自 Handle
|
||
// 反向(右→左) = 合并:整块一个蒙版框、共享 Handle(Form3 拼接为一条规格)
|
||
// · ★ 框选区域按 300DPI 高清重渲染后交 Windows 内置 OCR(PowerShell 桥)
|
||
// —— 与屏幕缩放无关,任意缩放下识别质量一致,单次 ~0.1-0.3s
|
||
// · 结果按"列分组、列内从上到下"排序(与 CAD 版 PaTextSelector 一致)
|
||
// · 蒙版持久化:<pdf路径>.pdfmask.json,重开 PDF 自动恢复
|
||
// · ZoomToFrame/DeleteFrames/MergeMasks 供 PdfBridge 联动表格
|
||
// ============================================================
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Drawing;
|
||
using System.Drawing.Drawing2D;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using System.Web.Script.Serialization;
|
||
using 成套报价软件; // ★ A1:OCR/PDF 基础设施已链接共享主项目源码(类型在 成套报价软件 命名空间)
|
||
using System.Windows.Forms;
|
||
using WeifenLuo.WinFormsUI.Docking;
|
||
|
||
namespace pdfbt
|
||
{
|
||
/// <summary>一个已绘制的蒙版框。</summary>
|
||
public class PdfMaskItem
|
||
{
|
||
public string Handle; // 唯一编号 "M1"...
|
||
public string TextHandle; // 稳定文本标识(页+粗化位置),用于判重
|
||
public int Page; // 0 基页码
|
||
public RectangleF RectPt; // 页面坐标(PDF 点)
|
||
public int Mode; // 0=图号 1=箱号 2=元件
|
||
|
||
public Color Color
|
||
{
|
||
get { return MaskColor(Mode); }
|
||
}
|
||
|
||
public static Color MaskColor(int mode)
|
||
{
|
||
switch (mode)
|
||
{
|
||
case 0: return Color.FromArgb(220, 40, 40); // 图号 红
|
||
case 1: return Color.FromArgb(30, 110, 220); // 箱号 蓝
|
||
default: return Color.FromArgb(20, 150, 90); // 元件 绿
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>PDF 查看器停靠窗口(DockState.Document)。</summary>
|
||
public class PdfViewerWindow : DockContent
|
||
{
|
||
// ---------- 子控件 ----------
|
||
private ToolStrip _toolbar;
|
||
private ToolStripButton _btnOpen;
|
||
private ToolStripButton _btnPrev, _btnNext;
|
||
private ToolStripLabel _lblPage;
|
||
private ToolStripButton _btnZoomOut, _btnZoomIn, _btnFitWidth, _btnFitPage;
|
||
private ToolStripLabel _lblZoom;
|
||
private ToolStripLabel _lblMode;
|
||
private StatusStrip _status;
|
||
private ToolStripStatusLabel _statusLeft, _statusOcr;
|
||
private ViewerCanvas _canvas;
|
||
private HScrollBar _hsb;
|
||
private VScrollBar _vsb;
|
||
|
||
// ---------- 文档状态 ----------
|
||
private PdfDocument _doc;
|
||
private float _zoom = 1.0f; // 1.0 = 96dpi 视觉尺寸
|
||
private float _dpiFactor = 96f / 72f; // 像素/点 基准,构造时按实际 DPI 校准
|
||
private int _pageIndex;
|
||
private float _pxPerPt; // 当前页实际渲染比例(等比上限修正后)
|
||
|
||
// ---------- 滚动/平移 ----------
|
||
private int _scrollX, _scrollY; // 页面位图相对视口原点偏移(≥0)
|
||
private bool _panning;
|
||
private Point _panStart;
|
||
private int _panScrollX, _panScrollY;
|
||
|
||
// ---------- 扒图状态 ----------
|
||
private int _pickMode = -1; // -1=未激活 0/1/2
|
||
private bool _picking;
|
||
private Point _pickStart; // 画布客户区坐标
|
||
private Rectangle _rubber;
|
||
private int _ocrBusyCount;
|
||
private bool _ocrErrorShown;
|
||
|
||
// ---------- 蒙版 ----------
|
||
private List<PdfMaskItem> _masks = new List<PdfMaskItem>();
|
||
private HashSet<string> _highlight = new HashSet<string>();
|
||
private int _handleCounter = 1;
|
||
private List<PdfMaskItem> _sessionPicked = new List<PdfMaskItem>();
|
||
private string _maskSavePath;
|
||
|
||
// ★ 缩放卡顿修复(2026-10-01):光栅化转后台线程的两根支柱
|
||
private static readonly object PdfRenderGate = new object(); // pdfium 非线程安全,所有渲染/文档操作全局互斥
|
||
private volatile int _renderGen; // 渲染代数:每次发起渲染+1,过期结果直接丢弃
|
||
|
||
private OcrClient _ocr;
|
||
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
|
||
|
||
private EventHandler _btnOcrcfg_Click;
|
||
|
||
/// <summary>OCR 设置:打开设计器窗体 OcrSettingsForm。</summary>
|
||
private void ShowOcrSettings()
|
||
{
|
||
using (var f = new OcrSettingsForm(_ocr))
|
||
{
|
||
f.ShowDialog(this);
|
||
}
|
||
}
|
||
private Timer _highlightTimer;
|
||
|
||
private const int MAX_RENDER_DIM = 8192; // 单边渲染像素上限(等比)
|
||
private const int OCR_MAX_DIM = 4000; // OCR 区域单边像素上限
|
||
private const float OCR_DPI = 300f; // OCR 渲染分辨率
|
||
|
||
public bool IsDocumentOpen
|
||
{
|
||
get { return _doc != null && _doc.IsOpen; }
|
||
}
|
||
|
||
public bool PickModeActive
|
||
{
|
||
get { return _pickMode >= 0; }
|
||
}
|
||
|
||
public PdfViewerWindow()
|
||
{
|
||
BuildUi();
|
||
Text = "PDF 查看器";
|
||
TabText = "PDF 查看器";
|
||
PdfBridge.Attach(this);
|
||
AllowDrop = true;
|
||
DragEnter += (s, e) =>
|
||
{
|
||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||
e.Effect = DragDropEffects.Copy;
|
||
};
|
||
DragDrop += (s, e) =>
|
||
{
|
||
var files = e.Data.GetData(DataFormats.FileDrop) as string[];
|
||
if (files != null && files.Length > 0)
|
||
OpenPdf(files[0]);
|
||
};
|
||
|
||
_ocr = new OcrClient();
|
||
_ocr.StatusChanged += msg =>
|
||
{
|
||
if (InvokeRequired) BeginInvoke((Action)(() => _statusOcr.Text = msg));
|
||
else _statusOcr.Text = msg;
|
||
};
|
||
|
||
try
|
||
{
|
||
using (var g = CreateGraphics())
|
||
_dpiFactor = g.DpiX / 72f;
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
// ============================================================
|
||
// UI 构建
|
||
// ============================================================
|
||
private void BuildUi()
|
||
{
|
||
_toolbar = new ToolStrip();
|
||
_toolbar.GripStyle = ToolStripGripStyle.Hidden;
|
||
_toolbar.RenderMode = ToolStripRenderMode.System;
|
||
|
||
_btnOpen = new ToolStripButton("打开PDF");
|
||
_btnOpen.Click += (s, e) => ShowOpenDialog();
|
||
_btnPrev = new ToolStripButton("◀");
|
||
_btnPrev.Click += (s, e) => SetPage(_pageIndex - 1);
|
||
_lblPage = new ToolStripLabel("0 / 0");
|
||
_btnNext = new ToolStripButton("▶");
|
||
_btnNext.Click += (s, e) => SetPage(_pageIndex + 1);
|
||
_btnZoomOut = new ToolStripButton("-");
|
||
_btnZoomOut.Click += (s, e) => SetZoom(_zoom / 1.25f);
|
||
_lblZoom = new ToolStripLabel("100%");
|
||
_btnZoomIn = new ToolStripButton("+");
|
||
_btnZoomIn.Click += (s, e) => SetZoom(_zoom * 1.25f);
|
||
_btnFitWidth = new ToolStripButton("适合宽度");
|
||
_btnFitWidth.Click += (s, e) => FitWidth();
|
||
_btnFitPage = new ToolStripButton("适合整页");
|
||
_btnFitPage.Click += (s, e) => FitPage();
|
||
_lblMode = new ToolStripLabel("");
|
||
_lblMode.ForeColor = Color.Firebrick;
|
||
_lblMode.Font = new Font(_lblMode.Font, FontStyle.Bold);
|
||
|
||
_toolbar.Items.Add(_btnOpen);
|
||
var _btnOcrCfg = new ToolStripButton("OCR设置");
|
||
_btnOcrcfg_Click = delegate { ShowOcrSettings(); };
|
||
_btnOcrCfg.Click += _btnOcrcfg_Click;
|
||
_toolbar.Items.Add(_btnOcrCfg);
|
||
_toolbar.Items.Add(new ToolStripSeparator());
|
||
_toolbar.Items.Add(_btnPrev);
|
||
_toolbar.Items.Add(_lblPage);
|
||
_toolbar.Items.Add(_btnNext);
|
||
_toolbar.Items.Add(new ToolStripSeparator());
|
||
_toolbar.Items.Add(_btnZoomOut);
|
||
_toolbar.Items.Add(_lblZoom);
|
||
_toolbar.Items.Add(_btnZoomIn);
|
||
_toolbar.Items.Add(_btnFitWidth);
|
||
_toolbar.Items.Add(_btnFitPage);
|
||
_toolbar.Items.Add(new ToolStripSeparator());
|
||
_toolbar.Items.Add(_lblMode);
|
||
|
||
_status = new StatusStrip();
|
||
_statusLeft = new ToolStripStatusLabel("请打开 PDF 图纸(或直接拖入窗口)");
|
||
_statusLeft.Spring = true;
|
||
_statusLeft.TextAlign = ContentAlignment.MiddleLeft;
|
||
_statusOcr = new ToolStripStatusLabel("OCR 未启动");
|
||
_status.Items.Add(_statusLeft);
|
||
_status.Items.Add(_statusOcr);
|
||
|
||
_canvas = new ViewerCanvas();
|
||
_canvas.Dock = DockStyle.Fill;
|
||
_canvas.BackColor = Color.FromArgb(70, 70, 74);
|
||
|
||
_hsb = new HScrollBar();
|
||
_hsb.Dock = DockStyle.Bottom;
|
||
_hsb.SmallChange = 30;
|
||
_hsb.LargeChange = 120;
|
||
_vsb = new VScrollBar();
|
||
_vsb.Dock = DockStyle.Right;
|
||
_vsb.SmallChange = 30;
|
||
_vsb.LargeChange = 120;
|
||
_hsb.ValueChanged += (s, e) => { _scrollX = _hsb.Value; _canvas.Invalidate(); };
|
||
_vsb.ValueChanged += (s, e) => { _scrollY = _vsb.Value; _canvas.Invalidate(); };
|
||
|
||
// 布局顺序(Dock 逆序处理):toolbar(Top) → status(Bottom) → hsb(Bottom) → vsb(Right) → canvas(Fill)
|
||
Controls.Add(_canvas);
|
||
Controls.Add(_vsb);
|
||
Controls.Add(_hsb);
|
||
Controls.Add(_status);
|
||
Controls.Add(_toolbar);
|
||
|
||
_canvas.MouseDown += CanvasMouseDown;
|
||
_canvas.MouseMove += CanvasMouseMove;
|
||
_canvas.MouseUp += CanvasMouseUp;
|
||
_canvas.MouseWheel += CanvasMouseWheel;
|
||
_canvas.OriginProvider = () => PageOrigin;
|
||
_canvas.Resize += (s, e) => { UpdateScrollbars(); _canvas.Invalidate(); };
|
||
|
||
_highlightTimer = new Timer();
|
||
_highlightTimer.Interval = 3000;
|
||
_highlightTimer.Tick += (s, e) =>
|
||
{
|
||
_highlightTimer.Stop();
|
||
_highlight.Clear();
|
||
RefreshMasks();
|
||
};
|
||
}
|
||
|
||
private void ShowOpenDialog()
|
||
{
|
||
using (var dlg = new OpenFileDialog())
|
||
{
|
||
dlg.Filter = "PDF 文件 (*.pdf)|*.pdf|所有文件 (*.*)|*.*";
|
||
dlg.Title = "打开 PDF 图纸";
|
||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||
OpenPdf(dlg.FileName);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 打开 / 渲染 / 导航
|
||
// ============================================================
|
||
|
||
public void OpenPdf(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||
{
|
||
MessageBox.Show(this, "文件不存在: " + path, "打开 PDF");
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
// ★ 先把当前 PDF 关掉再开新的(用户要求的机制)——
|
||
// 旧文档(含后备缓冲/页句柄/掩码状态)先释放,也避免两份大 PDF 同时占内存
|
||
if (_doc != null)
|
||
{
|
||
_renderGen++; // 作废所有在途后台渲染
|
||
lock (PdfRenderGate) { _doc.Dispose(); } // 与后台渲染互斥,等它出锁再释放
|
||
_doc = null;
|
||
}
|
||
|
||
var doc = new PdfDocument();
|
||
doc.Open(path, null);
|
||
_doc = doc;
|
||
_pageIndex = 0;
|
||
_scrollX = 0; _scrollY = 0;
|
||
_canvas.SetPage(null, 1f); // 清掉上一份 PDF 的位图,白底等新页(渲染已异步)
|
||
|
||
_maskSavePath = path + ".pdfmask.json";
|
||
LoadMasks();
|
||
|
||
Text = Path.GetFileName(path);
|
||
TabText = Path.GetFileName(path);
|
||
_statusLeft.Text = path + " · " + _doc.PageCount + " 页";
|
||
|
||
// ★ 强制重绘:SetZoom 在新旧 PDF 页宽相同(工程图同为 A4/A3 很常见)时
|
||
// 算出相同缩放会直接 return,不触发 RenderCurrentPage——画布停留在
|
||
// 第一份 PDF 的位图上("换 PDF 显示不变"的元凶)。先把缩放置为无效值,
|
||
// FitWidth→SetZoom 必然视为变化,强制渲染新文档。
|
||
_zoom = -1f;
|
||
FitWidth();
|
||
UpdateNavUi();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show(this, ex.Message, "打开 PDF 失败",
|
||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
}
|
||
|
||
private float MaxZoom
|
||
{
|
||
get
|
||
{
|
||
if (!IsDocumentOpen) return 8f;
|
||
float maxSide = Math.Max(_doc.PageWidthPt[_pageIndex], _doc.PageHeightPt[_pageIndex]);
|
||
if (maxSide <= 0) return 8f;
|
||
return Math.Min(8f, MAX_RENDER_DIM / maxSide / _dpiFactor);
|
||
}
|
||
}
|
||
|
||
private void SetPage(int index)
|
||
{
|
||
if (!IsDocumentOpen) return;
|
||
if (index < 0) index = 0;
|
||
if (index > _doc.PageCount - 1) index = _doc.PageCount - 1;
|
||
if (index == _pageIndex && _canvas.Page != null) return;
|
||
_pageIndex = index;
|
||
_scrollX = 0; _scrollY = 0;
|
||
RenderCurrentPage(clearPage: true); // 翻页:清旧页位图,白底等新页
|
||
UpdateNavUi();
|
||
}
|
||
|
||
private void UpdateNavUi()
|
||
{
|
||
_lblPage.Text = IsDocumentOpen
|
||
? (_pageIndex + 1) + " / " + _doc.PageCount
|
||
: "0 / 0";
|
||
}
|
||
|
||
private void SetZoom(float zoom)
|
||
{
|
||
zoom = Math.Max(0.05f, Math.Min(MaxZoom, zoom));
|
||
if (Math.Abs(zoom - _zoom) < 0.001f) return;
|
||
_zoom = zoom;
|
||
_lblZoom.Text = (int)Math.Round(_zoom * 100) + "%";
|
||
RenderCurrentPage();
|
||
}
|
||
|
||
private void FitWidth()
|
||
{
|
||
if (!IsDocumentOpen) return;
|
||
float pageWpt = _doc.PageWidthPt[_pageIndex];
|
||
int avail = _canvas.ClientSize.Width - (int)_dpiFactor * 16;
|
||
if (pageWpt <= 0 || avail <= 0) return;
|
||
SetZoom(avail / (pageWpt * _dpiFactor));
|
||
}
|
||
|
||
private void FitPage()
|
||
{
|
||
if (!IsDocumentOpen) return;
|
||
float pageWpt = _doc.PageWidthPt[_pageIndex];
|
||
float pageHpt = _doc.PageHeightPt[_pageIndex];
|
||
int availW = _canvas.ClientSize.Width - (int)_dpiFactor * 16;
|
||
int availH = _canvas.ClientSize.Height - (int)_dpiFactor * 16;
|
||
if (pageWpt <= 0 || pageHpt <= 0) return;
|
||
float z = Math.Min(availW / (pageWpt * _dpiFactor), availH / (pageHpt * _dpiFactor));
|
||
SetZoom(Math.Max(0.05f, z));
|
||
}
|
||
|
||
/// <summary>异步重渲染当前页(等比限制在 MAX_RENDER_DIM 内)。
|
||
/// ★ 缩放卡顿修复(2026-10-01):原来 UI 线程同步光栅化整页,大幅面工程图每档滚轮
|
||
/// 冻结数百毫秒~秒级。现在:视图比例立即生效+旧位图拉伸过渡帧(滚轮即时反馈),
|
||
/// 光栅化转后台线程;连续触发用代数令牌只落最后一档,过期任务连渲染都不做。</summary>
|
||
private void RenderCurrentPage(bool clearPage = false)
|
||
{
|
||
if (!IsDocumentOpen)
|
||
{
|
||
_canvas.SetPage(null, 1f);
|
||
UpdateScrollbars();
|
||
_canvas.Invalidate();
|
||
return;
|
||
}
|
||
|
||
float scale = _zoom * _dpiFactor;
|
||
float maxSide = Math.Max(_doc.PageWidthPt[_pageIndex], _doc.PageHeightPt[_pageIndex]);
|
||
if (maxSide > 0 && scale * maxSide > MAX_RENDER_DIM)
|
||
scale = MAX_RENDER_DIM / maxSide; // ★ 等比降档,保持长宽比
|
||
_pxPerPt = scale;
|
||
|
||
if (clearPage)
|
||
{
|
||
// 翻页/换文档:旧页位图直接清掉(画布白底),避免"上一页拉伸"的错觉
|
||
_canvas.SetPage(null, scale);
|
||
}
|
||
else
|
||
{
|
||
// 缩放:保留旧位图按新比例拉伸显示(过渡帧),高清渲染完成后自动替换
|
||
_canvas.SetViewScale(scale);
|
||
}
|
||
UpdateScrollbars();
|
||
_canvas.Invalidate();
|
||
|
||
int gen = ++_renderGen;
|
||
int page = _pageIndex;
|
||
var doc = _doc;
|
||
System.Threading.Tasks.Task.Factory.StartNew(() =>
|
||
{
|
||
lock (PdfRenderGate) // pdfium 非线程安全:与 OCR 渲染/文档开关全局互斥
|
||
{
|
||
if (gen != _renderGen || doc != _doc) return null; // 过期:不再光栅化
|
||
return doc.RenderPage(page, scale);
|
||
}
|
||
}).ContinueWith(t =>
|
||
{
|
||
var bmp = t.Result;
|
||
// 回 UI 线程后复核:期间又缩放/翻页/换文档/窗口销毁 → 丢弃结果
|
||
if (gen != _renderGen || doc != _doc || page != _pageIndex || IsDisposed)
|
||
{
|
||
if (bmp != null) bmp.Dispose();
|
||
return;
|
||
}
|
||
if (bmp == null)
|
||
{
|
||
_statusLeft.Text = "页面渲染失败";
|
||
return;
|
||
}
|
||
_canvas.SetPage(bmp, scale);
|
||
_canvas.Masks = CurrentPageMasks();
|
||
UpdateScrollbars();
|
||
_canvas.Invalidate();
|
||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||
}
|
||
|
||
private List<PdfMaskItem> CurrentPageMasks()
|
||
{
|
||
var list = new List<PdfMaskItem>();
|
||
foreach (var m in _masks)
|
||
if (m.Page == _pageIndex) list.Add(m);
|
||
return list;
|
||
}
|
||
|
||
private void RefreshMasks()
|
||
{
|
||
_canvas.Masks = CurrentPageMasks();
|
||
_canvas.Highlight = _highlight;
|
||
_canvas.Invalidate();
|
||
}
|
||
|
||
// ============================================================
|
||
// 滚动条 / 偏移
|
||
// ============================================================
|
||
|
||
/// <summary>页面位图按当前视图比例(ViewScale)的显示尺寸。
|
||
/// 过渡帧拉伸期间与位图实际尺寸不同,滚动范围/原点/居中都必须用它。</summary>
|
||
private Size PageViewSize
|
||
{
|
||
get
|
||
{
|
||
var p = _canvas.Page;
|
||
if (p == null) return Size.Empty;
|
||
float st = _canvas.ViewScale;
|
||
return new Size((int)Math.Round(p.Width * st), (int)Math.Round(p.Height * st));
|
||
}
|
||
}
|
||
|
||
private void UpdateScrollbars()
|
||
{
|
||
var view = PageViewSize;
|
||
int clientW = _canvas.ClientSize.Width;
|
||
int clientH = _canvas.ClientSize.Height;
|
||
int pageW = view.Width;
|
||
int pageH = view.Height;
|
||
|
||
int maxX = Math.Max(0, pageW - clientW);
|
||
int maxY = Math.Max(0, pageH - clientH);
|
||
_scrollX = Clamp(_scrollX, 0, maxX);
|
||
_scrollY = Clamp(_scrollY, 0, maxY);
|
||
|
||
_hsb.Enabled = maxX > 0;
|
||
_vsb.Enabled = maxY > 0;
|
||
// Maximum 需含 LargeChange 补偿,否则最大值滚不到底
|
||
_hsb.LargeChange = Math.Max(10, Math.Min(clientW, maxX + 10));
|
||
_hsb.Maximum = maxX + _hsb.LargeChange - 1;
|
||
_hsb.Value = Clamp(_hsb.Value, 0, Math.Max(0, _hsb.Maximum - _hsb.LargeChange + 1));
|
||
if (_hsb.Value != _scrollX) _hsb.Value = _scrollX;
|
||
_vsb.LargeChange = Math.Max(10, Math.Min(clientH, maxY + 10));
|
||
_vsb.Maximum = maxY + _vsb.LargeChange - 1;
|
||
_vsb.Value = Clamp(_vsb.Value, 0, Math.Max(0, _vsb.Maximum - _vsb.LargeChange + 1));
|
||
if (_vsb.Value != _scrollY) _vsb.Value = _scrollY;
|
||
}
|
||
|
||
private static int Clamp(int v, int lo, int hi)
|
||
{
|
||
if (hi < lo) hi = lo;
|
||
return v < lo ? lo : (v > hi ? hi : v);
|
||
}
|
||
|
||
/// <summary>页面位图在画布上的绘制原点(小于视口时居中)。按视图尺寸(含过渡帧拉伸)计算。</summary>
|
||
private Point PageOrigin
|
||
{
|
||
get
|
||
{
|
||
var view = PageViewSize;
|
||
if (view.IsEmpty) return Point.Empty;
|
||
int dx = view.Width <= _canvas.ClientSize.Width
|
||
? (_canvas.ClientSize.Width - view.Width) / 2 : -_scrollX;
|
||
int dy = view.Height <= _canvas.ClientSize.Height
|
||
? (_canvas.ClientSize.Height - view.Height) / 2 : -_scrollY;
|
||
return new Point(dx, dy);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 鼠标交互
|
||
// ============================================================
|
||
|
||
private void CanvasMouseDown(object sender, MouseEventArgs e)
|
||
{
|
||
_canvas.Focus();
|
||
if (e.Button == MouseButtons.Middle)
|
||
{
|
||
_panStart = e.Location;
|
||
_panScrollX = _scrollX;
|
||
_panScrollY = _scrollY;
|
||
_panning = true;
|
||
return;
|
||
}
|
||
if (e.Button == MouseButtons.Right)
|
||
{
|
||
// ★ 扒图会话中右键循环切换模式(与网页 CAD 版一致):
|
||
// 图号→箱号→元件→退出;无会话时忽略
|
||
if (_pickMode >= 0)
|
||
{
|
||
_picking = false;
|
||
_canvas.Rubber = Rectangle.Empty;
|
||
if (_pickMode == 0) StartPick(1);
|
||
else if (_pickMode == 1) StartPick(2);
|
||
else
|
||
{
|
||
ExitPickMode();
|
||
PdfBridge.RaisePaExited();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (e.Button == MouseButtons.Left && _pickMode >= 0 && IsDocumentOpen)
|
||
{
|
||
_picking = true;
|
||
_pickStart = e.Location;
|
||
_rubber = new Rectangle(e.Location, Size.Empty);
|
||
}
|
||
}
|
||
|
||
private void CanvasMouseMove(object sender, MouseEventArgs e)
|
||
{
|
||
if (_panning)
|
||
{
|
||
var view = PageViewSize; // 视图尺寸(与滚动条范围一致)
|
||
int maxX = Math.Max(0, view.Width - _canvas.ClientSize.Width);
|
||
int maxY = Math.Max(0, view.Height - _canvas.ClientSize.Height);
|
||
_scrollX = Clamp(_panScrollX - (e.X - _panStart.X), 0, maxX);
|
||
_scrollY = Clamp(_panScrollY - (e.Y - _panStart.Y), 0, maxY);
|
||
SyncScrollbars();
|
||
_canvas.Invalidate();
|
||
return;
|
||
}
|
||
if (_picking)
|
||
{
|
||
_rubber = Rectangle.FromLTRB(
|
||
Math.Min(_pickStart.X, e.X), Math.Min(_pickStart.Y, e.Y),
|
||
Math.Max(_pickStart.X, e.X), Math.Max(_pickStart.Y, e.Y));
|
||
_canvas.Rubber = _rubber;
|
||
_canvas.RubberMerge = _pickStart.X > e.X; // 反向拖拽 = 合并
|
||
_canvas.Invalidate();
|
||
}
|
||
}
|
||
|
||
private void CanvasMouseUp(object sender, MouseEventArgs e)
|
||
{
|
||
if (e.Button == MouseButtons.Middle) { _panning = false; return; }
|
||
|
||
if (e.Button == MouseButtons.Left && _picking)
|
||
{
|
||
_picking = false;
|
||
_canvas.Rubber = Rectangle.Empty;
|
||
var rect = _rubber;
|
||
_rubber = Rectangle.Empty;
|
||
_canvas.Invalidate();
|
||
|
||
if (rect.Width < 5 || rect.Height < 5) return; // 误点忽略
|
||
bool merge = _pickStart.X > e.X; // 反向 = 合并
|
||
RunPickOcr(rect, merge);
|
||
}
|
||
}
|
||
|
||
private void CanvasMouseWheel(object sender, MouseEventArgs e)
|
||
{
|
||
var hme = e as HandledMouseEventArgs;
|
||
if ((Control.ModifierKeys & Keys.Control) != 0)
|
||
{
|
||
if (hme != null) hme.Handled = true;
|
||
if (!IsDocumentOpen) return;
|
||
// 以鼠标为中心缩放:记住鼠标处页面点,缩放后滚回
|
||
var origin = PageOrigin;
|
||
float ptX = (e.X - origin.X) / _pxPerPt;
|
||
float ptY = (e.Y - origin.Y) / _pxPerPt;
|
||
SetZoom(e.Delta > 0 ? _zoom * 1.2f : _zoom / 1.2f);
|
||
_scrollX = Clamp((int)(ptX * _pxPerPt) - e.X, 0, int.MaxValue);
|
||
_scrollY = Clamp((int)(ptY * _pxPerPt) - e.Y, 0, int.MaxValue);
|
||
UpdateScrollbars();
|
||
SyncScrollbars();
|
||
_canvas.Invalidate();
|
||
}
|
||
else
|
||
{
|
||
if (hme != null) hme.Handled = true;
|
||
int pageH = PageViewSize.Height; // 视图尺寸(过渡帧拉伸期间跟随缩放)
|
||
int maxY = Math.Max(0, pageH - _canvas.ClientSize.Height);
|
||
_scrollY = Clamp(_scrollY - e.Delta, 0, maxY);
|
||
SyncScrollbars();
|
||
_canvas.Invalidate();
|
||
}
|
||
}
|
||
|
||
private void SyncScrollbars()
|
||
{
|
||
if (_hsb.Value != _scrollX && _scrollX <= _hsb.Maximum) _hsb.Value = _scrollX;
|
||
if (_vsb.Value != _scrollY && _scrollY <= _vsb.Maximum) _vsb.Value = _scrollY;
|
||
}
|
||
|
||
// ============================================================
|
||
// 扒图:框选 → 高清重渲染 → OCR → 组装结果 + 蒙版
|
||
// ============================================================
|
||
|
||
public void StartPick(int mode)
|
||
{
|
||
if (!IsDocumentOpen)
|
||
{
|
||
_statusLeft.Text = "请先打开 PDF 图纸,再点扒图按钮";
|
||
return;
|
||
}
|
||
_pickMode = mode;
|
||
_highlight.Clear();
|
||
RefreshMasks();
|
||
string[] names = { "扒图号", "扒箱号", "扒元件" };
|
||
_lblMode.Text = names[mode] + ":框选中(左→右逐条 / 右→左合并),ESC 退出";
|
||
_canvas.Cursor = Cursors.Cross;
|
||
_statusLeft.Text = "框选识别:正向拖拽=逐条,反向拖拽=合并,ESC 退出";
|
||
}
|
||
|
||
public void ExitPickMode()
|
||
{
|
||
_pickMode = -1;
|
||
_picking = false;
|
||
_lblMode.Text = "";
|
||
_canvas.Cursor = Cursors.Default;
|
||
_canvas.Rubber = Rectangle.Empty;
|
||
_canvas.Invalidate();
|
||
_statusLeft.Text = "已退出扒图模式";
|
||
}
|
||
|
||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||
{
|
||
if (keyData == Keys.Escape && _pickMode >= 0)
|
||
{
|
||
ExitPickMode();
|
||
PdfBridge.RaisePaExited();
|
||
return true;
|
||
}
|
||
return base.ProcessCmdKey(ref msg, keyData);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 框选完成:客户区矩形 → 页面pt矩形 → 300DPI 高清重渲染 → OCR。
|
||
/// ★ 与屏幕缩放无关:任意缩放下识别质量一致。
|
||
/// </summary>
|
||
private void RunPickOcr(Rectangle rectClient, bool merge)
|
||
{
|
||
if (!IsDocumentOpen) return;
|
||
var origin = PageOrigin;
|
||
// 客户区 → 页面pt
|
||
float x0 = (rectClient.Left - origin.X) / _pxPerPt;
|
||
float y0 = (rectClient.Top - origin.Y) / _pxPerPt;
|
||
float x1 = (rectClient.Right - origin.X) / _pxPerPt;
|
||
float y1 = (rectClient.Bottom - origin.Y) / _pxPerPt;
|
||
var rectPt = RectangleF.FromLTRB(
|
||
Math.Min(x0, x1), Math.Min(y0, y1), Math.Max(x0, x1), Math.Max(y0, y1));
|
||
|
||
// OCR 渲染分辨率:300DPI,单边超限降档;不低于当前屏幕渲染比例
|
||
float ocrScale = OCR_DPI / 72f;
|
||
float maxSidePt = Math.Max(rectPt.Width, rectPt.Height);
|
||
if (maxSidePt > 0 && maxSidePt * ocrScale > OCR_MAX_DIM)
|
||
ocrScale = OCR_MAX_DIM / maxSidePt;
|
||
if (ocrScale < _pxPerPt) ocrScale = _pxPerPt;
|
||
|
||
Bitmap crop;
|
||
try
|
||
{
|
||
lock (PdfRenderGate) // pdfium 非线程安全:与后台缩放渲染互斥
|
||
{
|
||
crop = _doc.RenderRegion(_pageIndex, rectPt, ocrScale);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_statusLeft.Text = "框选区域渲染失败: " + ex.Message;
|
||
return;
|
||
}
|
||
if (crop == null || crop.Width < 4 || crop.Height < 4) return;
|
||
|
||
// Umi-OCR 本地引擎:识别前确保本体已安装且服务在线(懒启动,首次约几秒)
|
||
if (_ocr.Engine == "umi")
|
||
{
|
||
try
|
||
{
|
||
_statusLeft.Text = "正在启动 Umi-OCR 本地引擎…";
|
||
if (!UmiOcrLauncher.EnsureRunning()) // ★ A1:启停已拆到 UmiOcrLauncher(主项目共享版)
|
||
{
|
||
_statusLeft.Text = "Umi-OCR 服务启动超时(本体已安装),请稍后重试或检查 Umi-OCR 窗口";
|
||
crop.Dispose();
|
||
return;
|
||
}
|
||
}
|
||
catch (Exception exUmi)
|
||
{
|
||
_statusLeft.Text = exUmi.Message;
|
||
MessageBox.Show(this, exUmi.Message, "Umi-OCR", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
crop.Dispose();
|
||
return;
|
||
}
|
||
}
|
||
|
||
int mode = _pickMode;
|
||
_ocrBusyCount++;
|
||
UpdateOcrStatus();
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
_ocr.RecognizeAsync(crop, 30000).ContinueWith(t =>
|
||
{
|
||
_ocrBusyCount--;
|
||
UpdateOcrStatus();
|
||
try
|
||
{
|
||
var items = t.Result;
|
||
// crop 传入过滤层(图形符号墨水/笔画判定用)
|
||
HandleOcrResult(mode, items, rectPt, merge, ocrScale, sw.ElapsedMilliseconds, crop);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// t.Result 抛的 AggregateException 消息恒为"发生一个或多个错误",解包取根因
|
||
Exception root = ex is AggregateException ? ((AggregateException)ex).GetBaseException() : ex;
|
||
string msg = "OCR 识别失败: " + root.Message;
|
||
_statusLeft.Text = msg;
|
||
if (!_ocrErrorShown)
|
||
{
|
||
_ocrErrorShown = true;
|
||
MessageBox.Show(this, msg + "\r\n\r\n下次框选时将自动重启 OCR。",
|
||
"OCR", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
crop.Dispose();
|
||
}
|
||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||
}
|
||
|
||
private void UpdateOcrStatus()
|
||
{
|
||
if (_ocrBusyCount > 0)
|
||
_statusOcr.Text = "OCR 识别中…(" + _ocrBusyCount + " 个框)";
|
||
else
|
||
_statusOcr.Text = "OCR 空闲";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 高风险短串:2-4 字符、含 ASCII 字母、非纯数字、不含汉字。
|
||
/// (X1/M4/KI 这类图形误读高发形态;纯数字数量、汉字、长规格不受此门约束)
|
||
/// </summary>
|
||
private static bool IsShortLetterText(string s)
|
||
{
|
||
if (string.IsNullOrEmpty(s) || s.Length < 2 || s.Length > 4) return false;
|
||
bool hasLetter = false;
|
||
foreach (char c in s)
|
||
{
|
||
if (c >= 0x4E00 && c <= 0x9FFF) return false; // 含汉字不适用
|
||
if (!char.IsDigit(c))
|
||
{
|
||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) hasLetter = true;
|
||
// 其他字符(如 -)允许出现
|
||
}
|
||
}
|
||
return hasLetter;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 图形误读判定(基于裁剪图 bbox 区域的笔画特征):
|
||
/// · 百度置信度 <0.8(真短标注通常 >0.9;GLM Score=1 跳过,由下面两条兜底)
|
||
/// · 墨水密度 ≥22%(前景=亮度<140;粗线符号 15-60%,文字 5-15%)
|
||
/// · 水平行程中位数 ≥8px(粗笔画;文字笔画 2-5px@300DPI)
|
||
/// 三条任一命中即判图形。
|
||
/// </summary>
|
||
private static bool IsGraphicMisread(OcrItem it, Bitmap crop)
|
||
{
|
||
if (it.Score > 0f && it.Score < 0.8f) return true;
|
||
if (crop == null) return false;
|
||
return StrokeMetrics(crop, it.Bounds);
|
||
}
|
||
|
||
/// <summary>计算 bbox 区域墨水密度与水平行程中位数,超限返回 true。</summary>
|
||
private static bool StrokeMetrics(Bitmap crop, RectangleF boundsF)
|
||
{
|
||
var r = Rectangle.Round(boundsF);
|
||
r.Intersect(new Rectangle(0, 0, crop.Width, crop.Height));
|
||
if (r.Width < 4 || r.Height < 4) return false;
|
||
|
||
var data = crop.LockBits(r,
|
||
System.Drawing.Imaging.ImageLockMode.ReadOnly,
|
||
System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
||
int fg = 0, total = 0;
|
||
var runs = new List<int>();
|
||
try
|
||
{
|
||
var buf = new byte[r.Width * 4];
|
||
for (int y = 0; y < r.Height; y++)
|
||
{
|
||
System.Runtime.InteropServices.Marshal.Copy(
|
||
new IntPtr(data.Scan0.ToInt64() + y * data.Stride), buf, 0, buf.Length);
|
||
int run = 0;
|
||
for (int x = 0; x < r.Width; x++)
|
||
{
|
||
byte b = buf[x * 4], g = buf[x * 4 + 1], rr = buf[x * 4 + 2];
|
||
int lum = (rr * 299 + g * 587 + b * 114) / 1000;
|
||
if (lum < 140) { fg++; run++; }
|
||
else { if (run >= 2) runs.Add(run); run = 0; }
|
||
}
|
||
if (run >= 2) runs.Add(run);
|
||
total += r.Width;
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
crop.UnlockBits(data);
|
||
}
|
||
if (total == 0) return false;
|
||
float density = (float)fg / total;
|
||
if (density >= 0.22f) return true;
|
||
if (runs.Count == 0) return false;
|
||
runs.Sort();
|
||
float strokeMedian = runs[runs.Count / 2];
|
||
return strokeMedian >= 8;
|
||
}
|
||
|
||
/// <summary>过滤丢弃记录(exe 旁 filter.log,便于调阈值);失败静默。</summary>
|
||
private static void FilterLog(string msg)
|
||
{
|
||
try
|
||
{
|
||
File.AppendAllText(
|
||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "filter.log"),
|
||
DateTime.Now.ToString("HH:mm:ss.fff") + " " + msg + "\r\n");
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 剥离图形符号字符:电气图纸上的小三角(▲▼◀▶△)、箭头、制表线、
|
||
/// 杂项符号等被 OCR 误当文字的内容。剥完只剩空白 → 整条丢弃(调用处判断)。
|
||
/// ★ 只删明确的符号区段,不碰正常文字/标点/工程符号(-/.×x#等)。
|
||
/// </summary>
|
||
private static string RemoveSymbolJunk(string s)
|
||
{
|
||
if (string.IsNullOrEmpty(s)) return s;
|
||
bool changed = false;
|
||
var sb = new System.Text.StringBuilder(s.Length);
|
||
foreach (char c in s)
|
||
{
|
||
if (IsJunkSymbolChar(c)) { changed = true; continue; }
|
||
sb.Append(c);
|
||
}
|
||
if (!changed) return s;
|
||
string r = sb.ToString().Trim();
|
||
while (r.Contains(" ")) r = r.Replace(" ", " ");
|
||
return r;
|
||
}
|
||
|
||
/// <summary>图形符号字符判定(几何图形/箭头/制表线/杂项符号/零宽字符)。</summary>
|
||
private static bool IsJunkSymbolChar(char c)
|
||
{
|
||
int u = c;
|
||
if (u >= 0x25A0 && u <= 0x25FF) return true; // ▲△■□●○◤◥ 等
|
||
if (u >= 0x2190 && u <= 0x21FF) return true; // ←↑→↓↖↗↘↙ 等箭头
|
||
if (u >= 0x2500 && u <= 0x257F) return true; // ─│┌┐└┘├┤┬┴┼ 制表线
|
||
if (u >= 0x2600 && u <= 0x27BF) return true; // ★☆※§ 等杂项
|
||
if (u == 0xFE0F || u == 0x200B || u == 0x200D) return true; // 变体/零宽
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Windows OCR 符号归一化:间隔号/全角/Unicode 连字符 → 半角工程符号。</summary>
|
||
private static string NormalizeOcrText(string s) {
|
||
if (string.IsNullOrEmpty(s)) return s;
|
||
s = s.Replace(" · ", "-").Replace(" - ", "-").Replace(" / ", "/");
|
||
s = s.Replace('\u00B7', '-'); // 间隔号·
|
||
s = s.Replace('\uFF0D', '-'); // 全角-
|
||
s = s.Replace('\uFF0F', '/'); // 全角/
|
||
s = s.Replace('\u2010', '-').Replace('\u2011', '-')
|
||
.Replace('\u2012', '-').Replace('\u2013', '-')
|
||
.Replace('\u2014', '-').Replace('\u2212', '-');
|
||
return s.Trim();
|
||
}
|
||
|
||
/// <summary>OCR 完成:按 CAD 语义组装 results + 画蒙版 + 通知 Form3。</summary>
|
||
private void HandleOcrResult(int mode, List<OcrItem> items,
|
||
RectangleF rectPt, bool merge, float ocrScale, long elapsedMs, Bitmap crop)
|
||
{
|
||
var valid = new List<OcrItem>();
|
||
foreach (var it in items)
|
||
{
|
||
string norm = NormalizeOcrText(it.Text);
|
||
norm = RemoveSymbolJunk(norm); // ★ 剥离图形符号字符(▲等)
|
||
if (string.IsNullOrWhiteSpace(norm)) continue;
|
||
// ★ 电气图形符号被 OCR 误读成文字的特征过滤:
|
||
// 1) 剥完只剩 1 个非数字非汉字的字符(如图形认成 "A"/"M") → 丢
|
||
// 2) 低置信度(<0.55,百度引擎给出;GLM 恒为 1 不受影响) → 丢
|
||
// 3) 短文本(2-3字符,非纯数字)+ 近方形框 → 圆圈/三角等图形符号误读
|
||
// (真实横排文字宽高比 >1.4,图形外接框宽≈高;纯数字与汉字单字保护)
|
||
if (norm.Length == 1 && !char.IsDigit(norm[0]) &&
|
||
!(norm[0] >= 0x4E00 && norm[0] <= 0x9FFF)) continue;
|
||
if (it.Score > 0f && it.Score < 0.55f) continue;
|
||
if (norm.Length >= 2 && norm.Length <= 3)
|
||
{
|
||
bool allDigits = true;
|
||
foreach (char c in norm) if (!char.IsDigit(c)) { allDigits = false; break; }
|
||
if (!allDigits)
|
||
{
|
||
var b = it.Bounds;
|
||
if (b.Height > 4)
|
||
{
|
||
float ratio = b.Width / b.Height;
|
||
if (ratio >= 0.7f && ratio <= 1.4f) continue;
|
||
}
|
||
}
|
||
}
|
||
// 4) ★ 短串(2-4字符,含字母)+ 图形笔画特征 → 线圈/方框等粗线符号误读
|
||
// (如 X|X 绕组符号认成 "X1":横宽与真文字一样,靠墨水密度/笔画宽度区分)
|
||
if (IsShortLetterText(norm) && IsGraphicMisread(it, crop))
|
||
{
|
||
FilterLog("丢弃疑似图形符号 '" + norm + "' score=" +
|
||
it.Score.ToString("0.00") + " 密度/笔画超限");
|
||
continue;
|
||
}
|
||
it.Text = norm;
|
||
valid.Add(it);
|
||
}
|
||
if (valid.Count == 0)
|
||
{
|
||
_statusLeft.Text = "未识别到文字 —— 可放大视图或调整框选范围后重试 (" + elapsedMs + "ms)";
|
||
return;
|
||
}
|
||
|
||
// 列分组、列内从上到下(与 CAD 版 PaTextSelector 顺序语义一致)
|
||
valid = SortByColumns(valid);
|
||
|
||
var results = new List<MyApp.PaTextResult>();
|
||
string mergeHandle = null;
|
||
|
||
foreach (var it in valid)
|
||
{
|
||
// 裁剪图像素 → 页面点
|
||
RectangleF b = it.Bounds;
|
||
RectangleF boxPt = new RectangleF(
|
||
rectPt.X + b.X / ocrScale,
|
||
rectPt.Y + b.Y / ocrScale,
|
||
b.Width / ocrScale, b.Height / ocrScale);
|
||
string textHandle = FindOrBuildTextHandle(_pageIndex, boxPt);
|
||
|
||
string frameHandle;
|
||
if (merge)
|
||
{
|
||
if (mergeHandle == null)
|
||
{
|
||
mergeHandle = NewHandle();
|
||
_masks.Add(new PdfMaskItem
|
||
{
|
||
Handle = mergeHandle,
|
||
TextHandle = BuildTextHandle(_pageIndex, rectPt),
|
||
Page = _pageIndex,
|
||
RectPt = rectPt,
|
||
Mode = mode
|
||
});
|
||
}
|
||
frameHandle = mergeHandle;
|
||
}
|
||
else
|
||
{
|
||
frameHandle = NewHandle();
|
||
_masks.Add(new PdfMaskItem
|
||
{
|
||
Handle = frameHandle,
|
||
TextHandle = textHandle,
|
||
Page = _pageIndex,
|
||
RectPt = boxPt,
|
||
Mode = mode
|
||
});
|
||
}
|
||
|
||
results.Add(new MyApp.PaTextResult
|
||
{
|
||
Text = it.Text.Trim(),
|
||
FrameHandle = frameHandle,
|
||
TextHandle = textHandle
|
||
});
|
||
}
|
||
|
||
SaveMasks();
|
||
RefreshMasks();
|
||
_statusLeft.Text = "识别到 " + valid.Count + " 条文本(" + elapsedMs + "ms)" +
|
||
(merge ? ",合并模式,已拼为一条规格" : "") + ",已入表格并画框";
|
||
_ocrErrorShown = false;
|
||
|
||
PdfBridge.RaisePaPicked((MyApp.PaMode)mode, results);
|
||
}
|
||
|
||
/// <summary>按"列分组、列内从上到下"排序(阅读顺序)。</summary>
|
||
private static List<OcrItem> SortByColumns(List<OcrItem> items)
|
||
{
|
||
var byX = items.OrderBy(i => i.Bounds.X).ToList();
|
||
var columns = new List<List<OcrItem>>();
|
||
foreach (var it in byX)
|
||
{
|
||
bool placed = false;
|
||
foreach (var col in columns)
|
||
{
|
||
float colLeft = col.Min(c => c.Bounds.Left);
|
||
float colRight = col.Max(c => c.Bounds.Right);
|
||
float itMid = it.Bounds.Left + it.Bounds.Width / 2f;
|
||
if (itMid >= colLeft - 2 && itMid <= colRight + 2)
|
||
{
|
||
col.Add(it);
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!placed) columns.Add(new List<OcrItem> { it });
|
||
}
|
||
var result = new List<OcrItem>();
|
||
foreach (var col in columns)
|
||
result.AddRange(col.OrderBy(c => c.Bounds.Y).ThenBy(c => c.Bounds.X));
|
||
return result;
|
||
}
|
||
|
||
private string NewHandle()
|
||
{
|
||
return "M" + (_handleCounter++);
|
||
}
|
||
|
||
/// <summary>稳定文本标识:页码 + 中心点(10pt 网格) + 尺寸(5pt 网格)。</summary>
|
||
private static string BuildTextHandle(int page, RectangleF rectPt)
|
||
{
|
||
float cx = rectPt.X + rectPt.Width / 2f;
|
||
float cy = rectPt.Y + rectPt.Height / 2f;
|
||
return "P" + page
|
||
+ "@" + (int)Math.Round(cx / 10.0) + "," + (int)Math.Round(cy / 10.0)
|
||
+ "|" + (int)Math.Round(rectPt.Width / 5.0) + "x" + (int)Math.Round(rectPt.Height / 5.0);
|
||
}
|
||
|
||
/// <summary>会话内复用:同页 IoU>0.5 的旧框 → 沿用其 TextHandle。</summary>
|
||
private string FindOrBuildTextHandle(int page, RectangleF rectPt)
|
||
{
|
||
foreach (var m in _sessionPicked)
|
||
{
|
||
if (m.Page != page) continue;
|
||
RectangleF a = m.RectPt;
|
||
RectangleF inter = RectangleF.Intersect(a, rectPt);
|
||
if (inter.Width <= 0 || inter.Height <= 0) continue;
|
||
float interArea = inter.Width * inter.Height;
|
||
float union = a.Width * a.Height + rectPt.Width * rectPt.Height - interArea;
|
||
if (union > 0 && interArea / union > 0.5f)
|
||
{
|
||
m.RectPt = rectPt;
|
||
return m.TextHandle;
|
||
}
|
||
}
|
||
string th = BuildTextHandle(page, rectPt);
|
||
_sessionPicked.Add(new PdfMaskItem
|
||
{
|
||
Handle = "",
|
||
TextHandle = th,
|
||
Page = page,
|
||
RectPt = rectPt
|
||
});
|
||
return th;
|
||
}
|
||
|
||
// ============================================================
|
||
// 桥联动:定位 / 删框 / 合并 / 高亮
|
||
// ============================================================
|
||
|
||
public void ZoomToFrame(string handle)
|
||
{
|
||
PdfMaskItem m = null;
|
||
foreach (var k in _masks)
|
||
if (k.Handle == handle) { m = k; break; }
|
||
if (m == null) return;
|
||
|
||
if (m.Page != _pageIndex)
|
||
{
|
||
_pageIndex = m.Page;
|
||
RenderCurrentPage(clearPage: true); // 跨页定位:清旧页位图
|
||
UpdateNavUi();
|
||
}
|
||
// 框太小看不清时放大到框宽约 300px
|
||
float pxw = m.RectPt.Width * _pxPerPt;
|
||
if (pxw < 300)
|
||
{
|
||
float want = (300f / m.RectPt.Width) / _dpiFactor;
|
||
SetZoom(Math.Min(MaxZoom, want));
|
||
}
|
||
|
||
float cx = (m.RectPt.X + m.RectPt.Width / 2f) * _pxPerPt;
|
||
float cy = (m.RectPt.Y + m.RectPt.Height / 2f) * _pxPerPt;
|
||
var view = PageViewSize; // 视图尺寸(过渡帧拉伸期间按当前视图比例算滚动范围)
|
||
_scrollX = Clamp((int)cx - _canvas.ClientSize.Width / 2, 0, Math.Max(0, view.Width - _canvas.ClientSize.Width));
|
||
_scrollY = Clamp((int)cy - _canvas.ClientSize.Height / 2, 0, Math.Max(0, view.Height - _canvas.ClientSize.Height));
|
||
UpdateScrollbars();
|
||
SyncScrollbars();
|
||
|
||
_highlight.Clear();
|
||
_highlight.Add(handle);
|
||
RefreshMasks();
|
||
_highlightTimer.Stop();
|
||
_highlightTimer.Start();
|
||
}
|
||
|
||
public void DeleteFrames(List<string> handles)
|
||
{
|
||
if (handles == null || handles.Count == 0) return;
|
||
bool changed = false;
|
||
foreach (string h in handles)
|
||
{
|
||
int n = _masks.RemoveAll(m => m.Handle == h);
|
||
if (n > 0) changed = true;
|
||
_highlight.Remove(h);
|
||
}
|
||
if (changed)
|
||
{
|
||
SaveMasks();
|
||
RefreshMasks();
|
||
}
|
||
}
|
||
|
||
public void MergeMasks(string keep, List<string> removes)
|
||
{
|
||
PdfMaskItem k = null;
|
||
foreach (var m in _masks)
|
||
if (m.Handle == keep) { k = m; break; }
|
||
if (k == null) return;
|
||
bool changed = false;
|
||
foreach (string h in removes)
|
||
{
|
||
PdfMaskItem r = _masks.FirstOrDefault(m => m.Handle == h);
|
||
if (r == null) continue;
|
||
float l = Math.Min(k.RectPt.Left, r.RectPt.Left);
|
||
float t = Math.Min(k.RectPt.Top, r.RectPt.Top);
|
||
float rr = Math.Max(k.RectPt.Right, r.RectPt.Right);
|
||
float b = Math.Max(k.RectPt.Bottom, r.RectPt.Bottom);
|
||
k.RectPt = RectangleF.FromLTRB(l, t, rr, b);
|
||
_masks.Remove(r);
|
||
changed = true;
|
||
}
|
||
if (changed)
|
||
{
|
||
SaveMasks();
|
||
RefreshMasks();
|
||
}
|
||
}
|
||
|
||
public void ClearHighlight()
|
||
{
|
||
_highlight.Clear();
|
||
RefreshMasks();
|
||
}
|
||
|
||
// ============================================================
|
||
// 蒙版持久化
|
||
// ============================================================
|
||
|
||
private void SaveMasks()
|
||
{
|
||
if (string.IsNullOrEmpty(_maskSavePath)) return;
|
||
try
|
||
{
|
||
var data = new
|
||
{
|
||
counter = _handleCounter,
|
||
masks = _masks.Select(m => new
|
||
{
|
||
handle = m.Handle,
|
||
textHandle = m.TextHandle,
|
||
page = m.Page,
|
||
mode = m.Mode,
|
||
rect = new[] { m.RectPt.X, m.RectPt.Y, m.RectPt.Width, m.RectPt.Height }
|
||
}).ToArray()
|
||
};
|
||
File.WriteAllText(_maskSavePath, _json.Serialize(data));
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
private void LoadMasks()
|
||
{
|
||
_masks = new List<PdfMaskItem>();
|
||
_sessionPicked = new List<PdfMaskItem>();
|
||
_highlight.Clear();
|
||
_handleCounter = 1;
|
||
if (string.IsNullOrEmpty(_maskSavePath) || !File.Exists(_maskSavePath)) return;
|
||
try
|
||
{
|
||
var data = _json.Deserialize<Dictionary<string, object>>(File.ReadAllText(_maskSavePath));
|
||
if (data == null) return;
|
||
if (data.ContainsKey("counter"))
|
||
_handleCounter = Convert.ToInt32(data["counter"]);
|
||
var arr = data.ContainsKey("masks") ? data["masks"] as System.Collections.ArrayList : null;
|
||
if (arr != null)
|
||
{
|
||
foreach (Dictionary<string, object> m in arr)
|
||
{
|
||
var rect = m["rect"] as System.Collections.ArrayList;
|
||
var item = new PdfMaskItem
|
||
{
|
||
Handle = Convert.ToString(m["handle"]),
|
||
TextHandle = Convert.ToString(m["textHandle"]),
|
||
Page = Convert.ToInt32(m["page"]),
|
||
Mode = Convert.ToInt32(m["mode"]),
|
||
RectPt = new RectangleF(
|
||
Convert.ToSingle(rect[0]), Convert.ToSingle(rect[1]),
|
||
Convert.ToSingle(rect[2]), Convert.ToSingle(rect[3]))
|
||
};
|
||
_masks.Add(item);
|
||
_sessionPicked.Add(item);
|
||
}
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (disposing)
|
||
{
|
||
try { if (_ocr != null) _ocr.Dispose(); } catch { }
|
||
try
|
||
{
|
||
if (_doc != null)
|
||
{
|
||
_renderGen++; // 作废在途后台渲染
|
||
lock (PdfRenderGate) { _doc.Dispose(); } // 与后台渲染互斥后释放
|
||
}
|
||
}
|
||
catch { }
|
||
// Umi-OCR:只停由本程序启动的实例(用户自己开的 Umi-OCR 不动)
|
||
try { UmiOcrLauncher.StopIfStartedByUs(); } catch { } // ★ A1:启停已拆到 UmiOcrLauncher(主项目共享版)
|
||
}
|
||
base.Dispose(disposing);
|
||
}
|
||
|
||
// ============================================================
|
||
// 内部画布:位图 + 蒙版 + 橡皮筋(同一双缓冲绘制,无闪烁)
|
||
// ============================================================
|
||
private class ViewerCanvas : Control
|
||
{
|
||
private Bitmap _page;
|
||
public float PxPerPt = 1f;
|
||
/// <summary>视图比例/位图实际比例(缩放过渡帧>1 或 <1:位图待拉伸;高清渲染落图后回 1)。</summary>
|
||
public float ViewScale = 1f;
|
||
public List<PdfMaskItem> Masks = new List<PdfMaskItem>();
|
||
public HashSet<string> Highlight = new HashSet<string>();
|
||
public Rectangle Rubber = Rectangle.Empty;
|
||
public bool RubberMerge;
|
||
|
||
public ViewerCanvas()
|
||
{
|
||
SetStyle(ControlStyles.AllPaintingInWmPaint
|
||
| ControlStyles.OptimizedDoubleBuffer
|
||
| ControlStyles.UserPaint
|
||
| ControlStyles.ResizeRedraw, true);
|
||
Cursor = Cursors.Default;
|
||
TabStop = true;
|
||
}
|
||
|
||
public Bitmap Page
|
||
{
|
||
get { return _page; }
|
||
}
|
||
|
||
public void SetPage(Bitmap bmp, float pxPerPt)
|
||
{
|
||
if (_page != null && _page != bmp) _page.Dispose();
|
||
_page = bmp;
|
||
PxPerPt = pxPerPt;
|
||
ViewScale = 1f; // 新位图即视图比例
|
||
Invalidate();
|
||
}
|
||
|
||
/// <summary>缩放过渡帧:不换位图,只把视图比例改为 viewPxPerPt(旧位图按 ViewScale 拉伸显示),
|
||
/// 后台高清渲染完成 SetPage 后自动复位。位图未就绪时忽略。</summary>
|
||
public void SetViewScale(float viewPxPerPt)
|
||
{
|
||
if (_page == null || PxPerPt <= 0) return;
|
||
float st = viewPxPerPt / PxPerPt;
|
||
if (Math.Abs(st - ViewScale) < 0.001f) return;
|
||
ViewScale = st;
|
||
Invalidate();
|
||
}
|
||
|
||
protected override void OnPaint(PaintEventArgs e)
|
||
{
|
||
var g = e.Graphics;
|
||
g.Clear(BackColor);
|
||
if (_page == null) return;
|
||
|
||
var origin = GetOrigin();
|
||
// ★ 过渡帧:位图按 ViewScale 拉伸绘制(1:1 时与原 DrawImage 完全等价);
|
||
// 大图拉伸用 Bilinear(默认 HighQualityBicubic 更慢,过渡帧求即时反馈)
|
||
bool stretching = Math.Abs(ViewScale - 1f) > 0.001f;
|
||
if (stretching) g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.Bilinear;
|
||
g.DrawImage(_page, origin.X, origin.Y,
|
||
_page.Width * ViewScale, _page.Height * ViewScale);
|
||
|
||
float vpp = PxPerPt * ViewScale; // 当前视图比例(蒙版换算用)
|
||
|
||
// 蒙版框(页面pt → 屏幕)
|
||
foreach (var m in Masks)
|
||
{
|
||
var rect = new RectangleF(
|
||
origin.X + m.RectPt.X * vpp,
|
||
origin.Y + m.RectPt.Y * vpp,
|
||
m.RectPt.Width * vpp, m.RectPt.Height * vpp);
|
||
if (rect.Width < 1 || rect.Height < 1) continue;
|
||
bool hl = Highlight.Contains(m.Handle);
|
||
using (var fill = new SolidBrush(Color.FromArgb(70, m.Color)))
|
||
g.FillRectangle(fill, rect);
|
||
using (var pen = new Pen(m.Color, hl ? 4f : 2f))
|
||
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
|
||
if (hl)
|
||
{
|
||
using (var pen2 = new Pen(Color.FromArgb(120, Color.Gold), 7f))
|
||
g.DrawRectangle(pen2, rect.X - 3, rect.Y - 3,
|
||
rect.Width + 6, rect.Height + 6);
|
||
}
|
||
}
|
||
|
||
// 橡皮筋
|
||
if (Rubber.Width > 0 && Rubber.Height > 0)
|
||
{
|
||
Color rc = RubberMerge ? Color.OrangeRed : Color.RoyalBlue;
|
||
using (var fill = new SolidBrush(Color.FromArgb(40, rc)))
|
||
g.FillRectangle(fill, Rubber);
|
||
using (var pen = new Pen(rc, 1.5f) { DashStyle = DashStyle.Dash })
|
||
g.DrawRectangle(pen, Rubber.X, Rubber.Y, Rubber.Width, Rubber.Height);
|
||
}
|
||
base.OnPaint(e);
|
||
}
|
||
|
||
/// <summary>宿主注入的原点计算(避免画布反查滚动状态)。</summary>
|
||
public Func<Point> OriginProvider;
|
||
|
||
private Point GetOrigin()
|
||
{
|
||
return OriginProvider != null ? OriginProvider() : Point.Empty;
|
||
}
|
||
|
||
protected override bool IsInputKey(Keys keyData)
|
||
{
|
||
// 方向键给画布(避免被父容器抢去切焦点)
|
||
if ((keyData & Keys.KeyCode) == Keys.Left ||
|
||
(keyData & Keys.KeyCode) == Keys.Right ||
|
||
(keyData & Keys.KeyCode) == Keys.Up ||
|
||
(keyData & Keys.KeyCode) == Keys.Down)
|
||
return true;
|
||
return base.IsInputKey(keyData);
|
||
}
|
||
|
||
protected override void OnPaintBackground(PaintEventArgs e)
|
||
{
|
||
// 全部在 OnPaint 绘制(双缓冲)
|
||
}
|
||
}
|
||
}
|
||
}
|