diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ee1fe5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# MORT 프로젝트 작업 규약 + +## 프로젝트 성격 +MORT는 2013년부터 이어진 장기 유지보수 프로젝트로, **옛날 방식과 현재 방식이 한 코드베이스 안에 혼재**되어 있다. 사용자 설정 파일 호환성, 기존 사용자층 보호, 점진적 리팩터링 중인 상태이기 때문에 한 번에 정리할 수 없다. + +## 작업 원칙 + +### 1. 통일하려 들지 말 것 +- 작업 요청 범위를 벗어나서 "낡았으니 정리하자"는 식의 리팩터링 금지 +- 사용자가 명시적으로 요청할 때만 패턴을 통일한다 +- 버그 픽스/기능 추가는 **주변 코드 스타일을 그대로 따른다** +- Newtonsoft.Json과 System.Text.Json이 같이 쓰여도, RestSharp와 HttpClient가 같이 쓰여도 통일하지 말 것 + +### 2. 싱글톤과 DI 공존 +- `OcrManager.Instace`, `FormManager.Instace`, `TransManager._instance` 같은 싱글톤과 `Program.ServiceContainer`, `ConfigureServices`의 DI는 **공존하는 게 정상** +- 새 매니저/서비스를 만들 땐 DI 우선 +- 기존 매니저를 호출할 땐 싱글톤 접근(`Instace`)을 자연스럽게 써도 된다 +- `TransManager`처럼 DI로 등록되어 있지만 `_instance`도 같이 유지하는 하이브리드 패턴도 OK + +### 3. 호환성 묶인 구조 보존 (절대 변경 금지) +- `SettingManager.TransType` enum 순서 (`google_url`, `db`, `papago_web`, `naver`, `google`, `deepl`, `deeplApi`, `gemini`, `ezTrans`, `customApi`) +- `SettingManager.OcrType` enum 순서 (`Tesseract=0`, `Window=1`, `OneOcr=2`, `Google=3`, `EasyOcr=4`) +- `SettingManager.Skin` enum 순서 (`dark`, `layer`, `over`) +- `@KEY ` 프리픽스 기반 텍스트 키-값 설정 포맷 +- 직렬화 키 이름 +- 코드 주석에도 "앞 소문자 바꾸면 안 됨 -> 기존 버전과 호환성"이라고 명시되어 있음 + +### 4. 거대 파일은 그대로 둠 +점진적 분리 중인 상태로, 손대지 않는다: +- `Form1.cs` (3441줄) +- `SettingManager.cs` (1930줄) +- `UIAdvencedOption.cs` (1190줄) +- `TransManager.cs` (1246줄) +- `FormManager.cs` (1152줄) +- `AdvencedOptionManager.cs` (740줄) + +요청받은 작업 범위 외엔 분리/리팩터링하지 않는다. + +### 5. 새 기능 추가 위치 +최근 커밋 흐름을 따른다: +- 비즈니스 로직 → `Service/` (e.g. `Service/Gemini/`, `Service/CustomApi/`) +- 데이터 모델 → `Model/` (record 타입 선호) +- DI 등록 → `Program.ConfigureServices` +- 번역 API → `TransAPI/` +- OCR API → `OcrApi/` + +### 6. 로컬라이즈 CSV 직접 수정 금지 +- `Resources/localize.csv`는 구글 스프레드 시트에서 관리한다 +- 코드 작업 중 `Resources/localize.csv`를 직접 수정하지 않는다 + +## 프로젝트 구조 요약 + +### 솔루션 (5개 프로젝트) +1. **MORT** — 메인 WinForms 앱 (.NET 9, x64) +2. **CloudVision** — Google Cloud Vision OCR 래퍼 +3. **GSTrans** — Google Sheets 번역기 +4. **PipeClient** — EzTrans 연동용 IPC +5. **Updater** — 업데이트 모듈 (AnyCPU) + +### 핵심 디렉토리 +- **진입점**: `Program.cs` → DI 구성 → `Form1` +- **Manager**: `OcrManager`(싱글톤), `TransManager`(DI+싱글톤 하이브리드), `FormManager`(싱글톤), `OCRDataManager` +- **Service**: `Service/Gemini/`, `Service/CustomApi/`, `Service/TranslateTyp/`, `Service/PythonService/`, `Service/ProcessTranslateService/` +- **번역 API**: `TransAPI/` (Google, Naver, Papago Web, DeepL, DeepL API, Gemini, EzTrans, CustomAPI) +- **OCR API**: `OcrApi/OneOcr/`, `OcrApi/WindowOcr/`, `OcrApi/EasyOcr/` (+ Tesseract는 `MORT_CORE.DLL`) +- **로컬라이즈**: `LocalizeManager/`, `Resources/localize.csv` (ko/en/ja/zh-CN/id/ru/pt/uk/tr) + +### 외부 의존성 +- `MORT_CORE.DLL`, `nhocr.DLL` — 별도 C++ 프로젝트, 빌드 후 릴리즈 폴더에 압축해제 필요 +- `Google.GenAI` 패키지는 있지만 Gemini는 직접 REST 호출 사용 + +### 빌드 +- **x64 전용** (Updater만 AnyCPU) +- `.NET 9` (`net9.0-windows10.0.22621.0`) +- WinForms + WPF 동시 사용 + +## 구현 위키 유지 + +- 구현 전에 `docs/wiki/index.html`을 참조한다. +- 위키는 단순 클래스 설명보다 구현 의도, 작동 방식, 실제 예시, 아직 답이 없는 질문을 우선한다. +- 코드 변경으로 공통 의도·흐름·예시·질문의 답이 달라지면 `docs/wiki/wiki-content.json`을 같은 작업에서 수정한다. +- 기능의 구현·작동 방식이 달라지면 `docs/wiki/feature-content.json`을 수정한다. +- 자동 분류보다 구체적인 파일 설명이 필요하면 `docs/wiki/file-overrides.json`에 구현 의도와 작동 방식을 기록한다. +- 모든 코드 작업이 끝나면 `powershell -NoProfile -ExecutionPolicy Bypass -File tools/update-wiki.ps1`을 실행한다. +- 생성물인 `docs/wiki/index.html`은 직접 수정하지 않는다. +- 빌드와 저장소 pre-commit hook도 위키를 자동 갱신한다. diff --git a/CLAUDE.md b/CLAUDE.md index 6cf8d1e..64989ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,71 +1,26 @@ -# MORT 프로젝트 작업 규약 +# MORT 작업 규약 — 원본은 AGENTS.md -## 프로젝트 성격 -MORT는 2013년부터 이어진 장기 유지보수 프로젝트로, **옛날 방식과 현재 방식이 한 코드베이스 안에 혼재**되어 있다. 사용자 설정 파일 호환성, 기존 사용자층 보호, 점진적 리팩터링 중인 상태이기 때문에 한 번에 정리할 수 없다. +이 저장소의 작업 규약 원본은 **`AGENTS.md`** 하나뿐이다. Claude Code는 보조 도구이며, +Codex가 쓰는 `AGENTS.md`의 정의를 그대로 따른다. -## 작업 원칙 +## 작업 시작 전 -### 1. 통일하려 들지 말 것 -- 작업 요청 범위를 벗어나서 "낡았으니 정리하자"는 식의 리팩터링 금지 -- 사용자가 명시적으로 요청할 때만 패턴을 통일한다 -- 버그 픽스/기능 추가는 **주변 코드 스타일을 그대로 따른다** -- Newtonsoft.Json과 System.Text.Json이 같이 쓰여도, RestSharp와 HttpClient가 같이 쓰여도 통일하지 말 것 +**어떤 작업이든 시작하기 전에 `AGENTS.md`를 읽고 그 내용을 그대로 적용한다.** +이 파일에 규약 본문이 없다고 해서 제약이 없는 것이 아니다. -### 2. 싱글톤과 DI 공존 -- `OcrManager.Instace`, `FormManager.Instace`, `TransManager._instance` 같은 싱글톤과 `Program.ServiceContainer`, `ConfigureServices`의 DI는 **공존하는 게 정상** -- 새 매니저/서비스를 만들 땐 DI 우선 -- 기존 매니저를 호출할 땐 싱글톤 접근(`Instace`)을 자연스럽게 써도 된다 -- `TransManager`처럼 DI로 등록되어 있지만 `_instance`도 같이 유지하는 하이브리드 패턴도 OK +`AGENTS.md`가 정하는 것: +- 작업 원칙 6개 (통일 금지 / 싱글톤·DI 공존 / 호환성 구조 동결 / 거대 파일 불가침 / + 새 기능 추가 위치 / `Resources/localize.csv` 직접 수정 금지) +- 프로젝트 구조와 빌드 조건 +- 구현 위키(`docs/wiki/`) 유지 절차 -### 3. 호환성 묶인 구조 보존 (절대 변경 금지) -- `SettingManager.TransType` enum 순서 (`google_url`, `db`, `papago_web`, `naver`, `google`, `deepl`, `deeplApi`, `gemini`, `ezTrans`, `customApi`) -- `SettingManager.OcrType` enum 순서 (`Tesseract=0`, `Window=1`, `OneOcr=2`, `Google=3`, `EasyOcr=4`) -- `SettingManager.Skin` enum 순서 (`dark`, `layer`, `over`) -- `@KEY ` 프리픽스 기반 텍스트 키-값 설정 포맷 -- 직렬화 키 이름 -- 코드 주석에도 "앞 소문자 바꾸면 안 됨 -> 기존 버전과 호환성"이라고 명시되어 있음 +## 규약을 바꿔야 할 때 -### 4. 거대 파일은 그대로 둠 -점진적 분리 중인 상태로, 손대지 않는다: -- `Form1.cs` (3441줄) -- `SettingManager.cs` (1930줄) -- `UIAdvencedOption.cs` (1190줄) -- `TransManager.cs` (1246줄) -- `FormManager.cs` (1152줄) -- `AdvencedOptionManager.cs` (740줄) +`AGENTS.md`만 수정한다. 이 파일에는 규약 내용을 옮겨 적지 않는다. +과거에 두 파일에 같은 내용을 중복해 두었다가 `AGENTS.md`에만 추가된 항목 +(로컬라이즈 CSV 규칙, 구현 위키 유지 절차)이 이쪽에 빠진 채 어긋난 적이 있다. -요청받은 작업 범위 외엔 분리/리팩터링하지 않는다. +## 이 파일이 담당하는 범위 -### 5. 새 기능 추가 위치 -최근 커밋 흐름을 따른다: -- 비즈니스 로직 → `Service/` (e.g. `Service/Gemini/`, `Service/CustomApi/`) -- 데이터 모델 → `Model/` (record 타입 선호) -- DI 등록 → `Program.ConfigureServices` -- 번역 API → `TransAPI/` -- OCR API → `OcrApi/` - -## 프로젝트 구조 요약 - -### 솔루션 (5개 프로젝트) -1. **MORT** — 메인 WinForms 앱 (.NET 9, x64) -2. **CloudVision** — Google Cloud Vision OCR 래퍼 -3. **GSTrans** — Google Sheets 번역기 -4. **PipeClient** — EzTrans 연동용 IPC -5. **Updater** — 업데이트 모듈 (AnyCPU) - -### 핵심 디렉토리 -- **진입점**: `Program.cs` → DI 구성 → `Form1` -- **Manager**: `OcrManager`(싱글톤), `TransManager`(DI+싱글톤 하이브리드), `FormManager`(싱글톤), `OCRDataManager` -- **Service**: `Service/Gemini/`, `Service/CustomApi/`, `Service/TranslateTyp/`, `Service/PythonService/`, `Service/ProcessTranslateService/` -- **번역 API**: `TransAPI/` (Google, Naver, Papago Web, DeepL, DeepL API, Gemini, EzTrans, CustomAPI) -- **OCR API**: `OcrApi/OneOcr/`, `OcrApi/WindowOcr/`, `OcrApi/EasyOcr/` (+ Tesseract는 `MORT_CORE.DLL`) -- **로컬라이즈**: `LocalizeManager/`, `Resources/localize.csv` (ko/en/ja/zh-CN/id/ru/pt/uk/tr) - -### 외부 의존성 -- `MORT_CORE.DLL`, `nhocr.DLL` — 별도 C++ 프로젝트, 빌드 후 릴리즈 폴더에 압축해제 필요 -- `Google.GenAI` 패키지는 있지만 Gemini는 직접 REST 호출 사용 - -### 빌드 -- **x64 전용** (Updater만 AnyCPU) -- `.NET 9` (`net9.0-windows10.0.22621.0`) -- WinForms + WPF 동시 사용 +Claude Code 세션에만 해당하는 사항이 생기면 여기에 적는다. +프로젝트 규약은 여기에 적지 않는다. diff --git a/MORT/Form1.Designer.cs b/MORT/Form1.Designer.cs index 40b2fe3..deb4e75 100644 --- a/MORT/Form1.Designer.cs +++ b/MORT/Form1.Designer.cs @@ -325,6 +325,7 @@ private void InitializeComponent() panel24 = new System.Windows.Forms.Panel(); panel26 = new System.Windows.Forms.Panel(); plDebugOn = new System.Windows.Forms.Panel(); + cbSaveAnalysisResult = new System.Windows.Forms.CheckBox(); cbShowOverlayWordArea = new System.Windows.Forms.CheckBox(); cbSetLineTrans = new System.Windows.Forms.CheckBox(); btClearFormerResult = new System.Windows.Forms.Button(); @@ -3787,6 +3788,7 @@ private void InitializeComponent() // // plDebugOn // + plDebugOn.Controls.Add(cbSaveAnalysisResult); plDebugOn.Controls.Add(cbShowOverlayWordArea); plDebugOn.Controls.Add(cbSetLineTrans); plDebugOn.Controls.Add(btClearFormerResult); @@ -3799,9 +3801,22 @@ private void InitializeComponent() plDebugOn.Name = "plDebugOn"; plDebugOn.Size = new System.Drawing.Size(507, 512); plDebugOn.TabIndex = 56; - // + // + // cbSaveAnalysisResult + // + cbSaveAnalysisResult.AutoSize = true; + cbSaveAnalysisResult.Font = new System.Drawing.Font("맑은 고딕", 9.75F, System.Drawing.FontStyle.Bold); + cbSaveAnalysisResult.ForeColor = System.Drawing.Color.White; + cbSaveAnalysisResult.Location = new System.Drawing.Point(14, 240); + cbSaveAnalysisResult.Name = "cbSaveAnalysisResult"; + cbSaveAnalysisResult.Size = new System.Drawing.Size(383, 21); + cbSaveAnalysisResult.TabIndex = 29; + cbSaveAnalysisResult.Text = "이미지 인식 결과 저장 - UserData/Debug/OcrAnalysis/*.json"; + cbSaveAnalysisResult.UseVisualStyleBackColor = true; + cbSaveAnalysisResult.CheckedChanged += cbSaveAnalysisResult_CheckedChanged; + // // cbShowOverlayWordArea - // + // cbShowOverlayWordArea.AutoSize = true; cbShowOverlayWordArea.Font = new System.Drawing.Font("맑은 고딕", 9.75F, System.Drawing.FontStyle.Bold); cbShowOverlayWordArea.ForeColor = System.Drawing.Color.White; @@ -4286,6 +4301,7 @@ private void InitializeComponent() private System.Windows.Forms.Panel panel24; private System.Windows.Forms.Panel panel26; private System.Windows.Forms.Label lbDebugging; + private System.Windows.Forms.CheckBox cbSaveAnalysisResult; private System.Windows.Forms.CheckBox cbShowOCRIndex; private System.Windows.Forms.Button btnHideTransEmpty; private System.Windows.Forms.Button btnHideTransDefault; diff --git a/MORT/Form1.cs b/MORT/Form1.cs index fd15b00..09c6e0d 100644 --- a/MORT/Form1.cs +++ b/MORT/Form1.cs @@ -176,6 +176,7 @@ public List WinLanguageCodeList public static bool IsDebugShowFormerResultLog = false; public static bool IsDebugTransOneLine = false; public static bool IsDebugShowWordArea = false; + public static bool IsDebugSaveAnalysisResult = false; private List inputKeyUIList = new List(); @@ -820,7 +821,7 @@ public Form1(GeminiConfigMaker geminiConfigMaker, TranslateTypListService transl //GDI+ 동작 여부 검사. CheckGDI(); - _processTranslateService = new ProcessTranslateService(this, _translateResultMemoryService, MySettingManager, loader, isAvailableWinOCR, StopTrans); + _processTranslateService = new ProcessTranslateService(this, _translateResultMemoryService, MySettingManager, loader, isAvailableWinOCR, isOnceTrans => StopTrans(isOnceTrans)); MakeLogo(); @@ -1181,7 +1182,8 @@ public void gHook_KeyDown(object sender, KeyEventArgs e) } else if (_processTranslateService.ProcessingState) { - StopTrans(); + //저수준 키보드 훅에서 부르는 자리다. 오래 붙잡히면 훅이 제거된다 + StopTrans(false, true); } } //한 번만 번역하기 @@ -1194,7 +1196,8 @@ public void gHook_KeyDown(object sender, KeyEventArgs e) } else if (_processTranslateService.ProcessingState) { - _processTranslateService.PauseAndRestartTranslate(SetCaptureArea, OcrMethodType.Once); + _processTranslateService.PauseAndRestartTranslate(SetCaptureArea, OcrMethodType.Once, + ProcessTranslateService.KeyHookJoinTimeoutMs); } } @@ -1975,12 +1978,18 @@ public void StartTrnas(OcrMethodType ocrMethodType) MakeTransForm(); } - public void StopTrans(bool isOnceTrans = false) + /// + /// 저수준 키보드 훅에서 불렸는지. 훅 프로시저가 300ms 넘게 붙잡히면 + /// 윈도우가 훅을 제거해 이후 모든 단축키가 죽으므로 대기 시간을 짧게 잡는다. + /// + public void StopTrans(bool isOnceTrans = false, bool fromKeyHook = false) { _processTrans = false; FormManager.Instace.MyRemoteController.ToggleStartButton(false); - _processTranslateService.StopTranslate(); + _processTranslateService.StopTranslate(fromKeyHook + ? ProcessTranslateService.KeyHookJoinTimeoutMs + : ProcessTranslateService.DefaultJoinTimeoutMs); var transform = FormManager.Instace.GetITransform(); diff --git a/MORT/Form1Button.cs b/MORT/Form1Button.cs index bb8fa20..22654dd 100644 --- a/MORT/Form1Button.cs +++ b/MORT/Form1Button.cs @@ -145,6 +145,14 @@ private void cbShowOverlayWordArea_CheckedChanged(object sender, EventArgs e) } } + private void cbSaveAnalysisResult_CheckedChanged(object sender, EventArgs e) + { + if (MySettingManager.isDebugMode) + { + IsDebugSaveAnalysisResult = cbSaveAnalysisResult.Checked; + } + } + #endregion diff --git a/MORT/Manager/OCRDataManager.cs b/MORT/Manager/OCRDataManager.cs index a9c046b..b553abf 100644 --- a/MORT/Manager/OCRDataManager.cs +++ b/MORT/Manager/OCRDataManager.cs @@ -951,6 +951,11 @@ public static OCRDataManager Instace public bool MergeLine { get; set; } = false; + /// + /// 목록만 복사해서 넘긴다. 원소는 그대로 공유한다. + /// 번역 스레드가 다음 회차에 ClearData 로 내부 목록을 비우기 때문에, + /// 내부 목록을 그대로 넘기면 번역창이 순회하는 도중에 비어버린다. + /// public List GetData() { List list = new List(); @@ -958,7 +963,7 @@ public List GetData() { list.Add(dataList[i]); } - return dataList; + return list; } public ResultData GetData(int index) diff --git a/MORT/Model/Debug/OcrDebugSnapshotModel.cs b/MORT/Model/Debug/OcrDebugSnapshotModel.cs new file mode 100644 index 0000000..8091897 --- /dev/null +++ b/MORT/Model/Debug/OcrDebugSnapshotModel.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using System.Drawing; + +namespace MORT.Model.Debug +{ + /// + /// 이미지 분석 결과를 나중에 다시 확인하기 위한 디버깅 스냅샷 모델. + /// 저장 전용이며 다시 읽어들이지 않기 때문에 키 이름은 호환성 제약이 없다. + /// + public record OcrDebugRect + { + public int X { get; init; } + public int Y { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public int Right { get; init; } + public int Bottom { get; init; } + + public static OcrDebugRect From(Rectangle rect) + { + return new OcrDebugRect + { + X = rect.X, + Y = rect.Y, + Width = rect.Width, + Height = rect.Height, + Right = rect.Right, + Bottom = rect.Bottom, + }; + } + } + + public record OcrDebugWord + { + public string Text { get; init; } = ""; + public OcrDebugRect Rect { get; init; } + } + + public record OcrDebugLine + { + public int GroupIndex { get; init; } + public string LineString { get; init; } = ""; + public string TransString { get; init; } = ""; + public string AngleType { get; init; } = ""; + public OcrDebugRect LineRect { get; init; } + public List Words { get; init; } = new(); + public List TransWords { get; init; } = new(); + } + + /// + /// 오버레이가 하나의 블록으로 그리는 번역 문장 단위. + /// + public record OcrDebugTransBlock + { + public int Index { get; init; } + public string Trans { get; init; } = ""; + public bool IsTitle { get; init; } + public string AngleType { get; init; } = ""; + public OcrDebugRect LineRect { get; init; } + public OcrDebugRect SourceRect { get; init; } + public OcrDebugRect ViewRect { get; init; } + public OcrDebugRect ContentRect { get; init; } + public List Lines { get; init; } = new(); + } + + public record OcrDebugAutoColor + { + public string Font { get; init; } = ""; + public string Background { get; init; } = ""; + } + + /// + /// OCR 영역 하나의 인식 결과. + /// + public record OcrDebugArea + { + public int Index { get; init; } + public bool SnapShot { get; init; } + /// 화면 좌표 기준 OCR 영역. + public OcrDebugRect AreaRect { get; init; } + /// 캡쳐 이미지 좌표 기준 인식 결과 전체 영역. + public OcrDebugRect ResultRect { get; init; } + public string OcrText { get; init; } = ""; + public string TransText { get; init; } = ""; + public bool UseAutoColor { get; init; } + public List AutoColors { get; init; } = new(); + public List Lines { get; init; } = new(); + public List TransBlocks { get; init; } = new(); + } + + /// + /// 오버레이가 실제로 그린 블록 하나의 최종값. + /// + public record OcrDebugOverlayBlock + { + public int AreaIndex { get; init; } + public int ColorIndex { get; init; } + public string Text { get; init; } = ""; + public bool IsTitle { get; init; } + public bool VerticalMode { get; init; } + + /// 이하 모두 오버레이 폼 클라이언트 좌표. + public OcrDebugRect CaptureRect { get; init; } + public OcrDebugRect SourceRect { get; init; } + public OcrDebugRect ViewRect { get; init; } + public OcrDebugRect ContentRect { get; init; } + + public string FontFamily { get; init; } = ""; + public string FontStyle { get; init; } = ""; + /// 실제로 그린 폰트 크기(pt). + public float FontSize { get; init; } + /// 자동 크기 계산의 목표값(pt). + public float PreferredFontSize { get; init; } + public float MinimumFontSize { get; init; } + /// 원문에서 추정한 폰트 크기(pt). + public float SourceFontSize { get; init; } + + public string FontColor { get; init; } = ""; + public string BackgroundColor { get; init; } = ""; + public bool DrawBackground { get; init; } + public bool UseAutoColor { get; init; } + public bool ContrastCorrected { get; init; } + public bool UseOutline { get; init; } + public string OutlineColor1 { get; init; } = ""; + public string OutlineColor2 { get; init; } = ""; + + /// 줄바꿈까지 끝난 최종 출력 줄. + public List WrappedLines { get; init; } = new(); + public float LineAdvance { get; init; } + /// 최소 폰트로도 들어가지 않아 잘린 상태. + public bool Clipped { get; init; } + } + + /// + /// 페인트 한 번의 구간별 시간. 오버레이가 UI 스레드를 얼마나 잡고 있는지 확인용. + /// + public record OcrDebugPaintTiming + { + /// DoUpdatePaint 전체. + public double TotalMs { get; init; } + /// 창 크기·위치 계산. + public double CheckSizeMs { get; init; } + /// 레이아웃·폰트 탐색·드로잉(AddText). + public double LayoutAndDrawMs { get; init; } + /// GetHbitmap + UpdateLayeredWindow. + public double PresentMs { get; init; } + /// 이 페인트에서 텍스트 측정을 재사용한 횟수. + public int MeasureCacheHit { get; init; } + /// 실제로 GDI+ 측정을 돌린 횟수. + public int MeasureCacheMiss { get; init; } + } + + public record OcrDebugOverlayInfo + { + public OcrDebugPaintTiming Timing { get; init; } + public OcrDebugRect FormRect { get; init; } + public bool IsAutoFontSize { get; init; } + public int MinAutoFontSize { get; init; } + public int MaxAutoFontSize { get; init; } + public bool KeepSourceDirection { get; init; } + public bool UseFontOutline { get; init; } + public bool AutoColor { get; init; } + public bool AutoBackgroundColor { get; init; } + public bool AutoFontColor { get; init; } + public bool UseBackColor { get; init; } + public List Blocks { get; init; } = new(); + } + + public record OcrDebugSnapshotModel + { + public string CapturedAt { get; init; } = ""; + public string Skin { get; init; } = ""; + public string OcrType { get; init; } = ""; + public string TransType { get; init; } = ""; + public string OcrText { get; init; } = ""; + public string TransText { get; init; } = ""; + public List Areas { get; init; } = new(); + /// 오버레이 스킨일 때만 채워진다. + public OcrDebugOverlayInfo Overlay { get; init; } + } +} diff --git a/MORT/Program.cs b/MORT/Program.cs index d750a7e..ef92d16 100644 --- a/MORT/Program.cs +++ b/MORT/Program.cs @@ -242,6 +242,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + //using System.Diagnostics 와 이름이 겹쳐서 네임스페이스를 그대로 적는다 + services.AddSingleton(); // 만약 Form1이 의존하는 다른 서비스들이 있다면 여기서 추가로 등록합니다. diff --git a/MORT/Properties/Settings.Designer.cs b/MORT/Properties/Settings.Designer.cs index 4022622..6b0070e 100644 --- a/MORT/Properties/Settings.Designer.cs +++ b/MORT/Properties/Settings.Designer.cs @@ -25,7 +25,7 @@ public static Settings Default { [global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("1.316V")] + [global::System.Configuration.DefaultSettingValueAttribute("1.317V")] public string MORT_VERSION { get { return ((string)(this["MORT_VERSION"])); @@ -43,7 +43,7 @@ public string LAYER_TEXT { [global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("1316")] + [global::System.Configuration.DefaultSettingValueAttribute("1317")] public int MORT_VERSION_VALUE { get { return ((int)(this["MORT_VERSION_VALUE"])); @@ -90,7 +90,7 @@ public string TOOLTIP_LIST { [global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("2026 08 02")] + [global::System.Configuration.DefaultSettingValueAttribute("2026 08 04")] public string MORT_RELEASE { get { return ((string)(this["MORT_RELEASE"])); diff --git a/MORT/Properties/Settings.settings b/MORT/Properties/Settings.settings index 8bef98c..a727261 100644 --- a/MORT/Properties/Settings.settings +++ b/MORT/Properties/Settings.settings @@ -3,14 +3,14 @@ - 1.316V + 1.317V MORT {0} 레이어 번역창 - 1316 + 1317 MORT를 처음 쓰시면 그 외-> MORT 사용법을 확인해 주세요, @@ -45,7 +45,7 @@ OCR 영역을 빠르게 추가하고 싶을 땐 빠른 OCR 영역을 사용하 구글 OCR의 사용량은 실제 사용량과 다를 수 있습니다. 수시로 구글 콘솔에서 확인하셔야 합니다 - 2026 08 02 + 2026 08 04 Monkeyhead's OCR Realtime TransLate {0} diff --git a/MORT/Service/Debug/OcrDebugSnapshotService.cs b/MORT/Service/Debug/OcrDebugSnapshotService.cs new file mode 100644 index 0000000..ee20278 --- /dev/null +++ b/MORT/Service/Debug/OcrDebugSnapshotService.cs @@ -0,0 +1,297 @@ +using MORT.Model.Debug; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; + +namespace MORT.Service.Debug +{ + /// + /// 이미지 인식 결과를 JSON 파일로 남겨 나중에 다시 확인할 수 있게 한다. + /// OCR 단계와 오버레이 렌더 단계가 서로 다른 쓰레드에서 끝나기 때문에, + /// OCR 결과를 먼저 받아두고 오버레이 스킨일 때만 렌더 값이 채워질 때까지 기다렸다가 저장한다. + /// + public class OcrDebugSnapshotService + { + private const string DirectoryName = "OcrAnalysis"; + + private readonly object _lock = new object(); + private readonly string _debugDirectory; + + private OcrDebugSnapshotModel _pending; + private bool _waitingOverlay; + + /// 오버레이가 렌더 값을 채워주기를 기다리는 중인지. + public bool IsWaitingOverlay + { + get + { + lock(_lock) + { + return _waitingOverlay && _pending != null; + } + } + } + + public OcrDebugSnapshotService() + { + _debugDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UserData", "Debug", DirectoryName); + } + + /// + /// OCR / 번역이 끝난 시점의 결과를 담아둔다. + /// 오버레이 스킨이 아니면 이 시점에 바로 저장한다. + /// + public void CaptureOcrResult( + List dataList, + SettingManager.Skin skin, + SettingManager.OcrType ocrType, + SettingManager.TransType transType, + string ocrText, + string transText) + { + try + { + var areas = new List(); + if(dataList != null) + { + foreach(var data in dataList) + { + if(data == null) + { + continue; + } + + areas.Add(ConvertArea(data)); + } + } + + var snapshot = new OcrDebugSnapshotModel + { + CapturedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), + Skin = skin.ToString(), + OcrType = ocrType.ToString(), + TransType = transType.ToString(), + OcrText = ocrText ?? "", + TransText = transText ?? "", + Areas = areas, + }; + + OcrDebugSnapshotModel abandoned = null; + lock(_lock) + { + // 오버레이가 끝내 그려지지 않은 이전 스냅샷은 오버레이 정보 없이라도 남긴다. + if(_pending != null) + { + abandoned = _pending; + } + + _pending = snapshot; + _waitingOverlay = skin == SettingManager.Skin.over; + } + + if(abandoned != null) + { + Save(abandoned); + } + + if(skin != SettingManager.Skin.over) + { + Flush(); + } + } + catch(Exception ex) + { + Util.ShowLog($"OcrDebugSnapshotService: CaptureOcrResult failed - {ex.Message}"); + } + } + + /// + /// 오버레이가 실제로 그린 최종값을 채우고 파일로 저장한다. + /// + public void CompleteOverlay(Rectangle formRect, bool useBackColor, List blocks, + OcrDebugPaintTiming timing = null) + { + try + { + var overlay = new OcrDebugOverlayInfo + { + Timing = timing, + FormRect = OcrDebugRect.From(formRect), + IsAutoFontSize = AdvencedOptionManager.IsAutoFontSize, + MinAutoFontSize = AdvencedOptionManager.MinAutoFontSize, + MaxAutoFontSize = AdvencedOptionManager.MaxAutoFontSize, + KeepSourceDirection = AdvencedOptionManager.OverlayKeepSourceDirection, + UseFontOutline = AdvencedOptionManager.OverlayUseFontOutline, + AutoColor = AdvencedOptionManager.OverlayAutoColor, + AutoBackgroundColor = AdvencedOptionManager.OverlayAutoBackgroundColor, + AutoFontColor = AdvencedOptionManager.OverlayAutoFontColor, + UseBackColor = useBackColor, + Blocks = blocks ?? new List(), + }; + + lock(_lock) + { + if(_pending == null) + { + return; + } + + _pending = _pending with { Overlay = overlay }; + _waitingOverlay = false; + } + + Flush(); + } + catch(Exception ex) + { + Util.ShowLog($"OcrDebugSnapshotService: CompleteOverlay failed - {ex.Message}"); + } + } + + private void Flush() + { + OcrDebugSnapshotModel target; + lock(_lock) + { + target = _pending; + _pending = null; + _waitingOverlay = false; + } + + if(target != null) + { + Save(target); + } + } + + private void Save(OcrDebugSnapshotModel snapshot) + { + try + { + Directory.CreateDirectory(_debugDirectory); + + string fileName = $"ocr_analysis_{DateTime.Now:yyyyMMdd_HHmmss_fff}.json"; + string filePath = Path.Combine(_debugDirectory, fileName); + string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented); + + Util.SaveFile(filePath, json, false); + Util.ShowLog($"OcrDebugSnapshotService: saved {filePath}"); + } + catch(Exception ex) + { + Util.ShowLog($"OcrDebugSnapshotService: Save failed - {ex.Message}"); + } + } + + private static OcrDebugArea ConvertArea(OCRDataManager.ResultData data) + { + var autoColors = new List(); + foreach(var color in data.AutoColor) + { + autoColors.Add(new OcrDebugAutoColor + { + Font = ToColorText(color.Font), + Background = ToColorText(color.BackGround), + }); + } + + var lines = new List(); + foreach(var line in data.LineDataList) + { + lines.Add(ConvertLine(line)); + } + + var transBlocks = new List(); + foreach(var transData in data.TransDataList) + { + transBlocks.Add(ConvertTransBlock(transData)); + } + + return new OcrDebugArea + { + Index = data.Index, + SnapShot = data.SnapShot, + AreaRect = OcrDebugRect.From(GetAreaRect(data)), + ResultRect = OcrDebugRect.From(data.ResultRect), + OcrText = data.GetOCR(), + TransText = data.TransString, + UseAutoColor = data.UseAutoColor, + AutoColors = autoColors, + Lines = lines, + TransBlocks = transBlocks, + }; + } + + private static OcrDebugTransBlock ConvertTransBlock(OCRDataManager.TransData transData) + { + var lines = new List(); + foreach(var line in transData.lineDataList) + { + lines.Add(ConvertLine(line)); + } + + return new OcrDebugTransBlock + { + Index = transData.index, + Trans = transData.trans, + IsTitle = transData.TitleData, + AngleType = transData.angleType.ToString(), + LineRect = OcrDebugRect.From(transData.lineRect), + SourceRect = OcrDebugRect.From(transData.SourceRect), + ViewRect = OcrDebugRect.From(transData.ViewRect), + ContentRect = OcrDebugRect.From(transData.ContentRect), + Lines = lines, + }; + } + + private static OcrDebugLine ConvertLine(OCRDataManager.LineData lineData) + { + var words = new List(); + for(int i = 0; i < lineData.wordList.Count; i++) + { + Rectangle wordRect = i < lineData.wordRectList.Count ? lineData.wordRectList[i] : Rectangle.Empty; + words.Add(new OcrDebugWord + { + Text = lineData.wordList[i], + Rect = OcrDebugRect.From(wordRect), + }); + } + + return new OcrDebugLine + { + GroupIndex = lineData.groupIndex, + LineString = lineData.lineString, + TransString = lineData.transString, + AngleType = lineData.angleType.ToString(), + LineRect = OcrDebugRect.From(lineData.lineRect), + Words = words, + TransWords = new List(lineData.transWordList), + }; + } + + private static Rectangle GetAreaRect(OCRDataManager.ResultData data) + { + try + { + if(data.SnapShot) + { + return FormManager.Instace.MyMainForm.MySettingManager.LastSnapShotRect; + } + + return FormManager.Instace.MyMainForm.GetOcrAreaProcessRect(data.Index); + } + catch(Exception ex) + { + Util.ShowLog($"OcrDebugSnapshotService: GetAreaRect failed - {ex.Message}"); + return Rectangle.Empty; + } + } + + public static string ToColorText(Color color) + { + return $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; + } + } +} diff --git a/MORT/Service/ProcessTranslateService/ProcessTranslateService.cs b/MORT/Service/ProcessTranslateService/ProcessTranslateService.cs index b73fc12..f554ab1 100644 --- a/MORT/Service/ProcessTranslateService/ProcessTranslateService.cs +++ b/MORT/Service/ProcessTranslateService/ProcessTranslateService.cs @@ -27,6 +27,19 @@ internal class ProcessTranslateService public bool ProcessingState => thread != null && thread.IsAlive; public int OcrProcessSpeed { get; set; } = 2000; //ocr 처리 딜레이 시간 + + //OCR 결과가 직전과 같을 때 번역창을 다시 그리는 최소 간격. + //내용이 같아도 창 기하가 바뀔 수 있어 완전히 멈추지는 않는다. + private const int IdleRepaintIntervalMs = 1000; + + //작업을 기다리는 도중 중단 요청이 왔는지 확인하는 간격 + private const int StopCheckIntervalMs = 50; + + //정지 요청 후 이 스레드가 끝나기를 기다리는 한도. + //저수준 키보드 훅 프로시저가 300ms 넘게 붙잡히면 윈도우가 훅을 제거해 + //이후 MORT 단축키가 전부 죽는다. 그래서 훅에서 온 요청은 더 짧게 기다린다. + public const int KeyHookJoinTimeoutMs = 250; + public const int DefaultJoinTimeoutMs = 3000; public bool ClipeBoardReady { get; private set; } = true; public bool DebugUnlockOCRSpeed @@ -56,6 +69,7 @@ public bool IsUseClipBoardFlag private readonly bool _isAvailableWinOCR; private readonly TranslationProcessInitializationService _initializationService; private readonly TranslationImageModelService _imageModelService; + private readonly MORT.Service.Debug.OcrDebugSnapshotService _debugSnapshotService; private OcrMethodType _ocrMethodType = OcrMethodType.None; private CancellationTokenSource _cts = new CancellationTokenSource(); @@ -77,9 +91,71 @@ public ProcessTranslateService(Form parent, TranslateResultMemoryService memoryS _imageModelService = new TranslationImageModelService( () => isEndFlag, () => isEndFlag = true); + _debugSnapshotService = Program.ServiceContainer?.GetService(typeof(MORT.Service.Debug.OcrDebugSnapshotService)) + as MORT.Service.Debug.OcrDebugSnapshotService; this.OnStopTranslate = OnStopTranslate; } + /// + /// 작업이 끝나기를 기다리되, 중단 요청이 오면 기다리기를 멈춘다. + /// 작업 자체를 취소하지는 못하지만, 여기서 계속 붙잡고 있으면 isEndFlag 를 확인할 기회가 없어 + /// 이 스레드가 끝나지 않는다. UI 는 thread.Join() 으로 이 스레드를 기다리는 중이라 같이 멈춘다. + /// + private TResult WaitForResult(Task task) + { + WaitForCompletion(task); + return task.Result; + } + + private void WaitForCompletion(Task task) + { + while (true) + { + bool completed; + try + { + completed = task.Wait(StopCheckIntervalMs); + } + catch (AggregateException e) when (e.InnerException is OperationCanceledException) + { + //작업이 취소로 끝난 것은 오류가 아니다. + //그대로 두면 아래 일반 예외 처리로 흘러가 정지할 때마다 오류창이 뜬다. + throw new OperationCanceledException(); + } + + if (completed) + { + return; + } + + if (isEndFlag) + { + throw new OperationCanceledException(); + } + } + } + + /// + /// 번역 스레드가 끝나기를 기다린다. 시간 안에 끝나지 않으면 false. + /// 시간 초과 시 호출부는 뒷작업을 진행하면 안 된다. 아직 살아있는 스레드와 설정 변경이 겹친다. + /// + private bool JoinThread(int timeoutMs) + { + if (thread == null || !thread.IsAlive) + { + return true; + } + + isEndFlag = true; + if (thread.Join(timeoutMs)) + { + return true; + } + + Util.ShowLog($"ProcessTranslateService: 번역 스레드가 {timeoutMs}ms 안에 끝나지 않았다"); + return false; + } + private string AdjustText(string text) { string result = text; @@ -173,7 +249,7 @@ private void MakeFinalOcrAndTrans(int index, OCRDataManager.ResultData ocrResult transTask = TransManager.Instace.StartTrans(currentOcr, _settingManager.NowTransType, ocrList); //번역 결과를 적용한다 - var transResult = transTask.Result; + var transResult = WaitForResult(transTask); if (ocrResultData != null) { @@ -343,10 +419,14 @@ public void DoTextToSpeach(string text) } /// - /// 실제 OCR 번역을 시작한다 + /// 실제 OCR 번역을 시작한다. + /// 이 메서드는 전용 스레드 위에서 끝까지 동기로 돌아야 한다. + /// async Task 로 두고 여기에 await 를 넣으면 스레드가 첫 await 에서 끝나버려 + /// thread.Join() 이 즉시 돌아오고 IdleState / ProcessingState 가 거짓을 보고한다. + /// 그러면 정지했다고 판단한 쪽이 아직 살아있는 파이프라인과 동시에 설정을 바꾼다. /// /// - private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessInitializationResult initialization) + private void DoTrans(OcrMethodType ocrMethodType, TranslationProcessInitializationResult initialization) { _cts.Cancel(); _cts.Dispose(); @@ -365,6 +445,8 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI string formerOcrString = ""; //바로 이전에 가져온 문장 ClipeBoardReady = true; int lastTick = 0; + //OCR 결과가 그대로일 때 다시 그리는 간격 + int lastIdleRepaintTick = 0; try { while (isEndFlag == false) @@ -412,7 +494,7 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI var task = OcrManager.Instace.ProcessGoogleAsync(imgDataList[j]); string currentOcr = ""; - var result = task.Result; + var result = WaitForResult(task); currentOcr = result.MainText; currentOcr = currentOcr.Replace("\r\n", "\n"); @@ -538,9 +620,9 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI { Util.CheckTimeSpan(false); - var task = _oneOcr.ConvertToTextAsync(imgDataList[j].data, imgDataList[j].channels, imgDataList[j].x, imgDataList[j].y, imgDataList[j].Clear).ConfigureAwait(false); + var task = _oneOcr.ConvertToTextAsync(imgDataList[j].data, imgDataList[j].channels, imgDataList[j].x, imgDataList[j].y, imgDataList[j].Clear).AsTask(); - var result = task.GetAwaiter().GetResult(); + var result = WaitForResult(task); if (result == null) { @@ -603,7 +685,8 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI var prepareTask = OcrManager.Instace.PrepareEasyOcrAsync(_settingManager.EasyOcrCode, false, "torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121"); - Task.WaitAll(prepareTask); + //pip 설치는 오래 걸린다. 중단 요청이 오면 기다리기를 멈춘다 + WaitForCompletion(prepareTask); Util.CheckTimeSpan(true); @@ -718,7 +801,7 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI if (_settingManager.NowTransType != SettingManager.TransType.db && formerOcrString.CompareTo(NowOcrString) != 0) { System.Threading.Tasks.Task test = TransManager.Instace.StartTrans(NowOcrString, _settingManager.NowTransType); - finalTransResult = test.Result; + finalTransResult = WaitForResult(test); } } @@ -740,6 +823,18 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI finalTransResult = _memoryService.CheckMemoryResult(finalTransResult); + //디버깅 : 이미지 인식 결과를 파일로 남긴다. + //오버레이는 아래 UpdateText 이후 실제로 그릴 때 최종값이 채워진다. + if (Form1.IsDebugSaveAnalysisResult && _debugSnapshotService != null) + { + _debugSnapshotService.CaptureOcrResult( + OCRDataManager.Instace.GetData(), + _settingManager.NowSkin, + _settingManager.OCRType, + _settingManager.NowTransType, + NowOcrString, + finalTransResult); + } if (_settingManager.NowSkin == SettingManager.Skin.dark && FormManager.Instace.MyBasicTransForm != null) { @@ -796,14 +891,23 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI else { //이전과 같아서 그래픽만 갱신함. - if (_settingManager.NowSkin == SettingManager.Skin.layer && FormManager.Instace.MyLayerTransForm != null) + //같은 내용을 다시 그리는 것은 낭비지만, 데이터가 그대로여도 창 기하가 + //바뀌는 경우(OCR 영역 이동, 대상 창 이동)가 있어 완전히 멈출 수는 없다. + //그래서 매번이 아니라 일정 간격으로만 다시 그린다. + int idleDiff = Math.Abs(System.Environment.TickCount - lastIdleRepaintTick); + if (idleDiff >= IdleRepaintIntervalMs) { - FormManager.Instace.MyLayerTransForm.UpdatePaint(); - } + lastIdleRepaintTick = System.Environment.TickCount; - if (_settingManager.NowSkin == SettingManager.Skin.over && FormManager.Instace.MyOverTransForm != null) - { - FormManager.Instace.MyOverTransForm.UpdatePaint(); + if (_settingManager.NowSkin == SettingManager.Skin.layer && FormManager.Instace.MyLayerTransForm != null) + { + FormManager.Instace.MyLayerTransForm.UpdatePaint(); + } + + if (_settingManager.NowSkin == SettingManager.Skin.over && FormManager.Instace.MyOverTransForm != null) + { + FormManager.Instace.MyOverTransForm.UpdatePaint(); + } } if (isOnce) @@ -833,7 +937,20 @@ private async Task DoTransAsync(OcrMethodType ocrMethodType, TranslationProcessI } catch (Exception e) { - MessageBox.Show($"{e.Message} / {e.StackTrace}"); + //이 스레드에서 MessageBox 를 띄우면 이 스레드에 모달 루프가 생겨 스레드가 끝나지 않는다. + //UI 가 thread.Join() 으로 이 스레드를 기다리는 중이면 서로 못 빠져나온다. + //알림은 UI 스레드에 넘기고 이 스레드는 그대로 끝낸다. + string message = $"{e.Message} / {e.StackTrace}"; + Util.ShowLog(message); + + try + { + _parent.BeginInvoke((Action)(() => MessageBox.Show(message))); + } + catch (Exception reportException) + { + Util.ShowLog($"ProcessTranslateService: failed to report error - {reportException.Message}"); + } } } @@ -848,7 +965,7 @@ private void StartTranslationThread(OcrMethodType ocrMethodType) return; } - thread = new Thread(() => DoTransAsync(ocrMethodType, initialization)); + thread = new Thread(() => DoTrans(ocrMethodType, initialization)); thread.Start(); } @@ -914,50 +1031,61 @@ private void SaveOcrResult(string transText, string ocrText) public void ProcessTrans(OcrMethodType ocrMethodType) //번역 시작 쓰레드 { - if (thread != null && thread.IsAlive == true) + if (!JoinThread(DefaultJoinTimeoutMs)) { - isEndFlag = true; - thread.Join(); - - isEndFlag = false; + //이전 스레드가 아직 살아있다. 여기서 새로 시작하면 둘이 같이 돌게 된다. + //isEndFlag 는 되돌리지 않는다. 되돌리면 그 스레드가 계속 돈다. + return; } + isEndFlag = false; StartTranslationThread(ocrMethodType); } - public void StopTranslate() + public void StopTranslate(int joinTimeoutMs = DefaultJoinTimeoutMs) { _cts.Cancel(); _cts.Dispose(); _cts = new CancellationTokenSource(); TransManager.Instace.StopTrans(); - if (thread != null && thread.IsAlive == true) + + if (JoinThread(joinTimeoutMs)) { - isEndFlag = true; - thread.Join(); thread = null; + isEndFlag = false; } - - isEndFlag = false; + //시간 초과면 isEndFlag 를 true 로 남겨둔다. + //되돌리면 아직 살아있는 스레드가 정지 요청을 못 보고 계속 번역한다. } /// /// 작업을 처리한 후 번역 다시 시작 - 기존 번역이 없으면 무시 /// /// - public bool PauseAndRestartTranslate(Action callback, OcrMethodType ocrMethodType = OcrMethodType.None) + /// + /// 돌고 있던 번역을 멈췄다가 다시 시작했으면 true. + /// 제한 시간 안에 멈추지 못하면 callback 을 실행하지 않고 false 를 돌려준다. + /// 호출부는 "번역이 돌고 있지 않았다"와 같게 취급하게 되는데, + /// 그 경로가 대개 ProcessTrans 로 이어져 정지를 한 번 더 시도하므로 회복될 여지가 있다. + /// + public bool PauseAndRestartTranslate(Action callback, OcrMethodType ocrMethodType = OcrMethodType.None, + int joinTimeoutMs = DefaultJoinTimeoutMs) { _cts.Cancel(); _cts.Dispose(); _cts = new CancellationTokenSource(); TransManager.Instace.StopTrans(); - bool requireRestart = false; - if (thread != null && thread.IsAlive == true) + bool requireRestart = thread != null && thread.IsAlive; + if (requireRestart) { - requireRestart = true; - isEndFlag = true; - thread.Join(); + if (!JoinThread(joinTimeoutMs)) + { + //번역 스레드가 아직 살아있다. 여기서 callback 을 실행하면 + //그 스레드가 쓰고 있는 설정과 캡쳐 영역을 동시에 바꾸게 된다. + //isEndFlag 도 되돌리지 않는다. + return false; + } isEndFlag = false; } diff --git a/MORT/TransFormLayer.cs b/MORT/TransFormLayer.cs index faf40c6..9c1d559 100644 --- a/MORT/TransFormLayer.cs +++ b/MORT/TransFormLayer.cs @@ -359,11 +359,13 @@ private void DoUpdatePaint() // Get handle to the new bitmap and select it into the current // device context. - Bitmap bitmap = new Bitmap(this.Width, this.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + //Bitmap 과 Graphics 는 네이티브 GDI+ 자원을 들고 있어서 놓아주지 않으면 + //페인트마다 파이널라이저까지 남는다. 아래 GC.Collect() 를 없애려면 여기가 먼저다. + using Bitmap bitmap = new Bitmap(this.Width, this.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); using(Graphics gF = Graphics.FromImage(bitmap)) + using(SolidBrush brush = new SolidBrush(Color.FromArgb(0, 240, 248, 255))) { - SolidBrush brush = new SolidBrush(Color.FromArgb(0, 240, 248, 255)); gF.FillRectangle(brush, 0, 0, bitmap.Width, bitmap.Height); } @@ -379,7 +381,8 @@ private void DoUpdatePaint() blend.SourceConstantAlpha = 255; blend.AlphaFormat = AC_SRC_ALPHA; - Graphics g = Graphics.FromImage(bitmap); + //페인트마다 만들어지므로 놓아주지 않으면 GDI+ 자원이 계속 쌓인다 + using Graphics g = Graphics.FromImage(bitmap); Color OutlineForeColor = FormManager.Instace.MyMainForm.MySettingManager.OutLineColor1; float OutlineWidth = 2; using(GraphicsPath gp = new GraphicsPath()) @@ -507,7 +510,10 @@ private void DoUpdatePaint() DeleteObject(hBitmap); } DeleteDC(memDc); - GC.Collect(); + + //여기서 GC.Collect() 를 돌리지 않는다. + //페인트마다 전체 블로킹 GC 가 UI 스레드에서 돌아 번역 중 끊김의 원인이 된다. + //위에서 Bitmap / Graphics / 브러시를 직접 놓아주고 GDI 핸들도 정리하므로 강제 수집이 필요 없다. /* IntPtr screenDc = GetDC(IntPtr.Zero); diff --git a/MORT/TransFormOver.cs b/MORT/TransFormOver.cs index 5636e0d..c4c6e33 100644 --- a/MORT/TransFormOver.cs +++ b/MORT/TransFormOver.cs @@ -1,5 +1,7 @@ using R3; using System; +using MORT.Model.Debug; +using MORT.Service.Debug; using MORT.Service.Overlay; using System.Collections.Generic; using System.ComponentModel; @@ -504,6 +506,18 @@ public void UpdateTransform() UpdatePaint(); } + private OcrDebugSnapshotService _debugSnapshotService; + private OcrDebugSnapshotService DebugSnapshotService + => _debugSnapshotService ??= Program.ServiceContainer?.GetService(typeof(OcrDebugSnapshotService)) as OcrDebugSnapshotService; + + private readonly record struct OverlayDrawColors( + Color Font, + Color Background, + bool UsesAutomaticColor, + bool Corrected, + Color Outline1, + Color Outline2); + private sealed class OverlayRenderBlock { public OCRDataManager.ResultData TargetData; @@ -535,6 +549,13 @@ private void AddText(GraphicsPath gp, Graphics g, Font textFont, Rectangle recta List blocks = BuildRenderBlocks(); ResolveBlockCollisions(blocks); + //디버깅 : 실제로 그린 값 그대로를 모아 스냅샷에 넘긴다. + var debugService = DebugSnapshotService; + List debugBlocks = + Form1.IsDebugSaveAnalysisResult && debugService != null && debugService.IsWaitingOverlay + ? new List() + : null; + foreach(var block in blocks) { using StringFormat blockFormat = (StringFormat)sf.Clone(); @@ -546,6 +567,8 @@ private void AddText(GraphicsPath gp, Graphics g, Font textFont, Rectangle recta block.TransData.ViewRect = block.ViewRect; block.TransData.ContentRect = Rectangle.Empty; Util.ShowLog($"Overlay block clipped: {block.TransData.trans}"); + debugBlocks?.Add(MakeDebugOverlayBlock( + g, block, null, Rectangle.Empty, 0f, 0f, 0f, false, blockFormat, outlineColor1, outlineColor2, true)); continue; } @@ -581,6 +604,7 @@ private void AddText(GraphicsPath gp, Graphics g, Font textFont, Rectangle recta $"font={fontSize:0.00}, sourceFont={GetSourceFontPointSize(g, block):0.00}, text={block.TransData.trans}"); } + bool drawBackground = false; if(_isStart && Form1.IsDebugShowWordArea) { using var debugBrush = new SolidBrush(Color.FromArgb(90, 0, 0, 0)); @@ -588,6 +612,7 @@ private void AddText(GraphicsPath gp, Graphics g, Font textFont, Rectangle recta } else if(_isStart && FormManager.Instace.MyMainForm.MySettingManager.NowIsUseBackColor) { + drawBackground = true; Color background = FormManager.Instace.MyMainForm.MySettingManager.BackgroundColor; if(block.TargetData.UseAutoColor && AdvencedOptionManager.OverlayAutoBackgroundColor @@ -603,11 +628,83 @@ private void AddText(GraphicsPath gp, Graphics g, Font textFont, Rectangle recta DrawWrappedText(g, block, renderFont, blockFormat, outlineColor1, outlineWidth1, outlineColor2, outlineWidth2); - if(!DoesTextFit(g, block.TransData.trans, renderFont, contentRect, blockFormat, block.VerticalMode)) + bool clipped = !DoesTextFit(g, block.TransData.trans, renderFont, contentRect, blockFormat, block.VerticalMode); + if(clipped) { Util.ShowLog($"Overlay block clipped at minimum font: {block.TransData.trans}"); } - } + + debugBlocks?.Add(MakeDebugOverlayBlock( + g, block, renderFont, contentRect, fontSize, preferredSize, minimumSize, drawBackground, + blockFormat, outlineColor1, outlineColor2, clipped)); + } + + //스냅샷 저장은 DoUpdatePaint 가 화면 반영까지 끝낸 뒤에 한다. + //여기서 바로 넘기면 GetHbitmap / UpdateLayeredWindow 시간을 담을 수 없다. + _pendingDebugBlocks = debugBlocks; + } + + //AddText 가 모은 디버깅 블록. DoUpdatePaint 가 마지막에 넘긴다. + private List _pendingDebugBlocks; + + /// + /// 디버깅 스냅샷용 : 이 블록을 실제로 어떻게 그렸는지 최종값을 모은다. + /// + private OcrDebugOverlayBlock MakeDebugOverlayBlock( + Graphics g, + OverlayRenderBlock block, + Font renderFont, + Rectangle contentRect, + float fontSize, + float preferredSize, + float minimumSize, + bool drawBackground, + StringFormat blockFormat, + Color outlineColor1, + Color outlineColor2, + bool clipped) + { + OverlayDrawColors colors = ResolveDrawColors( + block.TargetData, block.ColorIndex, block.TransData.trans, outlineColor1, outlineColor2); + + var wrappedLines = new List(); + float lineAdvance = 0f; + if(renderFont != null) + { + wrappedLines = GetWrappedLinesByAddString( + g, block.TransData.trans, renderFont, contentRect.Width, contentRect.Height, blockFormat, block.VerticalMode); + lineAdvance = renderFont.GetHeight(g) * 1.2f; + } + + return new OcrDebugOverlayBlock + { + AreaIndex = block.TargetData.Index, + ColorIndex = block.ColorIndex, + Text = block.TransData.trans, + IsTitle = block.TransData.TitleData, + VerticalMode = block.VerticalMode, + CaptureRect = OcrDebugRect.From(block.CaptureRect), + SourceRect = OcrDebugRect.From(block.SourceRect), + ViewRect = OcrDebugRect.From(block.ViewRect), + ContentRect = OcrDebugRect.From(contentRect), + FontFamily = renderFont?.FontFamily.Name ?? "", + FontStyle = renderFont?.Style.ToString() ?? "", + FontSize = fontSize, + PreferredFontSize = preferredSize, + MinimumFontSize = minimumSize, + SourceFontSize = GetSourceFontPointSize(g, block), + FontColor = OcrDebugSnapshotService.ToColorText(colors.Font), + BackgroundColor = OcrDebugSnapshotService.ToColorText(colors.Background), + DrawBackground = drawBackground, + UseAutoColor = colors.UsesAutomaticColor, + ContrastCorrected = colors.Corrected, + UseOutline = AdvencedOptionManager.OverlayUseFontOutline, + OutlineColor1 = OcrDebugSnapshotService.ToColorText(colors.Outline1), + OutlineColor2 = OcrDebugSnapshotService.ToColorText(colors.Outline2), + WrappedLines = wrappedLines, + LineAdvance = lineAdvance, + Clipped = clipped, + }; } private List BuildRenderBlocks() @@ -961,15 +1058,34 @@ private void TryExpandForFont(Graphics g, OverlayRenderBlock block, List rect.Height : bounds.Width + outlinePadding > rect.Width) + if(bounds.Width <= 0 && bounds.Height <= 0) + { + //공백뿐인 줄은 그려지는 것이 없다 + continue; + } + + bounds.Inflate(outlinePadding, outlinePadding); + if(bounds.Left < rect.Left || bounds.Right > rect.Right + || bounds.Top < rect.Top || bounds.Bottom > rect.Bottom) { return false; } } - float occupied = maximumCrossSize + Math.Max(0, lines.Count - 1) * lineAdvance + outlinePadding; - if(vertical ? occupied > rect.Width : occupied > rect.Height) - { - return false; - } return true; } + /// + /// 줄 하나가 놓이는 칸. 판정과 그리기가 같은 자리를 써야 한다. + /// + private static Rectangle GetLineRect(Rectangle contentRect, int index, float lineAdvance, bool vertical) + { + return vertical + ? new Rectangle( + contentRect.Right - (int)Math.Ceiling((index + 1) * lineAdvance), + contentRect.Top, + (int)Math.Ceiling(lineAdvance), + contentRect.Height) + : new Rectangle( + contentRect.Left, + contentRect.Top + (int)Math.Floor(index * lineAdvance), + contentRect.Width, + (int)Math.Ceiling(lineAdvance)); + } + private void DrawWrappedText(Graphics g, OverlayRenderBlock block, Font font, StringFormat format, Color outlineColor1, int outlineWidth1, Color outlineColor2, int outlineWidth2) { List lines = GetWrappedLinesByAddString(g, block.TransData.trans, font, block.ContentRect.Width, block.ContentRect.Height, format, block.VerticalMode); float advance = font.GetHeight(g) * 1.2f; for(int index = 0; index < lines.Count; index++) { - Rectangle lineRect = block.VerticalMode - ? new Rectangle(block.ContentRect.Right - (int)Math.Ceiling((index + 1) * advance), block.ContentRect.Top, (int)Math.Ceiling(advance), block.ContentRect.Height) - : new Rectangle(block.ContentRect.Left, block.ContentRect.Top + (int)Math.Floor(index * advance), block.ContentRect.Width, (int)Math.Ceiling(advance)); + Rectangle lineRect = GetLineRect(block.ContentRect, index, advance, block.VerticalMode); lineRect = Rectangle.Intersect(lineRect, block.ContentRect); DrawStringWithOutline2(g, lines[index], font, lineRect, format, block.TargetData, block.ColorIndex, outlineColor1, outlineWidth1, outlineColor2, outlineWidth2); } @@ -1397,7 +1536,10 @@ private void GetAutoOutlineColors(Color fontColor, out Color outline1, out Color } // 2중 아웃라인 + 본문 텍스트를 DrawString으로 그리는 함수 - private void DrawStringWithOutline2(Graphics g, string text, Font font, Rectangle rect, StringFormat sf, OCRDataManager.ResultData targetData, int colorIdx, Color outlineColor1, int outlineWidth1, Color outlineColor2, int outlineWidth2) + /// + /// 실제로 칠하는 색을 정한다. 디버깅 스냅샷도 같은 값을 기록해야 하므로 분리해두었다. + /// + private OverlayDrawColors ResolveDrawColors(OCRDataManager.ResultData targetData, int colorIdx, string text, Color outlineColor1, Color outlineColor2) { SettingManager setting = FormManager.Instace.MyMainForm.MySettingManager; Color fontColor = setting.TextColor; @@ -1438,6 +1580,15 @@ private void DrawStringWithOutline2(Graphics g, string text, Font font, Rectangl GetAutoOutlineColors(fontColor, out outlineColor1, out outlineColor2); } + return new OverlayDrawColors(fontColor, backgroundColor, usesAutomaticColor, corrected, outlineColor1, outlineColor2); + } + + private void DrawStringWithOutline2(Graphics g, string text, Font font, Rectangle rect, StringFormat sf, OCRDataManager.ResultData targetData, int colorIdx, Color outlineColor1, int outlineWidth1, Color outlineColor2, int outlineWidth2) + { + OverlayDrawColors colors = ResolveDrawColors(targetData, colorIdx, text, outlineColor1, outlineColor2); + Color fontColor = colors.Font; + outlineColor1 = colors.Outline1; + outlineColor2 = colors.Outline2; // GraphicsPath로 텍스트 경로 생성 using(GraphicsPath path = new GraphicsPath()) @@ -1473,6 +1624,82 @@ private void DrawStringWithOutline2(Graphics g, string text, Font font, Rectangl } } + //측정 결과를 재사용하기 위한 키. + //폰트 크기가 같으면 결과가 완전히 같은 호출이 폰트 이분탐색 때문에 대량으로 중복된다. + //세로 여부와 StringFormat 플래그가 빠지면 다른 방향의 결과를 잘못 돌려주므로 반드시 포함한다. + private readonly record struct TextMeasureKey( + string Text, + string FontFamily, + FontStyle FontStyle, + float EmSize, + bool Vertical, + StringFormatFlags FormatFlags, + StringAlignment Alignment); + + private readonly record struct TextWrapKey( + string Text, + string FontFamily, + FontStyle FontStyle, + float EmSize, + int MaxWidth, + int MaxHeight, + bool Vertical, + StringFormatFlags FormatFlags, + StringAlignment Alignment); + + //페인트 한 번 동안만 살아 있는 캐시. AddText 진입에서 만들고 끝나면 버린다. + private Dictionary _fitSizeCache; + private Dictionary> _wrapCache; + + //디버깅 계측용. 디버깅 저장이 꺼져 있으면 그냥 증가만 하고 쓰이지 않는다. + private int _measureCacheHit; + private int _measureCacheMiss; + + /// + /// 줄바꿈 판단에 쓰는 길이. 세로는 높이, 가로는 너비를 본다. + /// + private float GetFitSize(Graphics g, string text, Font font, float emSize, StringFormat sf, bool isVertical) + { + TextMeasureKey key = default; + bool useCache = _fitSizeCache != null; + if(useCache) + { + key = new TextMeasureKey(text, font.FontFamily.Name, font.Style, emSize, isVertical, sf.FormatFlags, sf.Alignment); + if(_fitSizeCache.TryGetValue(key, out float cached)) + { + _measureCacheHit++; + return cached; + } + + _measureCacheMiss++; + } + + float result; + using(GraphicsPath path = new GraphicsPath()) + { + path.AddString(text, font.FontFamily, (int)font.Style, emSize, new Point(0, 0), sf); + RectangleF bounds = path.GetBounds(); + if(isVertical) + { + // 세로 모드는 높이로 판단 + result = bounds.Height; + } + else + { + // 가로 모드: MeasureString과 GraphicsPath 중 더 큰 값 사용 + SizeF ms = g.MeasureString(text, font); + result = Math.Max(bounds.Width, ms.Width); + } + } + + if(useCache) + { + _fitSizeCache[key] = result; + } + + return result; + } + private List GetWrappedLinesByAddString(Graphics g, string text, Font font, int maxWidth, int maxHeight, StringFormat sf, bool isVertical) { List lines = new List(); @@ -1480,6 +1707,22 @@ private List GetWrappedLinesByAddString(Graphics g, string text, Font fo return lines; float emSize = g.DpiY * font.SizeInPoints / 72f; + + TextWrapKey wrapKey = default; + bool useWrapCache = _wrapCache != null; + if(useWrapCache) + { + wrapKey = new TextWrapKey(text, font.FontFamily.Name, font.Style, emSize, + maxWidth, maxHeight, isVertical, sf.FormatFlags, sf.Alignment); + if(_wrapCache.TryGetValue(wrapKey, out List cachedLines)) + { + _measureCacheHit++; + return cachedLines; + } + + _measureCacheMiss++; + } + float fudge = font.Size * 1.2f; // 픽셀 여유 string[] originalLines = text.Replace("\r\n", "\n").Split('\n'); @@ -1488,39 +1731,27 @@ private List GetWrappedLinesByAddString(Graphics g, string text, Font fo string remaining = originalLine; while(!string.IsNullOrEmpty(remaining)) { - int lastFit = 0; - for(int i = 1; i <= remaining.Length; i++) + // 글자를 더할수록 길이는 줄어들 수 없으므로 들어가는 길이는 항상 앞쪽 구간이다. + // 1부터 하나씩 재면 한 줄마다 글자 수만큼 측정하게 되고, 이 함수가 + // 폰트 이분탐색 안에서 수십 번 반복되어 페인트 한 번이 통째로 느려진다. + // 결과는 같고 측정 횟수만 줄이도록 이분 탐색으로 찾는다. + int low = 0; + int high = remaining.Length; + while(low < high) { - string sub = remaining.Substring(0, i); - float width; - if(isVertical) + int middle = (low + high + 1) / 2; + if(GetFitSize(g, remaining.Substring(0, middle), font, emSize, sf, isVertical) + > (isVertical ? maxHeight - fudge : maxWidth - fudge)) { - // 세로 모드: GraphicsPath 기준 - using(GraphicsPath path = new GraphicsPath()) - { - path.AddString(sub, font.FontFamily, (int)font.Style, emSize, new Point(0, 0), sf); - RectangleF bounds = path.GetBounds(); - width = bounds.Height; // 세로 모드는 높이로 판단 - } - if(width > maxHeight - fudge) - break; + high = middle - 1; } else { - // 가로 모드: MeasureString과 GraphicsPath 중 더 큰 값 사용 - using(GraphicsPath path = new GraphicsPath()) - { - path.AddString(sub, font.FontFamily, (int)font.Style, emSize, new Point(0, 0), sf); - RectangleF bounds = path.GetBounds(); - SizeF ms = g.MeasureString(sub, font); - width = Math.Max(bounds.Width, ms.Width); - } - if(width > maxWidth - fudge) - break; + low = middle; } - - lastFit = i; } + + int lastFit = low; if(lastFit == 0) lastFit = 1; lines.Add(remaining.Substring(0, lastFit)); if(lastFit >= remaining.Length) @@ -1528,12 +1759,23 @@ private List GetWrappedLinesByAddString(Graphics g, string text, Font fo remaining = remaining.Substring(lastFit).TrimStart(); } } + + if(useWrapCache) + { + _wrapCache[wrapKey] = lines; + } + return lines; } private bool isLockPaint = false; private bool _forceLock; + //페인트 구간별 시간(ms). 디버깅 저장이 켜진 동안에만 채워진다. + private double _checkSizeMs; + private double _layoutAndDrawMs; + private double _presentMs; + public void UpdatePaint() { if(_forceLock) @@ -1541,14 +1783,37 @@ public void UpdatePaint() return; } - if(this.InvokeRequired) + if(IsDisposed || isDestroyFormFlag) { - Action action = () => DoUpdatePaint(); - this.BeginInvoke(action); + return; } - else + + //핸들이 없으면 InvokeRequired 가 false 라 번역 스레드가 그대로 그리게 되고, + //그리는 중 this.Handle 을 읽으면 핸들이 그 스레드에 생긴다. + //그러면 오버레이 메시지 펌프가 메시지를 돌리지 않는 스레드에 묶여 + //이후 이 폼에 대한 Invoke 가 영영 풀리지 않는다. + if(!IsHandleCreated && FormManager.Instace.MyMainForm is Form mainForm && mainForm.InvokeRequired) { - DoUpdatePaint(); + return; + } + + try + { + if(this.InvokeRequired) + { + Action action = () => DoUpdatePaint(); + this.BeginInvoke(action); + } + else + { + DoUpdatePaint(); + } + } + catch(Exception ex) + { + //정리 중인 폼에 BeginInvoke 하면 예외가 난다. + //번역 스레드까지 올라가면 스레드가 그대로 끝나버려 Join 이 풀리지 않으므로 여기서 막는다. + Util.ShowLog($"TransFormOver.UpdatePaint failed - {ex.Message}"); } } @@ -1561,25 +1826,53 @@ private void DoUpdatePaint() } isLockPaint = true; - CheckSizeAndLocation(); - Util.ShowLog("Update paint + " + makeIndex); + + //측정 결과는 이 페인트 안에서만 재사용한다. + //폰트 이분탐색이 같은 문자열을 같은 크기로 반복해서 재기 때문에 중복이 대부분이다. + _fitSizeCache = new Dictionary(); + _wrapCache = new Dictionary>(); + _measureCacheHit = 0; + _measureCacheMiss = 0; + + //계측은 디버깅 저장이 켜진 동안에만 한다 + bool measureTiming = Form1.IsDebugSaveAnalysisResult; + var paintWatch = measureTiming ? System.Diagnostics.Stopwatch.StartNew() : null; + _checkSizeMs = 0; + _layoutAndDrawMs = 0; + _presentMs = 0; // Get device contexts - IntPtr screenDc = GetDC(IntPtr.Zero); - IntPtr memDc = CreateCompatibleDC(screenDc); + IntPtr screenDc = IntPtr.Zero; + IntPtr memDc = IntPtr.Zero; IntPtr hBitmap = IntPtr.Zero; IntPtr hOldBitmap = IntPtr.Zero; try { + var sectionWatch = measureTiming ? System.Diagnostics.Stopwatch.StartNew() : null; + CheckSizeAndLocation(); + if(measureTiming) + { + _checkSizeMs = sectionWatch.Elapsed.TotalMilliseconds; + } + + Util.ShowLog("Update paint + " + makeIndex); + + screenDc = GetDC(IntPtr.Zero); + memDc = CreateCompatibleDC(screenDc); + if(bitmap == null || bitmap.Width != this.Width || bitmap.Height != Height) { + //Bitmap 은 네이티브 GDI+ 자원을 들고 있어서 놓아주지 않으면 파이널라이저까지 남는다. + //OCR 영역을 끌면 창 크기가 계속 바뀌어 큰 비트맵이 매번 새로 생기므로, + //여기서 안 버리면 GDI+ 자원이 말라 그리기가 실패하고 창이 검게 남는다. + bitmap?.Dispose(); bitmap = new Bitmap(this.Width, this.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); } using(Graphics gF = Graphics.FromImage(bitmap)) + using(SolidBrush brush = new SolidBrush(Color.FromArgb(0, 240, 248, 255))) { - SolidBrush brush = new SolidBrush(Color.FromArgb(0, 240, 248, 255)); gF.FillRectangle(brush, 0, 0, bitmap.Width, bitmap.Height); } @@ -1595,7 +1888,8 @@ private void DoUpdatePaint() blend.SourceConstantAlpha = 255; blend.AlphaFormat = AC_SRC_ALPHA; - Graphics g = Graphics.FromImage(bitmap); + //페인트마다 만들어지므로 놓아주지 않으면 GDI+ 자원이 계속 쌓인다 + using Graphics g = Graphics.FromImage(bitmap); Color OutlineForeColor = FormManager.Instace.MyMainForm.MySettingManager.OutLineColor1; float OutlineWidth = 2; using(GraphicsPath gp = new GraphicsPath()) @@ -1619,7 +1913,12 @@ private void DoUpdatePaint() g.PixelOffsetMode = PixelOffsetMode.HighQuality; + var layoutWatch = measureTiming ? System.Diagnostics.Stopwatch.StartNew() : null; AddText(gp, g, textFont, rectangle, sf); + if(measureTiming) + { + _layoutAndDrawMs = layoutWatch.Elapsed.TotalMilliseconds; + } if(!_isStart) { @@ -1648,6 +1947,8 @@ private void DoUpdatePaint() g.Clear(Color.FromArgb(0)); } + var presentWatch = measureTiming ? System.Diagnostics.Stopwatch.StartNew() : null; + hBitmap = bitmap.GetHbitmap(Color.FromArgb(0)); //Set the fact that background is transparent hOldBitmap = SelectObject(memDc, hBitmap); @@ -1672,6 +1973,10 @@ private void DoUpdatePaint() ); //SetWindowPos(this.Handle, HWND_TOPMOST, 0, 0, 0, 0, TOPMOST_FLAGS); + if(measureTiming) + { + _presentMs = presentWatch.Elapsed.TotalMilliseconds; + } } finally { @@ -1683,10 +1988,42 @@ private void DoUpdatePaint() DeleteObject(hBitmap); } DeleteDC(memDc); - GC.Collect(); - } - isLockPaint = false; + //여기서 GC.Collect() 를 돌리지 않는다. + //페인트마다 전체 블로킹 GC 가 UI 스레드에서 돌아 번역 중 끊김의 원인이 된다. + //이 함수가 만드는 Bitmap / Graphics / 브러시는 위에서 직접 놓아주고 + //GDI 핸들은 DeleteObject / DeleteDC 로 정리하므로 강제 수집이 필요 없다. + + _fitSizeCache = null; + _wrapCache = null; + + //화면 반영까지 끝난 뒤에 디버깅 스냅샷을 넘긴다. + //중간 return 으로 빠져나온 경우에도 모아둔 블록은 버리고 대기 상태를 풀어야 한다. + if(_pendingDebugBlocks != null) + { + var timing = new OcrDebugPaintTiming + { + TotalMs = paintWatch?.Elapsed.TotalMilliseconds ?? 0, + CheckSizeMs = _checkSizeMs, + LayoutAndDrawMs = _layoutAndDrawMs, + PresentMs = _presentMs, + MeasureCacheHit = _measureCacheHit, + MeasureCacheMiss = _measureCacheMiss, + }; + + DebugSnapshotService?.CompleteOverlay( + Bounds, + FormManager.Instace.MyMainForm.MySettingManager.NowIsUseBackColor, + _pendingDebugBlocks, + timing); + + _pendingDebugBlocks = null; + } + + //중간 return 이나 예외로 빠져나가도 잠금은 반드시 푼다. + //풀리지 않으면 이후 모든 갱신이 맨 위에서 막혀 오버레이가 멈춘 것처럼 보인다. + isLockPaint = false; + } } diff --git a/docs/wiki/index.html b/docs/wiki/index.html index faf1db8..02e8b82 100644 --- a/docs/wiki/index.html +++ b/docs/wiki/index.html @@ -59,7 +59,7 @@

기능 설명 feature-content.json · 파일 설명 file-overrides.json · 자동 생성 update-wiki.ps1
- +