A2:连接字符串统一收敛(6份+导入报价裸串→数据库连接.串/串无池,调系数/甲方报表/导入报价由裸串升级WAL+超时); A1:OCR/PDF基础设施链接共享(pdfbt链接主项目扒图源11文件,删10个旧副本,pdfbt获得ocr.cls方向纠正等修复); 双项目编译零警告通过,扒图护栏PASS

This commit is contained in:
dqb-dev
2026-10-01 23:12:48 +08:00
parent 1e65792b09
commit 22fed025bb
25 changed files with 95 additions and 2723 deletions
-355
View File
@@ -1,355 +0,0 @@
// ============================================================
// GlmOcrClient:智谱 GLM-OCR 云端接口(版面解析 layout_parsing)
// · POST {BaseUrl} Authorization: Bearer {ApiKey}
// {"model":"glm-ocr","file":"data:image/png;base64,..."}
// · 响应 layout_details[页][{label,bbox_2d(归一化),content,width,height}]
// → label=text 的块转 OcrItem(bbox 映射回裁剪图像素坐标)
// · 手写字识别(模型内置,无需专用参数);价格 ~0.2 元/百万 tokens
// · 纯 C# HTTP,无需 Python;引擎切换由 OcrClient 路由
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace pdfbt
{
/// <summary>智谱 GLM-OCR 客户端(单实例)。</summary>
internal sealed class GlmOcrClient
{
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _apiKey;
private string _baseUrl;
/// <summary>状态提示(未配置Key/请求失败等),接 OcrClient.StatusChanged。</summary>
public event Action<string> StatusChanged;
public GlmOcrClient()
{
_baseUrl = ReadAppSetting("GlmBaseUrl",
"https://open.bigmodel.cn/api/paas/v4/layout_parsing");
try
{
// net48 下显式启用 TLS1.2(大模型 API 必须)
ServicePointManager.SecurityProtocol =
ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12;
}
catch { }
}
/// <summary>Key 由 OcrClient 从 baidu_ocr.json 的 glm_api_key 注入(热更新)。</summary>
public void SetApiKey(string key)
{
_apiKey = key == null ? "" : key.Trim();
}
public bool IsConfigured
{
get { return !string.IsNullOrWhiteSpace(_apiKey); }
}
private static string ReadAppSetting(string key, string def)
{
try
{
var v = System.Configuration.ConfigurationManager.AppSettings[key];
return string.IsNullOrWhiteSpace(v) ? def : v;
}
catch { return def; }
}
/// <summary>识别一块位图 → 文本块列表(带 bbox 像素坐标)。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
if (crop == null || crop.Width <= 0 || crop.Height <= 0)
return Task.FromResult(new List<OcrItem>());
if (!IsConfigured)
{
Notify("GLM OCR 未配置 API Key(OCR 设置窗口里填智谱 API Key)");
var tcs0 = new TaskCompletionSource<List<OcrItem>>();
tcs0.SetException(new Exception(
"智谱 GLM-OCR 未配置 API Key。\r\n请到 open.bigmodel.cn 获取 Key," +
"在 OCR 设置窗口选择引擎\"智谱 GLM-OCR\"并填入 Key 后保存。"));
return tcs0.Task;
}
return Task.Factory.StartNew(() =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = RequestAndParse(crop, timeoutMs);
Notify("智谱GLM-OCR·" + result.Count + "条 " + sw.ElapsedMilliseconds + "ms");
return result;
}
catch
{
throw;
}
});
}
private List<OcrItem> RequestAndParse(Bitmap crop, int timeoutMs)
{
byte[] png;
using (var ms = new MemoryStream())
{
crop.Save(ms, ImageFormat.Png);
png = ms.ToArray();
}
// 服务端期望 data URI 或 URL(SDK 自动包装 data URI,此处显式包装)
string fileField = "data:image/png;base64," + Convert.ToBase64String(png);
string body = _json.Serialize(new Dictionary<string, object>
{
{ "model", "glm-ocr" },
{ "file", fileField }
});
var req = (HttpWebRequest)WebRequest.Create(_baseUrl);
req.Method = "POST";
req.ContentType = "application/json";
req.Headers.Add("Authorization", "Bearer " + _apiKey);
req.Timeout = timeoutMs;
req.ReadWriteTimeout = timeoutMs;
req.Proxy = null; // 尊重直连,避免系统代理拖慢
var bytes = Encoding.UTF8.GetBytes(body);
req.ContentLength = bytes.Length;
using (var rs = req.GetRequestStream())
{
rs.Write(bytes, 0, bytes.Length);
}
string respText;
try
{
using (var resp = (HttpWebResponse)req.GetResponse())
using (var sr = new StreamReader(resp.GetResponseStream(),
new UTF8Encoding(false)))
{
respText = sr.ReadToEnd();
}
}
catch (WebException wex)
{
string detail = "";
try
{
if (wex.Response != null)
using (var sr = new StreamReader(wex.Response.GetResponseStream(),
new UTF8Encoding(false)))
detail = sr.ReadToEnd();
}
catch { }
string hint = wex.Response == null
? ("网络不可达: " + wex.Message)
: ("HTTP " + (int)((HttpWebResponse)wex.Response).StatusCode + " " + detail);
throw new Exception("GLM OCR 请求失败: " + hint);
}
return ParseResponse(respText, crop.Width, crop.Height);
}
/// <summary>解析 layout_parsing 响应 → OcrItem 列表(text 块)。</summary>
private List<OcrItem> ParseResponse(string respText, int cropW, int cropH)
{
var msg = _json.Deserialize<Dictionary<string, object>>(respText);
if (msg == null) throw new Exception("GLM 响应解析失败(空响应)");
if (msg.ContainsKey("error"))
{
var err = msg["error"] as Dictionary<string, object>;
string code = err != null && err.ContainsKey("code") ? Convert.ToString(err["code"]) : "?";
string message = err != null && err.ContainsKey("message") ? Convert.ToString(err["message"]) : respText;
throw new Exception("GLM OCR 错误 [" + code + "]: " + message);
}
var items = new List<OcrItem>();
var pages = msg.ContainsKey("layout_details")
? msg["layout_details"] as System.Collections.ArrayList : null;
if (pages == null || pages.Count == 0)
throw new Exception("GLM 响应没有 layout_details(内容: " +
(respText.Length > 300 ? respText.Substring(0, 300) : respText) + ")");
// 单页输入 → 取第一页的块
var blocks = pages[0] as System.Collections.ArrayList;
if (blocks == null) return items;
foreach (Dictionary<string, object> b in blocks)
{
string label = b.ContainsKey("label") ? Convert.ToString(b["label"]) : "";
string content = b.ContainsKey("content") ? Convert.ToString(b["content"]) : "";
if (string.IsNullOrWhiteSpace(content)) continue;
var bbox = b.ContainsKey("bbox_2d") ? b["bbox_2d"] as System.Collections.ArrayList : null;
if (bbox == null || bbox.Count < 4) continue;
// ★ 实测返回像素坐标;官方文档描述归一化(0~1)。按值域自动兼容:
// 四个值全部 ≤1.5 → 归一化,乘图尺寸;否则直接当像素用。
float a0 = Convert.ToSingle(bbox[0]), a1 = Convert.ToSingle(bbox[1]);
float a2 = Convert.ToSingle(bbox[2]), a3 = Convert.ToSingle(bbox[3]);
bool normalized = a0 <= 1.5f && a1 <= 1.5f && a2 <= 1.5f && a3 <= 1.5f;
float x1 = normalized ? a0 * cropW : a0;
float y1 = normalized ? a1 * cropH : a1;
float x2 = normalized ? a2 * cropW : a2;
float y2 = normalized ? a3 * cropH : a3;
// 裁剪到图内(超界坐标画在页面外会导致蒙版不可见)
x1 = Math.Max(0, Math.Min(cropW, x1));
y1 = Math.Max(0, Math.Min(cropH, y1));
x2 = Math.Max(0, Math.Min(cropW, x2));
y2 = Math.Max(0, Math.Min(cropH, y2));
if (label == "table")
{
// ★ 框选多行文字常被判定为表格块(content 为 <table> HTML):
// 每个 <tr> 一条文本(单元格空格拼接),行高均分定位。
// 必须在 CleanHtml 剥标签之前解析结构。
AddTableItems(items, content, x1, y1, x2, y2);
continue;
}
if (label != "text") continue; // 图片/公式块跳过
content = CleanHtmlShared(content); // 居中块会返回 <div align=..>..</div> 等 HTML
if (string.IsNullOrWhiteSpace(content)) continue;
// 多行文本块拆行(content 按 \n,块内均匀分布);清理 Markdown 标记
// ★ 行间上下各收缩 15%(≤3px):蒙版之间留空隙,不连成一片
string[] lines = content.Split(new[] { '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries);
float lineH = (y2 - y1) / lines.Length;
float pad = Math.Min(lineH * 0.15f, 3f);
for (int i = 0; i < lines.Length; i++)
{
string text = CleanMarkdownShared(lines[i]).Trim();
if (text.Length == 0) continue;
float ty1 = y1 + lineH * i + pad;
float ty2 = y1 + lineH * (i + 1) - pad;
if (ty2 - ty1 < 2) { ty1 = y1 + lineH * i; ty2 = ty1 + Math.Max(2, lineH); }
items.Add(new OcrItem
{
Text = text,
Box = new[]
{
new PointF(x1, ty1),
new PointF(x2, ty1),
new PointF(x2, ty2),
new PointF(x1, ty2)
},
Score = 1f
});
}
}
return items;
}
private static readonly System.Text.RegularExpressions.Regex _trRegex =
new System.Text.RegularExpressions.Regex("<tr[^>]*>(.*?)</tr>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex _tdRegex =
new System.Text.RegularExpressions.Regex("<t[dh][^>]*>(.*?)</t[dh]>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>表格块 → 每 tr 一条 item(td 内容空格拼接),行高均分 bbox。</summary>
private static void AddTableItems(List<OcrItem> items, string tableHtml,
float x1, float y1, float x2, float y2)
{
var rows = new List<string>();
foreach (System.Text.RegularExpressions.Match tr in _trRegex.Matches(tableHtml))
{
var cells = new List<string>();
foreach (System.Text.RegularExpressions.Match td in _tdRegex.Matches(tr.Groups[1].Value))
{
string cell = CleanHtmlShared(td.Groups[1].Value).Trim();
if (cell.Length > 0) cells.Add(cell);
}
if (cells.Count > 0) rows.Add(string.Join(" ", cells.ToArray()));
}
if (rows.Count == 0) return;
float rowH = (y2 - y1) / rows.Count;
float pad = Math.Min(rowH * 0.15f, 3f); // 行间留空隙,蒙版不连片
for (int i = 0; i < rows.Count; i++)
{
float ty1 = y1 + rowH * i + pad;
float ty2 = y1 + rowH * (i + 1) - pad;
if (ty2 - ty1 < 2) { ty1 = y1 + rowH * i; ty2 = ty1 + Math.Max(2, rowH); }
items.Add(new OcrItem
{
Text = rows[i],
Box = new[]
{
new PointF(x1, ty1),
new PointF(x2, ty1),
new PointF(x2, ty2),
new PointF(x1, ty2)
},
Score = 1f
});
}
}
private static readonly System.Text.RegularExpressions.Regex _htmlTag =
new System.Text.RegularExpressions.Regex("<[^>]*>",
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>
/// 剥离 GLM content 里的 HTML 片段(居中块返回 div 等)与常见实体;
/// 标签替换为空格,避免前后文字误粘连;连续空格合并。
/// </summary>
internal static string CleanHtmlShared(string s)
{
if (string.IsNullOrEmpty(s)) return s;
if (s.IndexOf('<') >= 0)
{
s = _htmlTag.Replace(s, " ");
s = s.Replace("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&apos;", "'");
}
while (s.Contains(" ")) s = s.Replace(" ", " ");
return s;
}
/// <summary>
/// 清理 GLM 返回 content 里的 Markdown 标记(★保守规则,不伤正文):
/// · 行首 "##"/"#" 标题井号(仅行首;"2#配电箱" 中间的 # 保留)
/// · 成对 **加粗** 星号、行首 "- "/"* " 列表标记
/// </summary>
internal static string CleanMarkdownShared(string s)
{
if (string.IsNullOrEmpty(s)) return s;
// 行首标题:# 开头且后跟空格/更多#/行尾 → 剥掉行首所有 #
string t = s.TrimStart();
if (t.StartsWith("#"))
{
int i = 0;
while (i < t.Length && t[i] == '#') i++;
// "#..." 后是空格或行尾才视为标题标记
if (i >= t.Length || t[i] == ' ' || t[i] == '\t')
t = t.Substring(i).TrimStart();
// 否则(如 "#5 线缆")保留原样
else t = s;
}
// 行首列表标记
if (t.StartsWith("- ") || t.StartsWith("* "))
t = t.Substring(2);
// 成对 ** (加粗) 去星号
if (t.Contains("**"))
t = t.Replace("**", "");
return t;
}
private void Notify(string s)
{
var h = StatusChanged;
if (h != null) h(s);
}
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
using System;
using System.IO;
using System.Windows.Forms;
using 成套报价软件; // ★ A1:OCR/PDF 基础设施已链接共享主项目源码(类型在 成套报价软件 命名空间)
namespace pdfbt
{
public partial class MainForm : Form
@@ -32,7 +32,7 @@ namespace pdfbt
.Deserialize<System.Collections.Generic.Dictionary<string, string>>(
File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json")));
string eng = jsv != null && jsv.ContainsKey("engine") ? jsv["engine"] : "baidu";
if (eng == "umi" && UmiOcrHost.IsInstalled) UmiOcrHost.EnsureRunning();
if (eng == "umi" && UmiOcrHost.IsInstalled) UmiOcrLauncher.EnsureRunning(); // ★ A1:启停已拆到 UmiOcrLauncher(主项目共享版)
}
}
catch { }
-417
View File
@@ -1,417 +0,0 @@
// ============================================================
// OcrClient:OCR 引擎路由(baidu_ocr.json 的 engine 字段选择)
// · engine=baidu(默认):百度 OCR 高精度含位置版(HTTP 直连)
// · engine=glm:智谱 GLM-OCR 版面解析(手写字强,GlmOcrClient)
// · Key 都在 exe 旁 baidu_ocr.json(OCR设置窗口写):
// api_key/secret_key=百度;glm_api_key=智谱
// 百度未保存过/Key 为空 → 内置默认 Key 直接可用
// · 百度 access_token 缓存 exe 旁 baidu_token.json(29 天,提前1天过期)
// · 未配 Key / 网络失败直接抛异常(界面提示),无本地回退
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace pdfbt
{
public class OcrItem
{
public string Text;
public PointF[] Box; // 裁剪图像素坐标,4 点(左上起顺时针)
public float Score;
public RectangleF Bounds
{
get
{
if (Box == null || Box.Length == 0) return RectangleF.Empty;
float l = Box[0].X, t = Box[0].Y, r = Box[0].X, b = Box[0].Y;
foreach (var p in Box)
{
if (p.X < l) l = p.X; if (p.X > r) r = p.X;
if (p.Y < t) t = p.Y; if (p.Y > b) b = p.Y;
}
return RectangleF.FromLTRB(l, t, r, b);
}
}
}
public class OcrClient : IDisposable
{
public event Action<string> StatusChanged;
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _ak = "", _sk = "";
private string _token;
private DateTime _tokenExpire = DateTime.MinValue;
// ★ 引擎路由:baidu(默认)/glm(智谱 GLM-OCR)/qianfan(百度 PaddleOCR-VL)/uapis(UApiPro)/umi(Umi-OCR 本地)
private string _engine = "baidu";
private readonly GlmOcrClient _glm = new GlmOcrClient();
private readonly QianfanOcrClient _qianfan = new QianfanOcrClient();
private readonly UapisOcrClient _uapis = new UapisOcrClient();
private readonly UmiOcrClient _umi = new UmiOcrClient();
/// <summary>当前 OCR 引擎(baidu/glm/qianfan/uapis/umi)。</summary>
public string Engine
{
get { return _engine; }
}
// 内置默认 Key(Base64 混淆,exe 内非明文):未保存过配置/Key 为空时直接使用
private static readonly string _defAk = Encoding.UTF8.GetString(Convert.FromBase64String("R0hjbjVxRmJLcG5TY3NyZHlIV1M4dzBB"));
private static readonly string _defSk = Encoding.UTF8.GetString(Convert.FromBase64String("VkMxVVU2RXlxN28wcGRLNElCZ1A4ZnNhWjIyQ3RlMWM="));
public OcrClient()
{
_glm.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_qianfan.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_uapis.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_umi.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
LoadConfig();
}
/// <summary>读 baidu_ocr.json(OCR设置窗口保存后调用可热更新 Key/引擎)。</summary>
public void LoadConfig()
{
try
{
string p = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json");
if (File.Exists(p))
{
var jsv = _json.Deserialize<Dictionary<string, string>>(File.ReadAllText(p));
if (jsv != null)
{
_ak = jsv.ContainsKey("api_key") ? (jsv["api_key"] ?? "") : "";
_sk = jsv.ContainsKey("secret_key") ? (jsv["secret_key"] ?? "") : "";
_engine = jsv.ContainsKey("engine") ? (jsv["engine"] ?? "baidu") : "baidu";
_glm.SetApiKey(jsv.ContainsKey("glm_api_key") ? (jsv["glm_api_key"] ?? "") : "");
_qianfan.SetApiKey(jsv.ContainsKey("qianfan_api_key") ? (jsv["qianfan_api_key"] ?? "") : "");
_uapis.SetApiKey(jsv.ContainsKey("uapis_api_key") ? (jsv["uapis_api_key"] ?? "") : "");
_umi.SetBaseUrl(jsv.ContainsKey("umi_url") ? (jsv["umi_url"] ?? "") : "");
}
}
}
catch { }
// 缺省回退内置默认 Key:装完即用,无需打开设置点保存;用户保存过则文件值优先
if (string.IsNullOrEmpty(_ak)) _ak = _defAk;
if (string.IsNullOrEmpty(_sk)) _sk = _defSk;
if (_engine != "baidu" && _engine != "glm" && _engine != "qianfan" && _engine != "uapis" && _engine != "umi")
_engine = "baidu";
}
public bool HasKey
{
get { return !string.IsNullOrEmpty(_ak) && !string.IsNullOrEmpty(_sk); }
}
private void Notify(string msg)
{
var h = StatusChanged;
if (h != null) h(msg);
}
/// <summary>获取 access_token(内存→文件缓存→在线换取)。失败抛异常。</summary>
private string GetToken()
{
if (_token != null && DateTime.Now < _tokenExpire) return _token;
string cachePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_token.json");
try
{
if (File.Exists(cachePath))
{
var c = _json.Deserialize<Dictionary<string, object>>(File.ReadAllText(cachePath));
if (c != null && c.ContainsKey("token") && c.ContainsKey("expire"))
{
double exp;
double.TryParse(Convert.ToString(c["expire"]), out exp);
string tok = Convert.ToString(c["token"]);
if (tok.Length > 0 && DateTime.Now < new DateTime(1970, 1, 1).AddSeconds(exp))
{
_token = tok;
_tokenExpire = new DateTime(1970, 1, 1).AddSeconds(exp);
return _token;
}
}
}
}
catch { }
if (!HasKey)
throw new Exception("未配置百度 OCR Key(工具栏 OCR设置 填写 API Key / Secret Key)");
string url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials"
+ "&client_id=" + Uri.EscapeDataString(_ak)
+ "&client_secret=" + Uri.EscapeDataString(_sk);
using (var wc = new WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
string body = wc.UploadString(url, "POST", "");
var resp = _json.Deserialize<Dictionary<string, object>>(body);
if (resp == null || !resp.ContainsKey("access_token"))
throw new Exception("百度 token 获取失败: " +
(resp != null && resp.ContainsKey("error_description")
? Convert.ToString(resp["error_description"])
: body.Substring(0, Math.Min(120, body.Length))));
_token = Convert.ToString(resp["access_token"]);
double expires;
double.TryParse(Convert.ToString(resp.ContainsKey("expires_in") ? resp["expires_in"] : "2592000"), out expires);
_tokenExpire = DateTime.Now.AddSeconds(Math.Max(3600, expires - 86400));
try
{
var cache = new Dictionary<string, object>
{
{ "token", _token },
{ "expire", (_tokenExpire - new DateTime(1970, 1, 1)).TotalSeconds }
};
File.WriteAllText(cachePath, _json.Serialize(cache));
}
catch { }
return _token;
}
}
/// <summary>识别位图(300DPI 裁剪)。失败抛异常;成功通知引擎来源。
/// 竖排文字(实为旋转 90° 的横排)直接识别必乱码:细高框或平均置信度偏低时,
/// 旋转 ±90° 重识别并按平均置信度选优,坐标逆向还原回原图。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
// ★ 引擎路由:智谱 GLM-OCR / 千帆 PaddleOCR-VL(版面解析,不走旋转重试)
if (_engine == "glm")
return _glm.RecognizeAsync(crop, timeoutMs);
if (_engine == "qianfan")
return _qianfan.RecognizeAsync(crop, timeoutMs);
if (_engine == "uapis")
return _uapis.RecognizeAsync(crop, timeoutMs);
if (_engine == "umi")
return _umi.RecognizeAsync(crop, timeoutMs);
var tcs = new TaskCompletionSource<List<OcrItem>>();
Task.Factory.StartNew(delegate
{
try
{
var items = RecognizeOnce(crop, timeoutMs);
try
{
if (crop.Height >= crop.Width * 1.15f || AvgScore(items) < 0.88f)
{
float best = AvgScore(items);
foreach (bool cw in new bool[] { true, false })
{
using (var turned = new Bitmap(crop))
{
turned.RotateFlip(cw ? RotateFlipType.Rotate90FlipNone : RotateFlipType.Rotate270FlipNone);
var rr = RecognizeOnce(turned, timeoutMs);
UnrotateItems(rr, cw, crop.Width, crop.Height);
float s = AvgScore(rr);
if (s > best + 0.01f) { best = s; items = rr; }
}
}
}
}
catch { /* 旋转重试失败:保留原方向结果 */ }
Notify("百度OCR·高精度含位置版");
tcs.TrySetResult(items);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
/// <summary>单方向识别一次(编码→上传→解析);Box 为入参图像的像素坐标。</summary>
private List<OcrItem> RecognizeOnce(Bitmap img, int timeoutMs)
{
// 编码为百度限额内(高精度版 base64 后 ≤4MB)的 JPEG;返回缩放比用于还原识别坐标
float sendScale;
string b64 = EncodeFit(img, out sendScale);
string tok = GetToken();
string url = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate?access_token="
+ Uri.EscapeDataString(tok);
// Base64 的表单转义不能用 Uri.EscapeDataString:.NET Framework 对超长字符串
// (约 32K 字符)抛"无效的 URI: URI 字符串太长",大图 base64 必中
string form = "image=" + EscapeBase64(b64)
+ "&language_type=CHN_ENG&probability=true";
using (var wc = new WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
string body = wc.UploadString(url, "POST", form);
var resp = _json.Deserialize<Dictionary<string, object>>(body);
if (resp == null)
throw new Exception("百度OCR返回解析失败");
if (resp.ContainsKey("error_code") && Convert.ToInt64(resp["error_code"]) != 0)
throw new Exception("百度OCR错误 " + Convert.ToString(resp["error_code"]) + ": "
+ Convert.ToString(resp.ContainsKey("error_msg") ? resp["error_msg"] : ""));
var items = new List<OcrItem>();
var arr = resp.ContainsKey("words_result") ? resp["words_result"] as System.Collections.ArrayList : null;
if (arr != null)
{
foreach (Dictionary<string, object> w in arr)
{
var loc = (w != null && w.ContainsKey("location")) ? w["location"] as Dictionary<string, object> : null;
if (loc == null) continue;
float l = LocF(loc, "left"), t = LocF(loc, "top");
float w2 = LocF(loc, "width"), h = LocF(loc, "height");
float score = 0.95f;
var prob = w.ContainsKey("probability") ? w["probability"] as Dictionary<string, object> : null;
if (prob != null && prob.ContainsKey("average"))
{
float sv;
if (float.TryParse(Convert.ToString(prob["average"]), out sv)) score = sv;
}
items.Add(new OcrItem
{
Text = Convert.ToString(w.ContainsKey("words") ? w["words"] : ""),
// location 是发送图的像素坐标,除以缩放比还原到原始裁剪图坐标
Box = new PointF[]
{
new PointF(l / sendScale, t / sendScale),
new PointF((l + w2) / sendScale, t / sendScale),
new PointF((l + w2) / sendScale, (t + h) / sendScale),
new PointF(l / sendScale, (t + h) / sendScale)
},
Score = score
});
}
}
return items;
}
}
/// <summary>平均置信度(无结果时 -1,便于选优时落败)。</summary>
private static float AvgScore(List<OcrItem> items)
{
if (items == null || items.Count == 0) return -1f;
float sum = 0;
foreach (var it in items) sum += it.Score;
return sum / items.Count;
}
/// <summary>把顺/逆时针旋转 90° 后识别出的 Box 坐标逆向还原到原图坐标系
/// (还原后重排为 左上/右上/右下/左下,Bounds 语义不变)。</summary>
private static void UnrotateItems(List<OcrItem> items, bool clockwise, int w, int h)
{
foreach (var it in items)
{
if (it.Box == null) continue;
float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue;
foreach (var p in it.Box)
{
float x, y;
if (clockwise) { x = p.Y; y = h - 1f - p.X; }
else { x = w - 1f - p.Y; y = p.X; }
if (x < l) l = x; if (x > r) r = x;
if (y < t) t = y; if (y > b) b = y;
}
it.Box = new PointF[]
{
new PointF(l, t), new PointF(r, t),
new PointF(r, b), new PointF(l, b)
};
}
}
private static float LocF(Dictionary<string, object> loc, string key)
{
float v;
return (loc != null && loc.ContainsKey(key) &&
float.TryParse(Convert.ToString(loc[key]), out v)) ? v : 0f;
}
/// <summary>编码为百度限额内的 JPEG(高精度版要求 base64 后 ≤4MB):
/// 质量 90/75/60 递降试编码,均超限则按 0.8 倍降采样重试(最多 8 轮,必然收敛)。
/// scale=发送图/原图(≤1),调用方用它把返回坐标还原到原图。</summary>
private static string EncodeFit(Bitmap img, out float scale)
{
scale = 1f;
string last = null;
for (int round = 0; round < 8; round++)
{
int w = Math.Max(1, (int)Math.Round(img.Width * scale));
int h = Math.Max(1, (int)Math.Round(img.Height * scale));
using (Bitmap cur = (w == img.Width && h == img.Height) ? new Bitmap(img) : new Bitmap(img, w, h))
{
foreach (long q in new long[] { 90, 75, 60 })
{
last = EncodeJpeg(cur, q);
// base64 字符数≈UTF-8 字节数;3.9M 留余量防 4M 硬限被卡
if (last.Length <= 3900000) return last;
}
}
scale *= 0.8f;
}
return last;
}
private static string EncodeJpeg(Bitmap bmp, long quality)
{
using (var ms = new MemoryStream())
{
var codec = GetJpegEncoder();
if (codec != null)
{
using (var ep = new EncoderParameters(1))
{
ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
bmp.Save(ms, codec, ep);
}
}
else bmp.Save(ms, ImageFormat.Jpeg);
return Convert.ToBase64String(ms.ToArray());
}
}
/// <summary>Base64 的 URI 表单转义:仅 + / = 三个字符转 %XX,字母数字无需转义。
/// 不能用 Uri.EscapeDataString:.NET Framework 对约 32K 以上字符串抛"URI 字符串太长"。</summary>
private static string EscapeBase64(string s)
{
var sb = new StringBuilder(s.Length + s.Length / 2);
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (c == '+') sb.Append("%2B");
else if (c == '/') sb.Append("%2F");
else if (c == '=') sb.Append("%3D");
else sb.Append(c);
}
return sb.ToString();
}
private static ImageCodecInfo GetJpegEncoder()
{
foreach (var c in ImageCodecInfo.GetImageEncoders())
if (c.FormatID == ImageFormat.Jpeg.Guid) return c;
return null;
}
public void Dispose() { }
}
}
-303
View File
@@ -1,303 +0,0 @@
namespace pdfbt
{
/// <summary>OCR 设置对话框:引擎选择(百度/智谱GLM) + 对应 Key(存 exe 旁 baidu_ocr.json)。</summary>
public partial class OcrSettingsForm : System.Windows.Forms.Form
{
/// <summary>必需的设计器变量。</summary>
private System.ComponentModel.IContainer components = null;
/// <summary>清理正在使用的资源。</summary>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
private void InitializeComponent()
{
this.lblEngine = new System.Windows.Forms.Label();
this.cboEngine = new System.Windows.Forms.ComboBox();
this.lblApiKey = new System.Windows.Forms.Label();
this.txtApiKey = new System.Windows.Forms.TextBox();
this.lblSecretKey = new System.Windows.Forms.Label();
this.txtSecretKey = new System.Windows.Forms.TextBox();
this.lblGlmKey = new System.Windows.Forms.Label();
this.txtGlmKey = new System.Windows.Forms.TextBox();
this.lblQianfanKey = new System.Windows.Forms.Label();
this.txtQianfanKey = new System.Windows.Forms.TextBox();
this.lblUapisKey = new System.Windows.Forms.Label();
this.txtUapisKey = new System.Windows.Forms.TextBox();
this.lblUmiUrl = new System.Windows.Forms.Label();
this.txtUmiUrl = new System.Windows.Forms.TextBox();
this.btnUmi下载 = new System.Windows.Forms.Button();
this.btnUmi检测 = new System.Windows.Forms.Button();
this.lblUmi状态 = new System.Windows.Forms.Label();
this.btn帮助 = new System.Windows.Forms.Button();
this.lbl说明 = new System.Windows.Forms.Label();
this.btn确定 = new System.Windows.Forms.Button();
this.btn取消 = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// lblEngine
//
this.lblEngine.AutoSize = true;
this.lblEngine.Location = new System.Drawing.Point(12, 18);
this.lblEngine.Name = "lblEngine";
this.lblEngine.Size = new System.Drawing.Size(89, 18);
this.lblEngine.TabIndex = 0;
this.lblEngine.Text = "识别引擎:";
//
// cboEngine
//
this.cboEngine.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cboEngine.Items.AddRange(new object[] {
"百度 OCR 高精度(含位置)",
"智谱 GLM-OCR(版面解析/手写强)",
"百度 PaddleOCR-VL(千帆版面解析)",
"UApiPro 通用OCR(免费积分/带位置)",
"Umi-OCR 本地引擎(离线免费,需先运行Umi-OCR)"});
this.cboEngine.Location = new System.Drawing.Point(139, 15);
this.cboEngine.Name = "cboEngine";
this.cboEngine.Size = new System.Drawing.Size(389, 26);
this.cboEngine.TabIndex = 1;
this.cboEngine.SelectedIndexChanged += new System.EventHandler(this.cboEngine_SelectedIndexChanged);
//
// lblApiKey
//
this.lblApiKey.AutoSize = true;
this.lblApiKey.Location = new System.Drawing.Point(12, 55);
this.lblApiKey.Name = "lblApiKey";
this.lblApiKey.Size = new System.Drawing.Size(80, 18);
this.lblApiKey.TabIndex = 2;
this.lblApiKey.Text = "API Key:";
//
// txtApiKey
//
this.txtApiKey.Location = new System.Drawing.Point(139, 52);
this.txtApiKey.Name = "txtApiKey";
this.txtApiKey.Size = new System.Drawing.Size(389, 28);
this.txtApiKey.TabIndex = 3;
this.txtApiKey.Text = "GHcn5qFbKpnScsrdyHWS8w0A";
this.txtApiKey.UseSystemPasswordChar = true;
//
// lblSecretKey
//
this.lblSecretKey.AutoSize = true;
this.lblSecretKey.Location = new System.Drawing.Point(12, 89);
this.lblSecretKey.Name = "lblSecretKey";
this.lblSecretKey.Size = new System.Drawing.Size(107, 18);
this.lblSecretKey.TabIndex = 4;
this.lblSecretKey.Text = "Secret Key:";
//
// txtSecretKey
//
this.txtSecretKey.Location = new System.Drawing.Point(139, 89);
this.txtSecretKey.Name = "txtSecretKey";
this.txtSecretKey.Size = new System.Drawing.Size(389, 28);
this.txtSecretKey.TabIndex = 5;
this.txtSecretKey.Text = "VC1UU6Eyq7o0pdK4IBgP8fsaZ22Cte1c";
this.txtSecretKey.UseSystemPasswordChar = true;
//
// lblGlmKey
//
this.lblGlmKey.AutoSize = true;
this.lblGlmKey.Location = new System.Drawing.Point(12, 55);
this.lblGlmKey.Name = "lblGlmKey";
this.lblGlmKey.Size = new System.Drawing.Size(125, 18);
this.lblGlmKey.TabIndex = 2;
this.lblGlmKey.Text = "智谱 API Key:";
//
// txtGlmKey
//
this.txtGlmKey.Location = new System.Drawing.Point(139, 52);
this.txtGlmKey.Name = "txtGlmKey";
this.txtGlmKey.Size = new System.Drawing.Size(389, 28);
this.txtGlmKey.TabIndex = 3;
this.txtGlmKey.UseSystemPasswordChar = true;
//
// lblQianfanKey
//
this.lblQianfanKey.AutoSize = true;
this.lblQianfanKey.Location = new System.Drawing.Point(12, 55);
this.lblQianfanKey.Name = "lblQianfanKey";
this.lblQianfanKey.Size = new System.Drawing.Size(125, 18);
this.lblQianfanKey.TabIndex = 2;
this.lblQianfanKey.Text = "千帆 API Key:";
//
// txtQianfanKey
//
this.txtQianfanKey.Location = new System.Drawing.Point(139, 52);
this.txtQianfanKey.Name = "txtQianfanKey";
this.txtQianfanKey.Size = new System.Drawing.Size(389, 28);
this.txtQianfanKey.TabIndex = 3;
this.txtQianfanKey.UseSystemPasswordChar = true;
//
// lblUapisKey
//
this.lblUapisKey.AutoSize = true;
this.lblUapisKey.Location = new System.Drawing.Point(12, 55);
this.lblUapisKey.Name = "lblUapisKey";
this.lblUapisKey.Size = new System.Drawing.Size(116, 18);
this.lblUapisKey.TabIndex = 2;
this.lblUapisKey.Text = "UApiPro Key:";
//
// txtUapisKey
//
this.txtUapisKey.Location = new System.Drawing.Point(139, 52);
this.txtUapisKey.Name = "txtUapisKey";
this.txtUapisKey.Size = new System.Drawing.Size(389, 28);
this.txtUapisKey.TabIndex = 3;
this.txtUapisKey.UseSystemPasswordChar = true;
//
// lblUmiUrl
//
this.lblUmiUrl.AutoSize = true;
this.lblUmiUrl.Location = new System.Drawing.Point(12, 55);
this.lblUmiUrl.Name = "lblUmiUrl";
this.lblUmiUrl.Size = new System.Drawing.Size(89, 18);
this.lblUmiUrl.TabIndex = 2;
this.lblUmiUrl.Text = "服务地址:";
//
// txtUmiUrl
//
this.txtUmiUrl.Location = new System.Drawing.Point(139, 52);
this.txtUmiUrl.Name = "txtUmiUrl";
this.txtUmiUrl.Size = new System.Drawing.Size(389, 28);
this.txtUmiUrl.TabIndex = 3;
this.txtUmiUrl.Text = "http://127.0.0.1:1224";
//
// btnUmi下载
//
this.btnUmi下载.Location = new System.Drawing.Point(139, 86);
this.btnUmi下载.Name = "btnUmi下载";
this.btnUmi下载.Size = new System.Drawing.Size(139, 28);
this.btnUmi下载.TabIndex = 4;
this.btnUmi下载.Text = "下载 OCR 本体";
this.btnUmi下载.UseVisualStyleBackColor = true;
this.btnUmi下载.Click += new System.EventHandler(this.btnUmi下载_Click);
//
// btnUmi检测
//
this.btnUmi检测.Location = new System.Drawing.Point(304, 86);
this.btnUmi检测.Name = "btnUmi检测";
this.btnUmi检测.Size = new System.Drawing.Size(138, 28);
this.btnUmi检测.TabIndex = 5;
this.btnUmi检测.Text = "检测 OCR 本体";
this.btnUmi检测.UseVisualStyleBackColor = true;
this.btnUmi检测.Click += new System.EventHandler(this.btnUmi检测_Click);
//
// lblUmi状态
//
this.lblUmi状态.ForeColor = System.Drawing.SystemColors.GrayText;
this.lblUmi状态.Location = new System.Drawing.Point(12, 118);
this.lblUmi状态.Name = "lblUmi状态";
this.lblUmi状态.Size = new System.Drawing.Size(506, 20);
this.lblUmi状态.TabIndex = 6;
//
// btn帮助
//
this.btn帮助.Location = new System.Drawing.Point(12, 200);
this.btn帮助.Name = "btn帮助";
this.btn帮助.Size = new System.Drawing.Size(90, 28);
this.btn帮助.TabIndex = 10;
this.btn帮助.Text = "帮助";
this.btn帮助.UseVisualStyleBackColor = true;
//
// lbl说明
//
this.lbl说明.ForeColor = System.Drawing.SystemColors.GrayText;
this.lbl说明.Location = new System.Drawing.Point(12, 142);
this.lbl说明.Name = "lbl说明";
this.lbl说明.Size = new System.Drawing.Size(506, 46);
this.lbl说明.TabIndex = 7;
this.lbl说明.Text = "百度引擎已内置默认 Key,可直接使用;换自己的 Key 更稳。\r\n智谱 GLM-OCR(手写字/版面强)需到 open.bigmodel.cn 获取 API K" +
"ey,约 0.2 元/百万 tokens。\r\nUmi-OCR 为本机离线引擎(免费无限次):本体将从网盘自动下载到程序目录,识别时自动启动、关窗自动关闭。";
//
// btn确定
//
this.btn确定.DialogResult = System.Windows.Forms.DialogResult.OK;
this.btn确定.Location = new System.Drawing.Point(367, 200);
this.btn确定.Name = "btn确定";
this.btn确定.Size = new System.Drawing.Size(75, 28);
this.btn确定.TabIndex = 8;
this.btn确定.Text = "保存";
this.btn确定.UseVisualStyleBackColor = true;
this.btn确定.Click += new System.EventHandler(this.btn确定_Click);
//
// btn取消
//
this.btn取消.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.btn取消.Location = new System.Drawing.Point(449, 200);
this.btn取消.Name = "btn取消";
this.btn取消.Size = new System.Drawing.Size(75, 28);
this.btn取消.TabIndex = 9;
this.btn取消.Text = "取消";
this.btn取消.UseVisualStyleBackColor = true;
//
// OcrSettingsForm
//
this.AcceptButton = this.btn确定;
this.CancelButton = this.btn取消;
this.ClientSize = new System.Drawing.Size(539, 244);
this.Controls.Add(this.lblEngine);
this.Controls.Add(this.cboEngine);
this.Controls.Add(this.lblApiKey);
this.Controls.Add(this.txtApiKey);
this.Controls.Add(this.lblSecretKey);
this.Controls.Add(this.txtSecretKey);
this.Controls.Add(this.lblGlmKey);
this.Controls.Add(this.txtGlmKey);
this.Controls.Add(this.lblQianfanKey);
this.Controls.Add(this.txtQianfanKey);
this.Controls.Add(this.lblUapisKey);
this.Controls.Add(this.txtUapisKey);
this.Controls.Add(this.lblUmiUrl);
this.Controls.Add(this.txtUmiUrl);
this.Controls.Add(this.btnUmi下载);
this.Controls.Add(this.btnUmi检测);
this.Controls.Add(this.lblUmi状态);
this.Controls.Add(this.lbl说明);
this.Controls.Add(this.btn帮助);
this.Controls.Add(this.btn确定);
this.Controls.Add(this.btn取消);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "OcrSettingsForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "OCR 设置";
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label lblEngine;
private System.Windows.Forms.ComboBox cboEngine;
private System.Windows.Forms.Label lblApiKey;
private System.Windows.Forms.TextBox txtApiKey;
private System.Windows.Forms.Label lblSecretKey;
private System.Windows.Forms.TextBox txtSecretKey;
private System.Windows.Forms.Label lblGlmKey;
private System.Windows.Forms.TextBox txtGlmKey;
private System.Windows.Forms.Label lblQianfanKey;
private System.Windows.Forms.TextBox txtQianfanKey;
private System.Windows.Forms.Label lblUapisKey;
private System.Windows.Forms.TextBox txtUapisKey;
private System.Windows.Forms.Label lblUmiUrl;
private System.Windows.Forms.TextBox txtUmiUrl;
private System.Windows.Forms.Button btnUmi下载;
private System.Windows.Forms.Button btnUmi检测;
private System.Windows.Forms.Label lblUmi状态;
private System.Windows.Forms.Button btn帮助;
private System.Windows.Forms.Label lbl说明;
private System.Windows.Forms.Button btn确定;
private System.Windows.Forms.Button btn取消;
}
}
-231
View File
@@ -1,231 +0,0 @@
// OcrSettingsForm:OCR 引擎与 Key 设置(百度 OCR / 智谱 GLM-OCR / 千帆 / UApiPro / Umi-OCR 本地)
// 保存 → exe 旁 baidu_ocr.json(engine/api_key/secret_key/glm_api_key/qianfan_api_key/uapis_api_key/umi_url)
// + 热更新 OcrClient + 作废百度 token 缓存。
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows.Forms;
using System.Web.Script.Serialization;
namespace pdfbt
{
public partial class OcrSettingsForm : Form
{
private readonly OcrClient _ocr;
public OcrSettingsForm(OcrClient ocr)
{
InitializeComponent();
_ocr = ocr;
// 回填现有配置(默认 Key 在 Designer 的 Text 里,密码遮罩显示;保存过则文件值优先)
string engine = "baidu";
try
{
string cfgPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json");
if (File.Exists(cfgPath))
{
var jsv = new JavaScriptSerializer().Deserialize<Dictionary<string, string>>(File.ReadAllText(cfgPath));
if (jsv != null)
{
if (jsv.ContainsKey("engine")) engine = jsv["engine"] ?? "baidu";
txtApiKey.Text = jsv.ContainsKey("api_key") ? (jsv["api_key"] ?? "") : "";
txtSecretKey.Text = jsv.ContainsKey("secret_key") ? (jsv["secret_key"] ?? "") : "";
txtGlmKey.Text = jsv.ContainsKey("glm_api_key") ? (jsv["glm_api_key"] ?? "") : "";
txtQianfanKey.Text = jsv.ContainsKey("qianfan_api_key") ? (jsv["qianfan_api_key"] ?? "") : "";
txtUapisKey.Text = jsv.ContainsKey("uapis_api_key") ? (jsv["uapis_api_key"] ?? "") : "";
txtUmiUrl.Text = jsv.ContainsKey("umi_url") && !string.IsNullOrWhiteSpace(jsv["umi_url"])
? jsv["umi_url"] : "http://127.0.0.1:1224";
}
}
}
catch { }
cboEngine.SelectedIndex =
engine == "glm" ? 1 :
engine == "qianfan" ? 2 :
engine == "uapis" ? 3 :
engine == "umi" ? 4 : 0;
UpdateEnginePanels();
}
/// <summary>按引擎下拉显示对应的输入区(百度=两格;智谱/千帆/UApiPro=Key一格;Umi-OCR=服务地址一格)。</summary>
private void UpdateEnginePanels()
{
int sel = cboEngine.SelectedIndex;
bool glm = sel == 1;
bool qf = sel == 2;
bool ua = sel == 3;
bool umi = sel == 4;
bool baidu = !glm && !qf && !ua && !umi;
lblApiKey.Visible = baidu;
txtApiKey.Visible = baidu;
lblSecretKey.Visible = baidu;
txtSecretKey.Visible = baidu;
lblGlmKey.Visible = glm;
txtGlmKey.Visible = glm;
lblQianfanKey.Visible = qf;
txtQianfanKey.Visible = qf;
lblUapisKey.Visible = ua;
txtUapisKey.Visible = ua;
lblUmiUrl.Visible = umi;
txtUmiUrl.Visible = umi;
btnUmi下载.Visible = umi;
btnUmi检测.Visible = umi;
lblUmi状态.Visible = umi;
if (umi) RefreshUmiStatus();
}
/// <summary>刷新 Umi 本体三态状态显示。</summary>
private void RefreshUmiStatus()
{
try { lblUmi状态.Text = UmiOcrHost.DetectText(); } catch { }
}
/// <summary>检测 OCR 本体:刷新状态(未安装/已安装未运行/运行中)。</summary>
private void btnUmi检测_Click(object sender, EventArgs e)
{
RefreshUmiStatus();
MessageBox.Show(this, UmiOcrHost.DetectText(), "检测 OCR 本体",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
/// <summary>下载 OCR 本体:网盘直链(~179MB)→解压到程序目录 Umi-OCR\→验证→删 zip。</summary>
private void btnUmi下载_Click(object sender, EventArgs e)
{
if (UmiOcrHost.IsInstalled)
{
var r = MessageBox.Show(this, "OCR 本体已存在,重新下载将覆盖安装。继续吗?", "下载 OCR 本体",
MessageBoxButtons.OKCancel, MessageBoxIcon.Question);
if (r != DialogResult.OK) return;
}
btnUmi下载.Enabled = false;
btnUmi检测.Enabled = false;
lblUmi状态.ForeColor = System.Drawing.Color.DimGray;
lblUmi状态.Text = "正在下载… 0%";
int lastPct = -1;
UmiOcrHost.DownloadAndInstallAsync(pct =>
{
if (pct == lastPct) return;
lastPct = pct;
try { this.BeginInvoke((MethodInvoker)delegate
{
lblUmi状态.Text = pct >= 100 ? "下载完成,正在解压安装…" : "正在下载… " + pct + "%";
}); } catch { }
}).ContinueWith(t =>
{
try { this.BeginInvoke((MethodInvoker)delegate
{
btnUmi下载.Enabled = true;
btnUmi检测.Enabled = true;
lblUmi状态.ForeColor = System.Drawing.SystemColors.GrayText;
if (t.IsFaulted)
{
Exception root = t.Exception;
while (root is AggregateException && root.InnerException != null) root = root.InnerException;
lblUmi状态.Text = "下载/安装失败: " + root.Message;
MessageBox.Show(this, "下载/安装失败:\n" + root.Message, "下载 OCR 本体",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
else
{
RefreshUmiStatus();
MessageBox.Show(this, "OCR 本体安装完成!\n" + UmiOcrHost.DetectText() +
"\n\n选 Umi-OCR 引擎保存后即可使用(识别时自动启动)。",
"下载 OCR 本体", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}); } catch { }
});
}
private void cboEngine_SelectedIndexChanged(object sender, EventArgs e)
{
UpdateEnginePanels();
}
/// <summary>帮助:全部引擎的 Key 获取方式与本地 OCR 设置流程。</summary>
private void btn帮助_Click(object sender, EventArgs e)
{
string help =
"════════ OCR 识别引擎使用帮助 ════════\r\n" +
"\r\n" +
"【1. 百度 OCR 高精度(含位置)】 默认引擎,已内置 Key 可直接用\r\n" +
" · 获取自己的 Key(更稳,免费额度足够日常):\r\n" +
" ① 打开 console.baidu.com 登录百度账号\r\n" +
" ② 进入\"产品服务 / 文字识别\"→ 创建应用\r\n" +
" ③ 勾选\"文字识别\"全部接口,创建完成\r\n" +
" ④ 在应用详情里复制 API Key 和 Secret Key 填入本窗口\r\n" +
"\r\n" +
"【2. 智谱 GLM-OCR(版面解析/手写强)】\r\n" +
" · 到 open.bigmodel.cn 注册并实名 → API Keys 页面新建密钥\r\n" +
" · 把密钥填入\"智谱 API Key\"\r\n" +
" · 计费约 0.2 元/百万 tokens,适合手写体/复杂版面\r\n" +
"\r\n" +
"【3. 百度 PaddleOCR-VL(千帆版面解析)】\r\n" +
" · 到 console.bce.baidu.com → 千帆 ModelBuilder → 应用管理\r\n" +
" · 创建应用后复制 API Key 填入\"千帆 API Key\"\r\n" +
" · 需在千帆控制台开通 PaddleOCR-VL 模型服务\r\n" +
"\r\n" +
"【4. UApiPro 通用OCR(免费积分/带位置)】\r\n" +
" · 到 uapis.cn 注册账号 → 控制台 → API Keys → 新建\r\n" +
" · 把 Key 填入\"UApiPro Key\"\r\n" +
" · 注册送免费积分(约 4 积分/次识别),小量使用零成本\r\n" +
"\r\n" +
"【5. Umi-OCR 本地引擎(离线免费,无限次)】\r\n" +
" · 本地引擎无需任何 Key,识别全在本机完成,数据不出电脑\r\n" +
" · 设置流程:\r\n" +
" ① 选本引擎后点\"下载 OCR 本体\"(约 179MB,自动下载安装)\r\n" +
" ② 点\"检测 OCR 本体\"确认状态为\"运行中\"或\"已安装\"\r\n" +
" ③ 点\"保存\"完成,之后识别时自动启动、关窗自动关闭\r\n" +
" · 服务地址保持默认 http://127.0.0.1:1224 即可\r\n" +
" (只有你在 Umi-OCR 软件里改过 HTTP 端口才需要改这里)\r\n" +
"\r\n" +
"════════════════════════════════\r\n" +
"提示: Key 全部以密码方式显示,点\"保存\"后立即生效;\r\n" +
"所有 Key 统一保存在程序目录 baidu_ocr.json。";
MessageBox.Show(this, help, "OCR 使用帮助",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void btn确定_Click(object sender, EventArgs e)
{
try
{
string cfgPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_ocr.json");
string engineValue =
cboEngine.SelectedIndex == 1 ? "glm" :
cboEngine.SelectedIndex == 2 ? "qianfan" :
cboEngine.SelectedIndex == 3 ? "uapis" :
cboEngine.SelectedIndex == 4 ? "umi" : "baidu";
var jsv = new JavaScriptSerializer();
File.WriteAllText(cfgPath, jsv.Serialize(new Dictionary<string, string>
{
{ "engine", engineValue },
{ "api_key", txtApiKey.Text.Trim() },
{ "secret_key", txtSecretKey.Text.Trim() },
{ "glm_api_key", txtGlmKey.Text.Trim() },
{ "qianfan_api_key", txtQianfanKey.Text.Trim() },
{ "uapis_api_key", txtUapisKey.Text.Trim() },
{ "umi_url", string.IsNullOrWhiteSpace(txtUmiUrl.Text.Trim()) ? "http://127.0.0.1:1224" : txtUmiUrl.Text.Trim() }
}));
// 热更新引擎/Key + 作废百度 token 缓存(下次识别重新换取)
if (_ocr != null)
{
_ocr.LoadConfig();
try { File.Delete(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "baidu_token.json")); } catch { }
}
// 选了 Umi-OCR 但还没装本体:提示引导下载(不阻断保存)
if (engineValue == "umi" && !UmiOcrHost.IsInstalled)
{
MessageBox.Show(this, "Umi-OCR 本体尚未安装。\n请点\"下载 OCR 本体\"完成安装后即可使用。",
"OCR 设置", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
catch (Exception ex)
{
MessageBox.Show(this, "保存失败: " + ex.Message, "OCR 设置",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}
}
-120
View File
@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
-160
View File
@@ -1,160 +0,0 @@
// ============================================================
// PdfDocument:PDF 文档封装(加载/页尺寸/按缩放渲染页位图)
// 坐标系:页面坐标 = PDF 点(1/72 英寸),原点左上(pdfium 渲染后
// 已翻转为屏幕方向,y 向下)。
// ============================================================
using System;
using System.Drawing;
using System.IO;
namespace pdfbt
{
/// <summary>已打开的 PDF 文档。非线程安全,只在 UI 线程使用。</summary>
internal sealed class PdfDocument : IDisposable
{
private IntPtr _doc = IntPtr.Zero;
private IntPtr[] _pageHandles; // 惰性加载的页句柄缓存
// ★ FPDF_LoadMemDocument 要求后备缓冲在文档关闭前保持有效且不可移动——
// 原来读入的 byte[] 是局部变量,打开后就没人引用;打开下一个 PDF 的大分配
// 触发 GC 把它搬走/回收,文档内存变垃圾 → 第二个 PDF 打不开(实测用户报障)。
// 钉住(GCHandleType.Pinned)+字段保引用,Close 时才释放。
private byte[] _bytes;
private System.Runtime.InteropServices.GCHandle _pin;
public string FilePath { get; private set; }
public int PageCount { get; private set; }
public float[] PageWidthPt { get; private set; }
public float[] PageHeightPt { get; private set; }
public bool IsOpen
{
get { return _doc != IntPtr.Zero; }
}
/// <summary>打开 PDF(读入内存后走 FPDF_LoadMemDocument,免疫路径问题)。失败抛异常。</summary>
public void Open(string path, string password)
{
Close();
if (!File.Exists(path))
throw new Exception("文件不存在: " + path);
_bytes = File.ReadAllBytes(path);
_pin = System.Runtime.InteropServices.GCHandle.Alloc(_bytes,
System.Runtime.InteropServices.GCHandleType.Pinned);
IntPtr doc = PdfiumNative.LoadDocumentFromBytes(_bytes, password);
if (doc == IntPtr.Zero)
{
FreeBuffer();
throw new Exception("PDF 打开失败:" + PdfiumNative.LastErrorText());
}
int count = PdfiumNative.GetPageCount(doc);
if (count <= 0)
{
PdfiumNative.CloseDocument(doc);
FreeBuffer();
throw new Exception("PDF 没有任何页面。");
}
_doc = doc;
FilePath = path;
PageCount = count;
_pageHandles = new IntPtr[count];
PageWidthPt = new float[count];
PageHeightPt = new float[count];
for (int i = 0; i < count; i++)
{
float w, h;
if (!PdfiumNative.GetPageSize(doc, i, out w, out h))
{
w = 595f; h = 842f; // A4 兜底
}
PageWidthPt[i] = w;
PageHeightPt[i] = h;
}
}
/// <summary>释放文档后备缓冲(钉住句柄+字节数组)。</summary>
private void FreeBuffer()
{
if (_pin.IsAllocated) _pin.Free();
_bytes = null;
}
public void Close()
{
if (_pageHandles != null)
{
for (int i = 0; i < _pageHandles.Length; i++)
PdfiumNative.ClosePage(_pageHandles[i]);
_pageHandles = null;
}
if (_doc != IntPtr.Zero)
{
PdfiumNative.CloseDocument(_doc);
_doc = IntPtr.Zero;
}
FreeBuffer();
FilePath = null;
PageCount = 0;
PageWidthPt = null;
PageHeightPt = null;
}
/// <summary>渲染指定页。scale = 像素/PDF点。失败返回 null。</summary>
public Bitmap RenderPage(int pageIndex, float scale)
{
if (!IsOpen) return null;
if (pageIndex < 0 || pageIndex >= PageCount) return null;
IntPtr page = GetPage(pageIndex);
if (page == IntPtr.Zero) return null;
int w = Math.Max(1, (int)Math.Round(PageWidthPt[pageIndex] * scale));
int h = Math.Max(1, (int)Math.Round(PageHeightPt[pageIndex] * scale));
return PdfiumNative.RenderPageToBitmap(page, w, h);
}
/// <summary>
/// 高清渲染页面局部区域(OCR 用):与屏幕显示缩放无关,
/// 用户任意缩放下框选,OCR 都拿到按 ocrScale 渲染的高清裁剪。
/// 返回位图与 rectPt 一一对应(像素/pt = ocrScale)。
/// </summary>
public Bitmap RenderRegion(int pageIndex, RectangleF rectPt, float ocrScale)
{
if (!IsOpen) return null;
if (pageIndex < 0 || pageIndex >= PageCount) return null;
IntPtr page = GetPage(pageIndex);
if (page == IntPtr.Zero) return null;
// 整页渲染尺寸(控制缩放)
int fullW = Math.Max(1, (int)Math.Round(PageWidthPt[pageIndex] * ocrScale));
int fullH = Math.Max(1, (int)Math.Round(PageHeightPt[pageIndex] * ocrScale));
// 区域矩形(裁到页面范围)
float pw = PageWidthPt[pageIndex], ph = PageHeightPt[pageIndex];
float l = Math.Max(0, rectPt.X), t = Math.Max(0, rectPt.Y);
float r = Math.Min(pw, rectPt.Right), b = Math.Min(ph, rectPt.Bottom);
if (r - l < 1 || b - t < 1) return null;
int startX = (int)Math.Floor(l * ocrScale);
int startY = (int)Math.Floor(t * ocrScale);
int endX = (int)Math.Ceiling(r * ocrScale);
int endY = (int)Math.Ceiling(b * ocrScale);
return PdfiumNative.RenderRegionToBitmap(page, startX, startY,
endX - startX, endY - startY, fullW, fullH);
}
private IntPtr GetPage(int pageIndex)
{
IntPtr page = _pageHandles[pageIndex];
if (page == IntPtr.Zero)
{
page = PdfiumNative.LoadPage(_doc, pageIndex);
_pageHandles[pageIndex] = page;
}
return page;
}
public void Dispose()
{
Close();
}
}
}
+3 -2
View File
@@ -18,6 +18,7 @@ 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;
@@ -723,7 +724,7 @@ namespace pdfbt
try
{
_statusLeft.Text = "正在启动 Umi-OCR 本地引擎…";
if (!UmiOcrHost.EnsureRunning())
if (!UmiOcrLauncher.EnsureRunning()) // ★ A1:启停已拆到 UmiOcrLauncher(主项目共享版)
{
_statusLeft.Text = "Umi-OCR 服务启动超时(本体已安装),请稍后重试或检查 Umi-OCR 窗口";
crop.Dispose();
@@ -1268,7 +1269,7 @@ namespace pdfbt
}
catch { }
// Umi-OCR:只停由本程序启动的实例(用户自己开的 Umi-OCR 不动)
try { UmiOcrHost.StopIfStartedByUs(); } catch { }
try { UmiOcrLauncher.StopIfStartedByUs(); } catch { } // ★ A1:启停已拆到 UmiOcrLauncher(主项目共享版)
}
base.Dispose(disposing);
}
-228
View File
@@ -1,228 +0,0 @@
// ============================================================
// PdfiumNative:pdfium.dll 原生库 P/Invoke 封装
// dll 来源:pypdfium2 win32 wheel(pypdfium2_raw/pdfium.dll,x86)
// 调用约定:cdecl(pypdfium2 ctypes.CDLL 已验证)
// ============================================================
using System;
using System.Runtime.InteropServices;
namespace pdfbt
{
/// <summary>pdfium C API 最小封装(仅本项目用到的函数)。</summary>
internal static class PdfiumNative
{
public const string DllName = "pdfium.dll";
// 渲染标志:渲染注释/批注
public const int FPDF_ANNOT = 0x01;
// FPDF_ERR_* 错误码(GetLastError 返回)
public const int FPDF_ERR_SUCCESS = 0; // 无错误
public const int FPDF_ERR_UNKNOWN = 1; // 未知错误
public const int FPDF_ERR_FILE = 2; // 文件无法打开或读取
public const int FPDF_ERR_FORMAT = 3; // 文件不是 PDF 或已损坏
public const int FPDF_ERR_PASSWORD = 4; // 需要密码或密码错误
public const int FPDF_ERR_SECURITY = 5; // 不支持的安全方案
public const int FPDF_ERR_PAGE = 6; // 页面无法加载
private static bool _inited;
private static readonly object _initLock = new object();
/// <summary>初始化 pdfium(进程内只需一次,线程安全)。</summary>
public static void InitLibrary()
{
lock (_initLock)
{
if (_inited) return;
FPDF_InitLibrary();
_inited = true;
}
}
[DllImport(DllName)]
private static extern void FPDF_InitLibrary();
[DllImport(DllName)]
private static extern void FPDF_DestroyLibrary();
[DllImport(DllName, CharSet = CharSet.Ansi)]
private static extern IntPtr FPDF_LoadDocument(string file_path, string password);
[DllImport(DllName)]
private static extern void FPDF_CloseDocument(IntPtr document);
[DllImport(DllName)]
private static extern uint FPDF_GetLastError();
[DllImport(DllName)]
private static extern int FPDF_GetPageCount(IntPtr document);
[DllImport(DllName)]
private static extern IntPtr FPDF_LoadPage(IntPtr document, int page_index);
[DllImport(DllName)]
private static extern void FPDF_ClosePage(IntPtr page);
// ★ 新版 GetPageSizeByIndexF 是 FS_SIZEF* 参数(易错);用稳定的旧版 double API
[DllImport(DllName)]
private static extern int FPDF_GetPageSizeByIndex(IntPtr document, int page_index,
out double width, out double height);
[DllImport(DllName)]
private static extern IntPtr FPDFBitmap_Create(int width, int height, int alpha);
[DllImport(DllName)]
private static extern void FPDFBitmap_FillRect(IntPtr bitmap, int left, int top,
int width, int height, uint color);
[DllImport(DllName)]
private static extern void FPDFBitmap_Destroy(IntPtr bitmap);
[DllImport(DllName)]
private static extern IntPtr FPDFBitmap_GetBuffer(IntPtr bitmap);
[DllImport(DllName)]
private static extern int FPDFBitmap_GetStride(IntPtr bitmap);
[DllImport(DllName)]
private static extern void FPDF_RenderPageBitmap(IntPtr bitmap, IntPtr page,
int start_x, int start_y, int size_x, int size_y,
int rotate, int flags);
[DllImport(DllName)]
private static extern IntPtr FPDF_LoadMemDocument(byte[] data, int size, string password);
public static IntPtr LoadDocumentFromBytes(byte[] data, string password)
{
InitLibrary();
if (data == null || data.Length == 0) return IntPtr.Zero;
return FPDF_LoadMemDocument(data, data.Length, string.IsNullOrEmpty(password) ? null : password);
}
// ------------------------------------------------------------
// 上层直接使用的安全包装
// ------------------------------------------------------------
/// <summary>打开 PDF 文件。失败返回 IntPtr.Zero,可用 LastErrorText 查原因。</summary>
public static IntPtr LoadDocument(string path, string password)
{
InitLibrary();
if (string.IsNullOrEmpty(path)) return IntPtr.Zero;
return FPDF_LoadDocument(path, string.IsNullOrEmpty(password) ? null : password);
}
public static void CloseDocument(IntPtr doc)
{
if (doc != IntPtr.Zero) FPDF_CloseDocument(doc);
}
public static int GetPageCount(IntPtr doc)
{
if (doc == IntPtr.Zero) return 0;
return FPDF_GetPageCount(doc);
}
/// <summary>取指定页尺寸(PDF 点,1/72 英寸)。失败返回 false。</summary>
public static bool GetPageSize(IntPtr doc, int pageIndex, out float widthPt, out float heightPt)
{
widthPt = 0; heightPt = 0;
if (doc == IntPtr.Zero) return false;
double w, h;
bool ok = FPDF_GetPageSizeByIndex(doc, pageIndex, out w, out h) != 0;
widthPt = (float)w;
heightPt = (float)h;
return ok;
}
public static IntPtr LoadPage(IntPtr doc, int pageIndex)
{
if (doc == IntPtr.Zero) return IntPtr.Zero;
return FPDF_LoadPage(doc, pageIndex);
}
public static void ClosePage(IntPtr page)
{
if (page != IntPtr.Zero) FPDF_ClosePage(page);
}
/// <summary>把整页渲染到 GDI+ 位图(白底不透明)。</summary>
public static System.Drawing.Bitmap RenderPageToBitmap(IntPtr page, int widthPx, int heightPx)
{
return RenderRegionToBitmap(page, 0, 0, widthPx, heightPx, widthPx, heightPx);
}
/// <summary>
/// 把页面局部区域渲染到位图(白底不透明)。
/// 页面整图尺寸 = fullW×fullH 像素;只取 (startX,startY,wPx,hPx) 矩形。
/// 用于:OCR 框选区域按任意分辨率高清重渲染(与显示缩放无关)。
/// </summary>
public static System.Drawing.Bitmap RenderRegionToBitmap(IntPtr page,
int startX, int startY, int wPx, int hPx, int fullW, int fullH)
{
if (page == IntPtr.Zero || wPx <= 0 || hPx <= 0) return null;
if (wPx > 16384 || hPx > 16384) return null;
IntPtr bmp = FPDFBitmap_Create(wPx, hPx, 1);
if (bmp == IntPtr.Zero) return null;
try
{
// 白底不透明(0xAARRGGBB)
FPDFBitmap_FillRect(bmp, 0, 0, wPx, hPx, 0xFFFFFFFF);
// start_x/start_y 为负偏移:位图原点相对页面原点
FPDF_RenderPageBitmap(bmp, page, -startX, -startY, fullW, fullH, 0, FPDF_ANNOT);
IntPtr buffer = FPDFBitmap_GetBuffer(bmp);
int stride = FPDFBitmap_GetStride(bmp);
if (buffer == IntPtr.Zero || stride <= 0) return null;
// pdfium BGRA(top-down) → GDI+ Format32bppArgb(BGRA) 逐行拷贝
var result = new System.Drawing.Bitmap(wPx, hPx,
System.Drawing.Imaging.PixelFormat.Format32bppArgb);
var rect = new System.Drawing.Rectangle(0, 0, wPx, hPx);
var data = result.LockBits(rect,
System.Drawing.Imaging.ImageLockMode.WriteOnly,
System.Drawing.Imaging.PixelFormat.Format32bppArgb);
try
{
for (int y = 0; y < hPx; y++)
{
IntPtr src = new IntPtr(buffer.ToInt64() + (long)y * stride);
IntPtr dst = new IntPtr(data.Scan0.ToInt64() + (long)y * data.Stride);
NativeMethods.CopyMemory(dst, src, (uint)(wPx * 4));
}
}
finally
{
result.UnlockBits(data);
}
return result;
}
finally
{
FPDFBitmap_Destroy(bmp);
}
}
public static string LastErrorText()
{
uint err = FPDF_GetLastError();
switch (err)
{
case FPDF_ERR_SUCCESS: return "成功";
case FPDF_ERR_FILE: return "文件无法打开或读取";
case FPDF_ERR_FORMAT: return "文件不是有效 PDF 或已损坏";
case FPDF_ERR_PASSWORD: return "PDF 已加密,需要密码";
case FPDF_ERR_SECURITY: return "不支持的安全方案";
case FPDF_ERR_PAGE: return "页面内容无法解析";
default: return "未知错误(" + err + ")";
}
}
private static class NativeMethods
{
[DllImport("kernel32.dll", EntryPoint = "RtlMoveMemory", SetLastError = false)]
public static extern void CopyMemory(IntPtr dest, IntPtr src, uint count);
}
}
}
-278
View File
@@ -1,278 +0,0 @@
// ============================================================
// QianfanOcrClient:百度千帆 PaddleOCR-VL-0.9B(版面解析)
// · POST https://qianfan.baidubce.com/v2/ocr/paddleocr
// Authorization: Bearer {QianfanApiKey}(千帆 ModelBuilder 的 API Key,永久有效)
// {"model":"paddleocr-vl-0.9b","file":"<纯base64>","fileType":1}
// · 响应 result.layoutParsingResults[0].prunedResult.parsing_res_list[]
// 每块 {block_label, block_content, block_bbox[x1,y1,x2,y2](像素)}
// → 文本块转 OcrItem;表格块逐行拆(与 GLM 处理一致)
// · Key 存 baidu_ocr.json 的 qianfan_api_key(OCR设置窗口写)
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace pdfbt
{
/// <summary>百度千帆 PaddleOCR-VL 客户端(单实例)。</summary>
internal sealed class QianfanOcrClient
{
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _apiKey;
private const string BaseUrl = "https://qianfan.baidubce.com/v2/ocr/paddleocr";
/// <summary>状态提示,接 OcrClient.StatusChanged。</summary>
public event Action<string> StatusChanged;
public QianfanOcrClient()
{
try
{
ServicePointManager.SecurityProtocol =
ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12;
}
catch { }
}
public bool IsConfigured
{
get { return !string.IsNullOrWhiteSpace(_apiKey); }
}
/// <summary>Key 由 OcrClient 从 baidu_ocr.json 的 qianfan_api_key 注入(热更新)。</summary>
public void SetApiKey(string key)
{
_apiKey = key == null ? "" : key.Trim();
}
/// <summary>识别一块位图 → 文本块列表(带 bbox 像素坐标)。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
if (crop == null || crop.Width <= 0 || crop.Height <= 0)
return Task.FromResult(new List<OcrItem>());
if (!IsConfigured)
{
Notify("千帆 PaddleOCR-VL 未配置 API Key(OCR 设置窗口里填千帆 API Key)");
var tcs0 = new TaskCompletionSource<List<OcrItem>>();
tcs0.SetException(new Exception(
"千帆 PaddleOCR-VL 未配置 API Key。\r\n" +
"请到百度智能云千帆平台(ModelBuilder)创建 API Key," +
"在 OCR 设置窗口选择引擎\"百度 PaddleOCR-VL\"并填入后保存。"));
return tcs0.Task;
}
return Task.Factory.StartNew(() =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = RequestAndParse(crop, timeoutMs);
Notify("PaddleOCR-VL·" + result.Count + "条 " + sw.ElapsedMilliseconds + "ms");
return result;
});
}
private List<OcrItem> RequestAndParse(Bitmap crop, int timeoutMs)
{
byte[] png;
using (var ms = new MemoryStream())
{
crop.Save(ms, ImageFormat.Png);
png = ms.ToArray();
}
// 千帆要求:base64 直传(无 data URI 前缀),fileType=1 图像
string body = _json.Serialize(new Dictionary<string, object>
{
{ "model", "paddleocr-vl-0.9b" },
{ "file", Convert.ToBase64String(png) },
{ "fileType", 1 }
});
var req = (HttpWebRequest)WebRequest.Create(BaseUrl);
req.Method = "POST";
req.ContentType = "application/json";
req.Headers.Add("Authorization", "Bearer " + _apiKey);
req.Timeout = timeoutMs;
req.ReadWriteTimeout = timeoutMs;
req.Proxy = null;
var bytes = Encoding.UTF8.GetBytes(body);
req.ContentLength = bytes.Length;
using (var rs = req.GetRequestStream())
{
rs.Write(bytes, 0, bytes.Length);
}
string respText;
try
{
using (var resp = (HttpWebResponse)req.GetResponse())
using (var sr = new StreamReader(resp.GetResponseStream(), new UTF8Encoding(false)))
{
respText = sr.ReadToEnd();
}
}
catch (WebException wex)
{
string detail = "";
try
{
if (wex.Response != null)
using (var sr = new StreamReader(wex.Response.GetResponseStream(), new UTF8Encoding(false)))
detail = sr.ReadToEnd();
}
catch { }
string hint = wex.Response == null
? ("网络不可达: " + wex.Message)
: ("HTTP " + (int)((HttpWebResponse)wex.Response).StatusCode + " " + detail);
throw new Exception("PaddleOCR-VL 请求失败: " + hint);
}
return ParseResponse(respText, crop.Width, crop.Height);
}
// ---- 响应解析(行拆分/清理逻辑与 GLM 一致,复用其 shared 方法) ----
private List<OcrItem> ParseResponse(string respText, int cropW, int cropH)
{
var msg = _json.Deserialize<Dictionary<string, object>>(respText);
if (msg == null) throw new Exception("千帆响应解析失败(空响应)");
if (msg.ContainsKey("error"))
{
var err = msg["error"] as Dictionary<string, object>;
string code = err != null && err.ContainsKey("code") ? Convert.ToString(err["code"]) : "?";
string message = err != null && err.ContainsKey("message") ? Convert.ToString(err["message"]) : respText;
throw new Exception("PaddleOCR-VL 错误 [" + code + "]: " + message);
}
var items = new List<OcrItem>();
// result.layoutParsingResults[0].prunedResult.parsing_res_list
var result = msg.ContainsKey("result") ? msg["result"] as Dictionary<string, object> : null;
var layouts = result != null && result.ContainsKey("layoutParsingResults")
? result["layoutParsingResults"] as System.Collections.ArrayList : null;
if (layouts == null || layouts.Count == 0)
throw new Exception("PaddleOCR-VL 响应没有 layoutParsingResults(内容: " +
(respText.Length > 300 ? respText.Substring(0, 300) : respText) + ")");
var first = layouts[0] as Dictionary<string, object>;
var pruned = first != null && first.ContainsKey("prunedResult")
? first["prunedResult"] as Dictionary<string, object> : null;
var blocks = pruned != null && pruned.ContainsKey("parsing_res_list")
? pruned["parsing_res_list"] as System.Collections.ArrayList : null;
if (blocks == null) return items;
foreach (Dictionary<string, object> b in blocks)
{
string label = b.ContainsKey("block_label") ? Convert.ToString(b["block_label"]) : "";
string content = b.ContainsKey("block_content") ? Convert.ToString(b["block_content"]) : "";
if (string.IsNullOrWhiteSpace(content)) continue;
var bbox = b.ContainsKey("block_bbox") ? b["block_bbox"] as System.Collections.ArrayList : null;
if (bbox == null || bbox.Count < 4) continue;
// block_bbox 像素坐标 [x1,y1,x2,y2];裁剪到图内
float x1 = Math.Max(0, Math.Min(cropW, Convert.ToSingle(bbox[0])));
float y1 = Math.Max(0, Math.Min(cropH, Convert.ToSingle(bbox[1])));
float x2 = Math.Max(0, Math.Min(cropW, Convert.ToSingle(bbox[2])));
float y2 = Math.Max(0, Math.Min(cropH, Convert.ToSingle(bbox[3])));
if (x2 - x1 < 2 || y2 - y1 < 2) continue;
if (label == "table")
{
AddTableItems(items, content, x1, y1, x2, y2);
continue;
}
if (label != "text" && label != "title" && label != "paragraph")
continue; // 图片/公式/图表块跳过(title 也是文字,收)
content = GlmOcrClient.CleanHtmlShared(content);
if (string.IsNullOrWhiteSpace(content)) continue;
string[] lines = content.Split(new[] { '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries);
float lineH = (y2 - y1) / lines.Length;
float pad = Math.Min(lineH * 0.15f, 3f);
for (int i = 0; i < lines.Length; i++)
{
string text = GlmOcrClient.CleanMarkdownShared(lines[i]).Trim();
if (text.Length == 0) continue;
float ty1 = y1 + lineH * i + pad;
float ty2 = y1 + lineH * (i + 1) - pad;
if (ty2 - ty1 < 2) { ty1 = y1 + lineH * i; ty2 = ty1 + Math.Max(2, lineH); }
items.Add(new OcrItem
{
Text = text,
Box = new[]
{
new PointF(x1, ty1),
new PointF(x2, ty1),
new PointF(x2, ty2),
new PointF(x1, ty2)
},
Score = 1f
});
}
}
return items;
}
private static readonly System.Text.RegularExpressions.Regex _trRegex =
new System.Text.RegularExpressions.Regex("<tr[^>]*>(.*?)</tr>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex _tdRegex =
new System.Text.RegularExpressions.Regex("<t[dh][^>]*>(.*?)</t[dh]>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>表格块 → 每 tr 一条 item(td 内容空格拼接),行高均分 bbox。</summary>
private static void AddTableItems(List<OcrItem> items, string tableHtml,
float x1, float y1, float x2, float y2)
{
var rows = new List<string>();
foreach (System.Text.RegularExpressions.Match tr in _trRegex.Matches(tableHtml))
{
var cells = new List<string>();
foreach (System.Text.RegularExpressions.Match td in _tdRegex.Matches(tr.Groups[1].Value))
{
string cell = GlmOcrClient.CleanHtmlShared(td.Groups[1].Value).Trim();
if (cell.Length > 0) cells.Add(cell);
}
if (cells.Count > 0) rows.Add(string.Join(" ", cells.ToArray()));
}
if (rows.Count == 0) return;
float rowH = (y2 - y1) / rows.Count;
float pad = Math.Min(rowH * 0.15f, 3f);
for (int i = 0; i < rows.Count; i++)
{
float ty1 = y1 + rowH * i + pad;
float ty2 = y1 + rowH * (i + 1) - pad;
if (ty2 - ty1 < 2) { ty1 = y1 + rowH * i; ty2 = ty1 + Math.Max(2, rowH); }
items.Add(new OcrItem
{
Text = rows[i],
Box = new[]
{
new PointF(x1, ty1),
new PointF(x2, ty1),
new PointF(x2, ty2),
new PointF(x1, ty2)
},
Score = 1f
});
}
}
private void Notify(string s)
{
var h = StatusChanged;
if (h != null) h(s);
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ using System.Drawing.Imaging;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using 成套报价软件; // ★ A1:OCR/PDF 基础设施已链接共享主项目源码(类型在 成套报价软件 命名空间)
namespace pdfbt
{
internal static class SelfTest
-221
View File
@@ -1,221 +0,0 @@
// ============================================================
// UapisOcrClient:UApiPro(uapis.cn) 通用文字识别(带位置)
// · POST https://uapis.cn/api/v1/image/ocr (multipart/form-data)
// X-API-Key: {key}(uapis.cn 控制台注册生成,免费积分计费 4积分/次,4QPS)
// 字段:image_base64(纯base64或DataURI) + need_location="true"
// · 响应 words_result[] { words, location{left,top,width,height},
// vertexes_location[], score } → 转 OcrItem(坐标为发送图像素)
// · Key 存 baidu_ocr.json 的 uapis_api_key(OCR设置窗口写)
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace pdfbt
{
/// <summary>UApiPro 通用 OCR 客户端(单实例)。</summary>
internal sealed class UapisOcrClient
{
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _apiKey;
private const string BaseUrl = "https://uapis.cn/api/v1/image/ocr";
/// <summary>状态提示,接 OcrClient.StatusChanged。</summary>
public event Action<string> StatusChanged;
public UapisOcrClient()
{
try
{
ServicePointManager.SecurityProtocol =
ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12;
}
catch { }
}
public bool IsConfigured
{
get { return !string.IsNullOrWhiteSpace(_apiKey); }
}
/// <summary>Key 由 OcrClient 从 baidu_ocr.json 的 uapis_api_key 注入(热更新)。</summary>
public void SetApiKey(string key)
{
_apiKey = key == null ? "" : key.Trim();
}
/// <summary>识别一块位图 → 文本行列表(带位置)。multipart 上传。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
if (crop == null || crop.Width <= 0 || crop.Height <= 0)
return Task.FromResult(new List<OcrItem>());
if (!IsConfigured)
{
Notify("UApiPro OCR 未配置 API Key(OCR 设置窗口里填 UApiPro Key)");
var tcs0 = new TaskCompletionSource<List<OcrItem>>();
tcs0.SetException(new Exception(
"UApiPro(uapis.cn) OCR 未配置 API Key。\r\n" +
"请到 uapis.cn/console 注册生成 API Key," +
"在 OCR 设置窗口选择引擎\"UApiPro 通用OCR\"并填入后保存。"));
return tcs0.Task;
}
return Task.Factory.StartNew(() =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = RequestAndParse(crop, timeoutMs);
Notify("UApiPro OCR·" + result.Count + "条 " + sw.ElapsedMilliseconds + "ms");
return result;
});
}
private List<OcrItem> RequestAndParse(Bitmap crop, int timeoutMs)
{
byte[] png;
using (var ms = new MemoryStream())
{
crop.Save(ms, ImageFormat.Png);
png = ms.ToArray();
}
string b64 = Convert.ToBase64String(png);
// multipart/form-data 手工拼装(net48 无 MultipartFormDataContent)
string boundary = "----pdfbt" + Guid.NewGuid().ToString("N");
var body = new MemoryStream();
Action<string, string> field = (name, value) =>
{
byte[] part = Encoding.UTF8.GetBytes(
"--" + boundary + "\r\nContent-Disposition: form-data; name=\"" +
name + "\"\r\n\r\n" + value + "\r\n");
body.Write(part, 0, part.Length);
};
field("image_base64", b64);
field("need_location", "true");
field("enable_cls", "true"); // 文字方向校正(歪扫图抗性)
byte[] tail = Encoding.UTF8.GetBytes("--" + boundary + "--\r\n");
body.Write(tail, 0, tail.Length);
var req = (HttpWebRequest)WebRequest.Create(BaseUrl);
req.Method = "POST";
req.ContentType = "multipart/form-data; boundary=" + boundary;
req.Headers.Add("X-API-Key", _apiKey);
req.Timeout = timeoutMs;
req.ReadWriteTimeout = timeoutMs;
req.Proxy = null;
byte[] payload = body.ToArray();
req.ContentLength = payload.Length;
using (var rs = req.GetRequestStream())
{
rs.Write(payload, 0, payload.Length);
}
string respText;
try
{
using (var resp = (HttpWebResponse)req.GetResponse())
using (var sr = new StreamReader(resp.GetResponseStream(), new UTF8Encoding(false)))
{
respText = sr.ReadToEnd();
}
}
catch (WebException wex)
{
string detail = "";
try
{
if (wex.Response != null)
using (var sr = new StreamReader(wex.Response.GetResponseStream(), new UTF8Encoding(false)))
detail = sr.ReadToEnd();
}
catch { }
string hint = wex.Response == null
? ("网络不可达: " + wex.Message)
: ("HTTP " + (int)((HttpWebResponse)wex.Response).StatusCode + " " + detail);
throw new Exception("UApiPro OCR 请求失败: " + hint);
}
return ParseResponse(respText, crop.Width, crop.Height);
}
private List<OcrItem> ParseResponse(string respText, int cropW, int cropH)
{
var msg = _json.Deserialize<Dictionary<string, object>>(respText);
if (msg == null) throw new Exception("UApiPro 响应解析失败(空响应)");
// 业务错误:HTTP 200 但 code 非 200/0;或 HTTP 错误体 {code,message}
if (msg.ContainsKey("code"))
{
string code = Convert.ToString(msg["code"]);
if (code != "200" && code != "0" && code != "success")
{
string m = msg.ContainsKey("message") ? Convert.ToString(msg["message"]) : respText;
throw new Exception("UApiPro OCR 错误 [" + code + "]: " + m);
}
}
var items = new List<OcrItem>();
var arr = msg.ContainsKey("words_result") ? msg["words_result"] as System.Collections.ArrayList : null;
if (arr == null)
throw new Exception("UApiPro 响应没有 words_result(内容: " +
(respText.Length > 300 ? respText.Substring(0, 300) : respText) + ")");
foreach (Dictionary<string, object> w in arr)
{
string text = w.ContainsKey("words") ? Convert.ToString(w["words"]) : "";
if (string.IsNullOrWhiteSpace(text)) continue;
var loc = w.ContainsKey("location") ? w["location"] as Dictionary<string, object> : null;
if (loc == null) continue;
float l = LocF(loc, "left"), t = LocF(loc, "top");
float wd = LocF(loc, "width"), h = LocF(loc, "height");
// 裁剪到图内
l = Math.Max(0, Math.Min(cropW, l));
t = Math.Max(0, Math.Min(cropH, t));
wd = Math.Max(2, Math.Min(cropW - l, wd));
h = Math.Max(2, Math.Min(cropH - t, h));
float score = 1f;
if (w.ContainsKey("score"))
{
float sv;
if (float.TryParse(Convert.ToString(w["score"]), out sv)) score = sv;
}
items.Add(new OcrItem
{
Text = text,
Box = new[]
{
new PointF(l, t),
new PointF(l + wd, t),
new PointF(l + wd, t + h),
new PointF(l, t + h)
},
Score = score
});
}
return items;
}
private static float LocF(Dictionary<string, object> d, string key)
{
if (d == null || !d.ContainsKey(key)) return 0f;
float v;
if (float.TryParse(Convert.ToString(d[key]), out v)) return v;
return 0f;
}
private void Notify(string s)
{
var h = StatusChanged;
if (h != null) h(s);
}
}
}
-141
View File
@@ -1,141 +0,0 @@
// ============================================================
// UmiOcrClient:Umi-OCR 本地 OCR 引擎(hiroi-sora/Umi-OCR,离线免费)
// · 前提:用户在本机运行 Umi-OCR 主程序,全局设置里开启 HTTP 服务(默认开启,端口 1224)
// · POST {BaseUrl}/api/ocr application/json:
// { "base64": "<纯base64无前缀>", "options": { "data.format": "dict" } }
// · 响应: code=100 成功;data=[{ text, score, box:[[x,y]×4 左上/右上/右下/左下], end }]
// code=101 无文本(data 为原因字符串);其余 code 失败
// · 地址存 baidu_ocr.json 的 umi_url(OCR设置窗口写,默认 http://127.0.0.1:1224)
// · 本地引擎无需 API Key,IsConfigured 恒 true
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace pdfbt
{
/// <summary>Umi-OCR 本地引擎客户端(单实例)。</summary>
internal sealed class UmiOcrClient
{
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _baseUrl = "http://127.0.0.1:1224";
/// <summary>状态提示,接 OcrClient.StatusChanged。</summary>
public event Action<string> StatusChanged;
public bool IsConfigured
{
get { return true; } // 本地引擎无需 Key
}
/// <summary>服务地址由 OcrClient 从 baidu_ocr.json 的 umi_url 注入(热更新)。</summary>
public void SetBaseUrl(string url)
{
url = (url == null ? "" : url.Trim());
if (url.Length == 0) url = "http://127.0.0.1:1224";
if (!url.StartsWith("http")) url = "http://" + url;
_baseUrl = url.TrimEnd('/');
}
/// <summary>识别一块位图 → 文本行列表(带位置)。JSON POST 到本机 Umi-OCR。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
if (crop == null || crop.Width <= 0 || crop.Height <= 0)
return Task.FromResult(new List<OcrItem>());
return Task.Factory.StartNew(() =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = RequestAndParse(crop, timeoutMs);
Notify("Umi-OCR·" + result.Count + "条 " + sw.ElapsedMilliseconds + "ms");
return result;
});
}
private List<OcrItem> RequestAndParse(Bitmap crop, int timeoutMs)
{
// PNG base64(无 data: 前缀);PNG 保坐标精度,本地服务无 4MB 限制
string b64;
using (var ms = new MemoryStream())
{
crop.Save(ms, ImageFormat.Png);
b64 = Convert.ToBase64String(ms.ToArray());
}
string req = _json.Serialize(new Dictionary<string, object>
{
{ "base64", b64 },
{ "options", new Dictionary<string, object> { { "data.format", "dict" } } }
});
string body;
using (var wc = new WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers["Content-Type"] = "application/json";
wc.Proxy = null; // 本地地址不走系统代理(配了代理会把 127.0.0.1 也代理走)
body = wc.UploadString(_baseUrl + "/api/ocr", "POST", req);
}
var resp = _json.Deserialize<Dictionary<string, object>>(body);
if (resp == null || !resp.ContainsKey("code"))
throw new Exception("Umi-OCR 返回解析失败");
int code;
int.TryParse(Convert.ToString(resp["code"]), out code);
if (code == 101)
return new List<OcrItem>(); // 无文本,不算错误
if (code != 100)
throw new Exception("Umi-OCR 错误 code=" + code + ": " +
Convert.ToString(resp.ContainsKey("data") ? resp["data"] : ""));
var items = new List<OcrItem>();
var arr = resp.ContainsKey("data") ? resp["data"] as System.Collections.ArrayList : null;
if (arr != null)
{
foreach (Dictionary<string, object> w in arr)
{
if (w == null) continue;
var boxArr = w.ContainsKey("box") ? w["box"] as System.Collections.ArrayList : null;
if (boxArr == null || boxArr.Count < 4) continue;
var pts = new PointF[4];
for (int i = 0; i < 4; i++)
{
var xy = boxArr[i] as System.Collections.ArrayList;
float x, y;
float.TryParse(Convert.ToString(xy != null && xy.Count > 0 ? xy[0] : "0"), out x);
float.TryParse(Convert.ToString(xy != null && xy.Count > 1 ? xy[1] : "0"), out y);
pts[i] = new PointF(x, y);
}
float score = 0.95f;
if (w.ContainsKey("score"))
{
float sv;
if (float.TryParse(Convert.ToString(w["score"]),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out sv)) score = sv;
}
items.Add(new OcrItem
{
Text = Convert.ToString(w.ContainsKey("text") ? w["text"] : ""),
Box = pts, // box 即发送图像素坐标(顺时针四点),与 OcrItem 语义一致
Score = score
});
}
}
return items;
}
private void Notify(string msg)
{
var h = StatusChanged;
if (h != null) h(msg);
}
}
}
-232
View File
@@ -1,232 +0,0 @@
// ============================================================
// UmiOcrHost:Umi-OCR 本体(网盘 zip)下载/安装/启停/检测 管理器
// · 本体 zip 直链(用户网盘,~179MB,zip 根直接是 Umi-OCR.exe 无顶层目录)
// · 安装 = 下载 zip 到临时文件 → 解压到 exe 旁 Umi-OCR\ → 找到主程序验证 → 删 zip
// · 启动 = Process.Start(主程序,Umi-OCR 启动即带 HTTP 服务 1224 端口) → 轮询端口就绪
// · 停止 = 只停由本程序启动的实例(CloseMainWindow→超时 Kill),不碰用户自己开的
// ============================================================
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace pdfbt
{
internal static class UmiOcrHost
{
/// <summary>本体 zip 直链(用户网盘直连,GET 才有内容,HEAD 会 404,不支持断点续传)。</summary>
public const string DownloadUrl = "http://s333.odn.cc/s/845f64a239b665ec";
private const string InstallDirName = "Umi-OCR";
private const int HttpPort = 1224;
/// <summary>由本程序启动的实例(停止时只关它,不碰用户自己开的)。</summary>
private static Process _startedProc;
/// <summary>本体安装目录(exe 旁 Umi-OCR\)。</summary>
public static string InstallDir
{
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, InstallDirName); }
}
/// <summary>找安装目录里的主程序(Umi-OCR.exe,大小写不敏感;找不到返回 null)。</summary>
public static string FindExe()
{
try
{
if (!Directory.Exists(InstallDir)) return null;
foreach (var f in Directory.GetFiles(InstallDir, "*.exe", SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileName(f);
if (name.Equals("Umi-OCR.exe", StringComparison.OrdinalIgnoreCase) ||
name.Equals("Umi-OCR.exe", StringComparison.OrdinalIgnoreCase))
return f;
}
// 目录名不完全一致时兜底:取第一个 exe
string[] all = Directory.GetFiles(InstallDir, "*.exe", SearchOption.TopDirectoryOnly);
return all.Length > 0 ? all[0] : null;
}
catch { return null; }
}
/// <summary>本体是否已安装(目录里能找到主程序)。</summary>
public static bool IsInstalled
{
get { return FindExe() != null; }
}
/// <summary>HTTP 服务(1224 端口)是否在监听。</summary>
public static bool IsHttpUp()
{
try
{
using (var c = new System.Net.Sockets.TcpClient())
{
var ar = c.BeginConnect("127.0.0.1", HttpPort, null, null);
if (ar.AsyncWaitHandle.WaitOne(800))
{
c.EndConnect(ar);
return true;
}
c.Close();
}
}
catch { }
return false;
}
/// <summary>三态:0=未安装,1=已安装未运行,2=运行中(HTTP 在线)。</summary>
public static int Detect()
{
if (!IsInstalled) return 0;
return IsHttpUp() ? 2 : 1;
}
/// <summary>状态文字(设置窗口显示用)。</summary>
public static string DetectText()
{
int st = Detect();
if (st == 0) return "未检测到 OCR 本体(点\"下载 OCR 本体\"自动下载安装)";
if (st == 1) return "已检测到 OCR 本体,服务未运行(识别时会自动启动)";
return "OCR 本体运行中(HTTP " + HttpPort + " 端口在线)";
}
/// <summary>下载 zip → 解压到 Umi-OCR\ → 验证主程序 → 删 zip。
/// progress: 0~100 下载百分比;解压与收尾阶段回调 100。失败抛异常(内部已清理残留)。
/// ★ 必须用 DownloadFileAsync:同步版 DownloadFile 不触发 DownloadProgressChanged
/// (进度事件只在异步路径发,同步下载会一直停在 0%)。</summary>
public static Task DownloadAndInstallAsync(Action<int> progress)
{
return Task.Factory.StartNew(delegate
{
string tmpZip = Path.Combine(Path.GetTempPath(), "Umi-OCR_" + Guid.NewGuid().ToString("N") + ".zip");
try
{
if (progress != null) progress(0);
// 异步下载包成同步等待(TaskCompletionSource),进度事件才会触发
var dlTcs = new TaskCompletionSource<bool>();
using (var wc = new WebClient())
{
wc.Proxy = null;
wc.DownloadProgressChanged += (s, e) =>
{
if (progress != null) progress(e.ProgressPercentage);
};
wc.DownloadFileCompleted += (s, e) =>
{
if (e.Error != null) dlTcs.TrySetException(e.Error);
else if (e.Cancelled) dlTcs.TrySetException(new Exception("下载已取消"));
else dlTcs.TrySetResult(true);
};
wc.DownloadFileAsync(new Uri(DownloadUrl), tmpZip);
}
dlTcs.Task.GetAwaiter().GetResult(); // 等下载结束(异常原样抛出)
if (progress != null) progress(100);
// 校验是 zip(PK 头)而不是错误页/JSON
using (var fs = File.OpenRead(tmpZip))
{
int b0 = fs.ReadByte(), b1 = fs.ReadByte();
if (b0 != 'P' || b1 != 'K')
throw new Exception("下载的内容不是有效的压缩包(可能是网盘临时故障),请稍后重试");
}
// 解压(覆盖);zip 根直接是 Umi-OCR.exe,目标目录即安装目录
string dst = InstallDir;
if (!Directory.Exists(dst)) Directory.CreateDirectory(dst);
using (var archive = ZipFile.OpenRead(tmpZip))
{
foreach (var entry in archive.Entries)
{
string target = Path.GetFullPath(Path.Combine(dst, entry.FullName));
if (!target.StartsWith(Path.GetFullPath(dst), StringComparison.OrdinalIgnoreCase))
continue; // 防 zip 路径穿越
if (string.IsNullOrEmpty(entry.Name))
{
if (!Directory.Exists(target)) Directory.CreateDirectory(target);
continue;
}
string tdir = Path.GetDirectoryName(target);
if (!string.IsNullOrEmpty(tdir) && !Directory.Exists(tdir)) Directory.CreateDirectory(tdir);
if (File.Exists(target))
{
try { File.Delete(target); } catch { } // 被占用则跳过(旧文件同名多半一致)
}
entry.ExtractToFile(target, false);
}
}
if (FindExe() == null)
throw new Exception("解压完成但未找到 Umi-OCR 主程序,压缩包内容可能不完整");
try { File.Delete(tmpZip); } catch { } // 验证通过后删 zip
}
catch
{
try { if (File.Exists(tmpZip)) File.Delete(tmpZip); } catch { }
throw;
}
});
}
/// <summary>确保本体已安装且 HTTP 服务在线:未安装抛错引导下载;未运行则静默启动(--hide 主窗口进托盘)并等端口就绪(≤15秒)。
/// 返回 false=已安装但启动超时(调用方提示)。</summary>
public static bool EnsureRunning()
{
if (!IsInstalled)
throw new Exception("尚未安装 Umi-OCR 本体。\r\n请到 OCR 设置窗口选择 Umi-OCR 引擎,点\"下载 OCR 本体\"。");
if (IsHttpUp()) return true;
string exe = FindExe();
// --hide:官方参数,启动后主窗口隐藏到托盘,后台提供 HTTP 服务(用户看到零弹窗)
_startedProc = Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "--hide",
WorkingDirectory = Path.GetDirectoryName(exe),
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
});
for (int i = 0; i < 30; i++) // 30 × 500ms = 最多 15 秒
{
if (IsHttpUp()) return true;
System.Threading.Thread.Sleep(500);
}
return false;
}
/// <summary>停止由本程序启动的实例(PdfViewerWindow 关闭时调用;用户自己开的不动)。
/// --hide 启动的窗口在托盘,CloseMainWindow 不可靠:先发官方 --quit 指令优雅退出,超时再 Kill。</summary>
public static void StopIfStartedByUs()
{
var p = _startedProc;
_startedProc = null;
if (p == null) return;
try
{
if (!p.HasExited)
{
// 官方退出指令:向已运行实例发 --quit(依赖其 HTTP 通道)
try
{
Process.Start(new ProcessStartInfo
{
FileName = FindExe(),
Arguments = "--quit",
UseShellExecute = false,
CreateNoWindow = true
});
}
catch { }
if (!p.WaitForExit(3000)) p.Kill();
}
}
catch { }
finally { try { p.Dispose(); } catch { } }
}
}
}
+39 -14
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="Build">
<PropertyGroup>
<ProjectGuid>{7C2A9D4E-1F58-4B6A-9E77-3A5B10D9C8F1}</ProjectGuid>
@@ -113,16 +113,43 @@
<Compile Include="Program.cs" />
<Compile Include="SelfTest.cs" />
<Compile Include="PdfBridge.cs" />
<Compile Include="PdfiumNative.cs" />
<Compile Include="PdfDocument.cs" />
<Compile Include="OcrClient.cs" />
<Compile Include="GlmOcrClient.cs" />
<Compile Include="QianfanOcrClient.cs" />
<Compile Include="UapisOcrClient.cs" />
<Compile Include="UmiOcrClient.cs" />
<Compile Include="UmiOcrHost.cs" />
<Compile Include="OcrSettingsForm.cs" />
<Compile Include="OcrSettingsForm.Designer.cs" />
<!-- ★ A1(2026-10-01):OCR/PDF 基础设施与主项目共享同一份源码(链接编译,不再各留副本)——
权威源 成套报价软件\扒图\(主项目版为演进版,含 OcrPaths 旧布局回退/ocr.cls 方向纠正/
UmiOcr 启停拆分等修复);此前 pdfbt 侧 10 个本地副本是旧快照,已删除(git 基线可找回) -->
<Compile Include="..\成套报价软件\扒图\PdfiumNative.cs">
<Link>共享扒图\PdfiumNative.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\PdfDocument.cs">
<Link>共享扒图\PdfDocument.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\OcrClient.cs">
<Link>共享扒图\OcrClient.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\GlmOcrClient.cs">
<Link>共享扒图\GlmOcrClient.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\QianfanOcrClient.cs">
<Link>共享扒图\QianfanOcrClient.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\UapisOcrClient.cs">
<Link>共享扒图\UapisOcrClient.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\UmiOcrClient.cs">
<Link>共享扒图\UmiOcrClient.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\UmiOcrHost.cs">
<Link>共享扒图\UmiOcrHost.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\UmiOcrLauncher.cs">
<Link>共享扒图\UmiOcrLauncher.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\OcrSettingsForm.cs">
<Link>共享扒图\OcrSettingsForm.cs</Link>
</Compile>
<Compile Include="..\成套报价软件\扒图\OcrSettingsForm.Designer.cs">
<Link>共享扒图\OcrSettingsForm.Designer.cs</Link>
<DependentUpon>OcrSettingsForm.cs</DependentUpon>
</Compile>
<Compile Include="PdfViewerWindow.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Plugin\Form3.cs" />
@@ -155,9 +182,7 @@
<EmbeddedResource Include="MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="OcrSettingsForm.resx">
<DependentUpon>OcrSettingsForm.Designer.cs</DependentUpon>
</EmbeddedResource>
<!-- OcrSettingsForm.resx 删除:表单已链接共享主项目源码(A1),本地空壳 resx 与链接 Designer 不再匹配 -->
<EmbeddedResource Include="Plugin\Form3.resx">
<DependentUpon>Form3.cs</DependentUpon>
</EmbeddedResource>
+1
View File
@@ -79,6 +79,7 @@ shezhi\许可证文本.Designer.cs
登录.Designer.cs
会员服务Host.cs
OpenClawHost.cs
数据库连接.cs
AI助手接口.cs
AI助手设置.cs
AI助手设置.Designer.cs
+2 -1
View File
@@ -48,7 +48,8 @@ namespace 成套报价软件
InitializeComponent();
_projectId = projectId;
_dbPath = dbPath;
_connString = "Data Source=" + dbPath + ";Version=3;";
// ★ A2(2026-10-01):原为裸串(无 WAL/超时,并发易锁死)——统一收敛到 数据库连接.串
_connString = 数据库连接.串(dbPath);
// 绑定事件
this.button1.Click += button1_Click;
+2 -1
View File
@@ -305,7 +305,8 @@ namespace 成套报价软件
private string GetConnString(string dbPath)
{
return "Data Source=" + dbPath + ";Version=3;";
// ★ A2(2026-10-01):原为裸串(无 WAL/超时,并发易锁死)——统一收敛到 数据库连接.串
return 数据库连接.串(dbPath);
}
private Dictionary<string, string> LoadProjectInfo()
+3 -2
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
@@ -503,7 +503,8 @@ namespace 成套报价软件
private string GetConnString(string dbPath)
{
return "Data Source=" + dbPath + ";Version=3;";
// ★ A2(2026-10-01):原为裸串(无 WAL/超时,并发易锁死)——统一收敛到 数据库连接.串
return 数据库连接.串(dbPath);
}
/// <summary>查表索引(复用连接版本)。</summary>
+2 -3
View File
@@ -3663,9 +3663,8 @@ namespace 成套报价软件
private string GetConnString(string dbPath)
{
// ★ B4(2026-10-01):加 BusyTimeout=5000——直写队列/全库重算/扒图窗口并发访问同一库时,
// 遇锁默认 0 等待直接抛 database is locked;加超时后 SQLite 自动等待重试(方案窗口早已同款)
return "Data Source=" + dbPath + ";Version=3;Journal Mode=WAL;Synchronous=Normal;Pooling=True;BusyTimeout=5000;";
// ★ A2(2026-10-01):统一收敛到 数据库连接.串(一处管理),本方法保留签名做转发,调用方零改动
return 数据库连接.串(dbPath);
}
/// <summary>
+4 -4
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
@@ -106,11 +106,11 @@ namespace 成套报价软件
return list;
}
/// <summary>GetConnString 的静态包装,供静态方法调用。</summary>
/// <summary>GetConnString 的静态包装,供静态方法调用。
/// ★ A2(2026-10-01):统一收敛到 数据库连接.串(一处管理),本方法保留签名做转发。</summary>
private static string GetConnStringStatic(string dbPath)
{
// ★ B4(2026-10-01):与实例版同款加 BusyTimeout(防并发锁直报错)
return "Data Source=\"" + dbPath + "\";Version=3;Journal Mode=WAL;Synchronous=Normal;Pooling=True;BusyTimeout=5000;";
return 数据库连接.串(dbPath);
}
/// <summary>
+2 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="Build">
<PropertyGroup>
<ProjectGuid>{B6966FA3-E161-44ED-8A40-CCDBE1F15192}</ProjectGuid>
@@ -159,6 +159,7 @@
<Compile Include="DockWindows\DockWindow10\DockWindow10.cs" />
<Compile Include="DockWindows\DockWindow10\DockWindow10.Designer.cs" />
<Compile Include="OpenClawHost.cs" />
<Compile Include="数据库连接.cs" />
<Compile Include="AI助手接口.cs" />
<Compile Include="AI助手设置.cs" />
<Compile Include="AI助手设置.Designer.cs" />
+2 -3
View File
@@ -612,9 +612,8 @@ namespace MyApp
private string GetConnString(string dbPath)
{
// ★ busy_timeout=3000:数据库被 CAD 或其他进程短暂占用时等待重试而非卡死;
// Pooling 关闭,避免连接池长期持有文件句柄
return "Data Source=" + dbPath + ";Version=3;busy_timeout=3000;";
// ★ A2(2026-10-01):统一收敛到主程序 数据库连接.串无池(保留扒图关池语义,一处管理)
return 成套报价软件.数据库连接.串无池(dbPath);
}
private string GetQuoteDbPath(string projectId)
+2 -3
View File
@@ -635,9 +635,8 @@ namespace MyAppPdf
private string GetConnString(string dbPath)
{
// ★ busy_timeout=3000:数据库被 CAD 或其他进程短暂占用时等待重试而非卡死;
// Pooling 关闭,避免连接池长期持有文件句柄
return "Data Source=" + dbPath + ";Version=3;busy_timeout=3000;";
// ★ A2(2026-10-01):统一收敛到主程序 数据库连接.串无池(保留扒图关池语义,一处管理)
return 成套报价软件.数据库连接.串无池(dbPath);
}
private string GetQuoteDbPath(string projectId)
+30
View File
@@ -0,0 +1,30 @@
using System;
namespace 成套报价软件
{
/// <summary>SQLite 连接字符串统一管理(A2,2026-10-01):全项目连接参数一处调整全局生效。
/// 此前 6 份重复定义(主窗口/菜单与库/调系数/甲方报表/Cad扒图/Pdf扒图)参数漂移——
/// 调系数与甲方报表是裸串(无 WAL/超时,并发易锁死),扒图窗口是有意关池(防句柄常驻)。
/// 现收敛为两个变体:各处私有 GetConnString 均转发到这里,调用方零改动。</summary>
public static class 数据库连接
{
/// <summary>标准连接串(主窗口/直写队列/重算/报表等主程序各模块用):
/// WAL+Normal 同步+连接池+5秒锁等待。项目库文件首开后即持久保持 WAL 模式,
/// 各连接串里是否声明 Journal Mode 只影响新库,对已建库无实际差异。</summary>
public static string 串(string dbPath)
{
// ★ BusyTimeout=5000(B4):直写队列/全库重算/扒图窗口并发访问同一库时,
// 遇锁默认 0 等待直接抛 database is locked;加超时后 SQLite 自动等待重试
return "Data Source=\"" + dbPath + "\";Version=3;Journal Mode=WAL;Synchronous=Normal;Pooling=True;BusyTimeout=5000;";
}
/// <summary>无池连接串(扒图窗口 Cad/Pdf Form3 专用):
/// ★ Pooling 关闭是该窗口的历史有意设计——避免连接池长期持有库文件句柄
/// (导出项目复制库/删库等文件操作不受池句柄干扰),收敛时保留该语义;
/// busy_timeout=3000:库被其他模块短暂占用时等待重试而非卡死。</summary>
public static string 串无池(string dbPath)
{
return "Data Source=" + dbPath + ";Version=3;busy_timeout=3000;";
}
}
}