Files
ctbjrj/pdfbt/PdfViewerWindow.cs
T

1401 lines
57 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 绘制(双缓冲)
}
}
}
}