Files

1540 lines
64 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(主窗口拼接为一条规格)
// · ★ 框选区域按 300DPI 高清重渲染后交当前 OCR 引擎识别
// —— 与屏幕缩放无关,任意缩放下识别质量一致,单次 ~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 System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace 成套报价软件
{
/// <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,过期结果直接丢弃
// ---------- 箱号组虚线框 ----------
// 点元件表/箱号表行时由 Form3 经 PdfBridge 调 SetGroupFrames:把该箱号的
// 箱号图框+全部元件图框按页求并集,每页画一个橙色虚线矩形圈住整组
private Dictionary<int, RectangleF> _groupRectPt = new Dictionary<int, RectangleF>();
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; }
}
/// <summary>当前打开 PDF 的图纸名(去扩展名;未打开为空)。扒图写入箱号「图纸」列用。</summary>
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));
}
/// <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();
// 翻页/缩放重渲染后同样带上当前页的组虚线框
RectangleF grpRender;
_canvas.GroupRectPt = _groupRectPt.TryGetValue(_pageIndex, out grpRender)
? grpRender : (RectangleF?)null;
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;
// 箱号组虚线框:只传当前页的并集矩形(pt 坐标,画布按 OriginProvider+PxPerPt 换算)
RectangleF grp;
_canvas.GroupRectPt = (_pxPerPt > 0 && _groupRectPt.TryGetValue(_pageIndex, out grp))
? grp : (RectangleF?)null;
_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 版一致):
// 图号→箱号→元件→回到箱号(循环);右键不再退出,退出只用 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);
}
/// <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())
{
_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 + 画蒙版 + 通知主窗口。</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);
// ★ 引导链(与网页 CAD 版一致):图号成功一次→自动进箱号;箱号成功一次→自动进元件;
// 元件停留连续扒(右键回箱号)。StartPick 会重写状态栏,过渡信息放在其后。
if (mode == 0)
{
StartPick(1);
_statusLeft.Text = "已识别 " + valid.Count + " 条,自动进入【扒箱号】";
}
else if (mode == 1)
{
StartPick(2);
_statusLeft.Text = "已识别 " + valid.Count + " 条,自动进入【扒元件】";
}
}
/// <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();
}
/// <summary>显示箱号组虚线框:把给定框Handle集合对应的蒙版按页求并集,
/// 每页画一个橙色虚线矩形(点元件表/箱号表行时由 Form3 经 PdfBridge 调用)。
/// 不在当前蒙版集里的 Handle(别的 PDF 扒的/已删的)自动忽略。</summary>
public void SetGroupFrames(IEnumerable<string> handles)
{
SetGroupFrames(handles, null);
}
/// <summary>带跳转的组框显示:focusHandle=点中的那个框——翻到它所在页,
/// 缩放改为"装下该页的组框"(而不是缩到单框),并保留单框高亮3秒反馈。
/// 焦点框不在当前蒙版集里(开的不是那张 PDF)时只画框不跳转。</summary>
public void SetGroupFrames(IEnumerable<string> 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();
}
/// <summary>清除箱号组虚线框(点图号表行/新开 PDF 时)。</summary>
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<PdfMaskItem>();
_sessionPicked = new List<PdfMaskItem>();
_highlight.Clear();
_groupRectPt.Clear(); // 新 PDF 的蒙版集全换,组虚线框一并清掉
_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 { }
}
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;
/// <summary>箱号组虚线框(当前页,页面pt坐标;null=不画)。橙色虚线矩形圈住该箱号全部图框。</summary>
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();
}
/// <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 (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);
}
/// <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 绘制(双缓冲)
}
}
}
}