// ============================================================ // PdfViewerWindow:停靠窗口(中央文档区)—— PDF 查看 + 框选 OCR 扒图 // · pdfium 渲染;单画布自管理滚动偏移 + 双缓冲 → 拖动/滚动无闪烁 // · 框选(仿 CAD 窗选语义): // 正向(左→右) = 逐条:每个识别文本一个彩色蒙版框、各自 Handle // 反向(右→左) = 合并:整块一个蒙版框、共享 Handle(主窗口拼接为一条规格) // · ★ 框选区域按 300DPI 高清重渲染后交当前 OCR 引擎识别 // —— 与屏幕缩放无关,任意缩放下识别质量一致,单次 ~0.1-0.3s // · 结果按"列分组、列内从上到下"排序(与 CAD 版 PaTextSelector 一致) // · 蒙版持久化:.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 System.Windows.Forms; using WeifenLuo.WinFormsUI.Docking; namespace 成套报价软件 { /// 一个已绘制的蒙版框。 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); // 元件 绿 } } } /// PDF 查看器停靠窗口(DockState.Document)。 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 _masks = new List(); private HashSet _highlight = new HashSet(); private int _handleCounter = 1; private List _sessionPicked = new List(); private string _maskSavePath; // ★ 缩放卡顿修复(2026-10-01):光栅化转后台线程的两根支柱 private static readonly object PdfRenderGate = new object(); // pdfium 非线程安全,所有渲染/文档操作全局互斥 private volatile int _renderGen; // 渲染代数:每次发起渲染+1,过期结果直接丢弃 // ---------- 箱号组虚线框 ---------- // 点元件表/箱号表行时由 Form3 经 PdfBridge 调 SetGroupFrames:把该箱号的 // 箱号图框+全部元件图框按页求并集,每页画一个橙色虚线矩形圈住整组 private Dictionary _groupRectPt = new Dictionary(); private OcrClient _ocr; private readonly JavaScriptSerializer _json = new JavaScriptSerializer(); private EventHandler _btnOcrcfg_Click; /// OCR 设置:打开设计器窗体 OcrSettingsForm。 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; } } /// 当前打开 PDF 的图纸名(去扩展名;未打开为空)。扒图写入箱号「图纸」列用。 public string CurrentDrawingName { get { if (_doc == null || !_doc.IsOpen || string.IsNullOrEmpty(_doc.FilePath)) return ""; try { return Path.GetFileNameWithoutExtension(_doc.FilePath); } catch { return ""; } } } public bool PickModeActive { get { return _pickMode >= 0; } } public PdfViewerWindow() { BuildUi(); this.BackColor = System.Drawing.Color.White; 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)); } /// 异步重渲染当前页(等比限制在 MAX_RENDER_DIM 内)。 /// ★ 缩放卡顿修复(2026-10-01):原来 UI 线程同步光栅化整页,大幅面工程图每档滚轮 /// 冻结数百毫秒~秒级。现在:视图比例立即生效+旧位图拉伸过渡帧(滚轮即时反馈), /// 光栅化转后台线程;连续触发用代数令牌只落最后一档,过期任务连渲染都不做。 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(); // 翻页/缩放重渲染后同样带上当前页的组虚线框 RectangleF grpRender; _canvas.GroupRectPt = _groupRectPt.TryGetValue(_pageIndex, out grpRender) ? grpRender : (RectangleF?)null; UpdateScrollbars(); _canvas.Invalidate(); }, TaskScheduler.FromCurrentSynchronizationContext()); } private List CurrentPageMasks() { var list = new List(); foreach (var m in _masks) if (m.Page == _pageIndex) list.Add(m); return list; } private void RefreshMasks() { _canvas.Masks = CurrentPageMasks(); _canvas.Highlight = _highlight; // 箱号组虚线框:只传当前页的并集矩形(pt 坐标,画布按 OriginProvider+PxPerPt 换算) RectangleF grp; _canvas.GroupRectPt = (_pxPerPt > 0 && _groupRectPt.TryGetValue(_pageIndex, out grp)) ? grp : (RectangleF?)null; _canvas.Invalidate(); } // ============================================================ // 滚动条 / 偏移 // ============================================================ /// 页面位图按当前视图比例(ViewScale)的显示尺寸。 /// 过渡帧拉伸期间与位图实际尺寸不同,滚动范围/原点/居中都必须用它。 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); } /// 页面位图在画布上的绘制原点(小于视口时居中)。按视图尺寸(含过渡帧拉伸)计算。 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 版一致): // 图号→箱号→元件→回到箱号(循环);右键不再退出,退出只用 ESC if (_pickMode >= 0) { _picking = false; _canvas.Rubber = Rectangle.Empty; if (_pickMode == 0) StartPick(1); else if (_pickMode == 1) StartPick(2); else StartPick(1); } 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 = { "扒图号", "扒箱号", "扒元件" }; // ★ 引导链文案(与网页 CAD 版一致):图号/箱号成功后自动前进,元件右键回箱号 string hint; if (mode == 0) hint = "成功后自动进入扒箱号"; else if (mode == 1) hint = "成功后自动进入扒元件"; else hint = "可连续扒,右键回到扒箱号"; _lblMode.Text = names[mode] + ":框选中(左→右逐条 / 右→左合并)," + hint + ",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); } /// /// 框选完成:客户区矩形 → 页面pt矩形 → 300DPI 高清重渲染 → OCR。 /// ★ 与屏幕缩放无关:任意缩放下识别质量一致。 /// 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()) { _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 空闲"; } /// /// 高风险短串:2-4 字符、含 ASCII 字母、非纯数字、不含汉字。 /// (X1/M4/KI 这类图形误读高发形态;纯数字数量、汉字、长规格不受此门约束) /// 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; } /// /// 图形误读判定(基于裁剪图 bbox 区域的笔画特征): /// · 百度置信度 <0.8(真短标注通常 >0.9;GLM Score=1 跳过,由下面两条兜底) /// · 墨水密度 ≥22%(前景=亮度<140;粗线符号 15-60%,文字 5-15%) /// · 水平行程中位数 ≥8px(粗笔画;文字笔画 2-5px@300DPI) /// 三条任一命中即判图形。 /// 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); } /// 计算 bbox 区域墨水密度与水平行程中位数,超限返回 true。 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(); 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; } /// 过滤丢弃记录(exe 旁 filter.log,便于调阈值);失败静默。 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 { } } /// /// 剥离图形符号字符:电气图纸上的小三角(▲▼◀▶△)、箭头、制表线、 /// 杂项符号等被 OCR 误当文字的内容。剥完只剩空白 → 整条丢弃(调用处判断)。 /// ★ 只删明确的符号区段,不碰正常文字/标点/工程符号(-/.×x#等)。 /// 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; } /// 图形符号字符判定(几何图形/箭头/制表线/杂项符号/零宽字符)。 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; } /// Windows OCR 符号归一化:间隔号/全角/Unicode 连字符 → 半角工程符号。 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(); } /// OCR 完成:按 CAD 语义组装 results + 画蒙版 + 通知主窗口。 private void HandleOcrResult(int mode, List items, RectangleF rectPt, bool merge, float ocrScale, long elapsedMs, Bitmap crop) { var valid = new List(); 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(); 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); // ★ 引导链(与网页 CAD 版一致):图号成功一次→自动进箱号;箱号成功一次→自动进元件; // 元件停留连续扒(右键回箱号)。StartPick 会重写状态栏,过渡信息放在其后。 if (mode == 0) { StartPick(1); _statusLeft.Text = "已识别 " + valid.Count + " 条,自动进入【扒箱号】"; } else if (mode == 1) { StartPick(2); _statusLeft.Text = "已识别 " + valid.Count + " 条,自动进入【扒元件】"; } } /// 按"列分组、列内从上到下"排序(阅读顺序)。 private static List SortByColumns(List items) { var byX = items.OrderBy(i => i.Bounds.X).ToList(); var columns = new List>(); 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 { it }); } var result = new List(); 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++); } /// 稳定文本标识:页码 + 中心点(10pt 网格) + 尺寸(5pt 网格)。 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); } /// 会话内复用:同页 IoU>0.5 的旧框 → 沿用其 TextHandle。 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 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 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(); } /// 显示箱号组虚线框:把给定框Handle集合对应的蒙版按页求并集, /// 每页画一个橙色虚线矩形(点元件表/箱号表行时由 Form3 经 PdfBridge 调用)。 /// 不在当前蒙版集里的 Handle(别的 PDF 扒的/已删的)自动忽略。 public void SetGroupFrames(IEnumerable handles) { SetGroupFrames(handles, null); } /// 带跳转的组框显示:focusHandle=点中的那个框——翻到它所在页, /// 缩放改为"装下该页的组框"(而不是缩到单框),并保留单框高亮3秒反馈。 /// 焦点框不在当前蒙版集里(开的不是那张 PDF)时只画框不跳转。 public void SetGroupFrames(IEnumerable handles, string focusHandle) { _groupRectPt.Clear(); if (handles != null) { foreach (string h in handles) { if (string.IsNullOrEmpty(h)) continue; PdfMaskItem m = null; foreach (var k in _masks) if (k.Handle == h) { m = k; break; } if (m == null) continue; if (_groupRectPt.ContainsKey(m.Page)) { var g = _groupRectPt[m.Page]; _groupRectPt[m.Page] = RectangleF.Union(g, m.RectPt); } else { _groupRectPt[m.Page] = m.RectPt; } } } // ★ 跳转:翻到焦点页 → 缩放到组框适配视口 → 滚动居中 → 高亮焦点框 PdfMaskItem 焦点 = null; if (!string.IsNullOrEmpty(focusHandle)) foreach (var k in _masks) if (k.Handle == focusHandle) { 焦点 = k; break; } if (焦点 != null && _groupRectPt.Count > 0) { if (焦点.Page != _pageIndex) { _pageIndex = 焦点.Page; RenderCurrentPage(clearPage: true); // 跨页定位:清旧页位图 UpdateNavUi(); } RectangleF grp; if (_groupRectPt.TryGetValue(_pageIndex, out grp) && grp.Width > 0 && grp.Height > 0) { // 缩放:组框(pt)按视口可用区适配(FitWidth 同款 16pt 边距) int availW = _canvas.ClientSize.Width - (int)_dpiFactor * 16; int availH = _canvas.ClientSize.Height - (int)_dpiFactor * 16; if (availW > 10 && availH > 10) { float want = Math.Min(availW / (grp.Width * _dpiFactor), availH / (grp.Height * _dpiFactor)); SetZoom(Math.Max(0.05f, Math.Min(MaxZoom, want))); } // 居中:滚动到组框中心 float cx = (grp.X + grp.Width / 2f) * _pxPerPt; float cy = (grp.Y + grp.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(); } // 保留"点中的框"高亮3秒的原反馈 _highlight.Clear(); _highlight.Add(焦点.Handle); _highlightTimer.Stop(); _highlightTimer.Start(); } RefreshMasks(); } /// 清除箱号组虚线框(点图号表行/新开 PDF 时)。 public void ClearGroupFrames() { if (_groupRectPt.Count == 0) return; _groupRectPt.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(); _sessionPicked = new List(); _highlight.Clear(); _groupRectPt.Clear(); // 新 PDF 的蒙版集全换,组虚线框一并清掉 _handleCounter = 1; if (string.IsNullOrEmpty(_maskSavePath) || !File.Exists(_maskSavePath)) return; try { var data = _json.Deserialize>(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 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 { } } base.Dispose(disposing); } // ============================================================ // 内部画布:位图 + 蒙版 + 橡皮筋(同一双缓冲绘制,无闪烁) // ============================================================ private class ViewerCanvas : Control { private Bitmap _page; public float PxPerPt = 1f; /// 视图比例/位图实际比例(缩放过渡帧>1 或 <1:位图待拉伸;高清渲染落图后回 1)。 public float ViewScale = 1f; public List Masks = new List(); public HashSet Highlight = new HashSet(); public Rectangle Rubber = Rectangle.Empty; public bool RubberMerge; /// 箱号组虚线框(当前页,页面pt坐标;null=不画)。橙色虚线矩形圈住该箱号全部图框。 public RectangleF? GroupRectPt = null; 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(); } /// 缩放过渡帧:不换位图,只把视图比例改为 viewPxPerPt(旧位图按 ViewScale 拉伸显示), /// 后台高清渲染完成 SetPage 后自动复位。位图未就绪时忽略。 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 (GroupRectPt.HasValue) { var gr = GroupRectPt.Value; var grect = new RectangleF( origin.X + gr.X * vpp - 4f, origin.Y + gr.Y * vpp - 4f, gr.Width * vpp + 8f, gr.Height * vpp + 8f); if (grect.Width > 2 && grect.Height > 2) using (var pen = new Pen(Color.OrangeRed, 2.5f) { DashStyle = DashStyle.Dash }) g.DrawRectangle(pen, grect.X, grect.Y, grect.Width, grect.Height); } // 橡皮筋 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); } /// 宿主注入的原点计算(避免画布反查滚动状态)。 public Func 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 绘制(双缓冲) } } } }