Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
87059d7
fix: overhaul formula detection pipeline — reduce false positives and…
claude Apr 4, 2026
ec93dc2
feat: wire CharacterParagraphBuilder as primary formula evidence prov…
claude Apr 4, 2026
19a1731
feat: two-tier confidence-based formula protection with LaTeX reconst…
claude Apr 4, 2026
9cb5c76
feat: soft-protect implicit tuples + quality-feedback retry loop (Pha…
claude Apr 5, 2026
bcd1921
refactor: simplify soft-span validation and formula detection
xiaocang Apr 5, 2026
12af4f5
feat: implement cache clearing functionality in TranslationManager an…
xiaocang Apr 5, 2026
4a40964
feat: add quality gate for formula-aware text reconstruction with Fal…
xiaocang Apr 6, 2026
327103b
refactor: extract ScrollHelper and use IClassFixture for PopButtonSel…
xiaocang Apr 6, 2026
c129251
feat: line-aware font fitting and per-line rendering for MuPdf PDF ex…
xiaocang Apr 6, 2026
a8b05be
feat: unified retry page layout to prevent block overlap in MuPdf PDF…
xiaocang Apr 6, 2026
b62065e
feat: enhance formula rendering by normalizing LaTeX markup for PDF e…
xiaocang Apr 6, 2026
1a11160
feat: add ToUnicode CMap for text extractability and improve tuple fo…
xiaocang Apr 7, 2026
49b512d
refactor: extract PageBlockLayoutPlanner for unified page layout (Gro…
xiaocang Apr 7, 2026
8ef6aee
fix: refine formula detection and preservation across translation pip…
xiaocang Apr 8, 2026
0a06d77
feat: add local debug tools for PDF rendering and long-doc CLI
xiaocang Apr 8, 2026
74cf613
refactor: simplify local debug CLI and share PDF page selection logic
xiaocang Apr 8, 2026
85ec8ea
fix: accept LaTeX-equivalent tuple forms in exact-span validation
xiaocang Apr 9, 2026
961e6de
fix: preserve formulas, tables, and numeric data verbatim in PDF export
xiaocang Apr 10, 2026
7d46940
feat: two-pass long-doc translation with glossary + preservation hints
xiaocang Apr 11, 2026
47d3e38
feat: TATR cell-level table preservation + page-scoped glossary injec…
xiaocang Apr 11, 2026
d6f6d99
fix: group PdfPig text lines by baseline, not glyph top
xiaocang Apr 11, 2026
04bb25a
fix: address PR #119 Copilot review comments
xiaocang Apr 11, 2026
9dd9047
ci: download ONNX layout + TATR models in WinUI Unit Tests job
xiaocang Apr 11, 2026
c7f5cae
ci: also download Noto Sans CJK SC font for WinUI Unit Tests
xiaocang Apr 11, 2026
f869341
Revert "ci: also download Noto Sans CJK SC font for WinUI Unit Tests"
xiaocang Apr 11, 2026
80888d4
Revert "ci: download ONNX layout + TATR models in WinUI Unit Tests job"
xiaocang Apr 11, 2026
982f789
Fix page2 formula classification CI regressions
xiaocang Apr 11, 2026
1ec795a
Stabilize page2 WinUI tests in CI
xiaocang Apr 11, 2026
093cbee
Stabilize long-doc UI automation mode switch
xiaocang Apr 12, 2026
8b65ee1
Simplify formula pipeline and fix soft-only char-level bypass
xiaocang Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,62 @@ dotnet test tests/Easydict.TranslationService.Tests --filter "FullyQualifiedName
dotnet test tests/Easydict.WinUI.Tests --logger "console;verbosity=minimal"
```

## Local Debug Tools

### PDF -> pics
- Purpose: render a PDF into per-page images for local inspection/debugging, using the repo's MuPDF-based helper.
- Tool: `dotnet/tools/PdfToImages/`
- Typical usage:
```bash
dotnet run --project dotnet/tools/PdfToImages -- --input <file.pdf>
dotnet run --project dotnet/tools/PdfToImages -- --input <file.pdf> --output-dir <dir> --dpi 144 --format png
dotnet run --project dotnet/tools/PdfToImages -- --input <file.pdf> --page 2
dotnet run --project dotnet/tools/PdfToImages -- --input <file.pdf> --page-range 2-4,7
```
- Notes:
- Default output directory is `<pdf-name>_pages` beside the source PDF.
- Supported formats are `png` and `jpg`.
- `--page` exports a single page; `--page-range` supports comma-separated pages/ranges like `1-3,5`.
- This is a developer utility, not a user-facing packaged feature.

### Local Long-Doc Translation CLI
- Purpose: locally debug the long-document translation pipeline from command line without going through the GUI.
- Wrapper script: `scripts/translate-long-doc.ps1`
- Underlying entry: `dotnet/src/Easydict.WinUI/Program.cs` + `Services/LongDocumentCliCommand.cs`
- Typical usage:
```powershell
powershell -File scripts/translate-long-doc.ps1 `
-InputFile "C:\path\paper.pdf" `
-TargetLanguage zh `
-EnvFile ".env"
```
```powershell
powershell -File scripts/translate-long-doc.ps1 `
-InputFile "C:\path\paper.pdf" `
-TargetLanguage zh `
-Page 2
```
```powershell
powershell -File scripts/translate-long-doc.ps1 `
-InputFile "C:\path\paper.pdf" `
-TargetLanguage zh `
-PageRange "2-4,7"
```
```bash
dotnet run --project dotnet/src/Easydict.WinUI -p:WindowsPackageType=None -p:EnableLocalDebugLongDocCli=true -- --translate-long-doc --input <file> --target-language <lang> [options]
```
- Useful options:
- `--service <id>`: choose translation service
- `--output <path>`: override output path
- `--page 2`: translate a single PDF page
- `--page-range 1-3,5`: limit PDF pages
- `--list-services`: list available long-doc-capable services
- Important packaging rule:
- This CLI is local-debug-only.
- It is only compiled when `EnableLocalDebugLongDocCli=true` (default only for local `Debug + WindowsPackageType=None`).
- It must not be included in packaged `MSIX`, published `.zip`, or installer `.exe` artifacts.
- The PowerShell wrapper under `scripts/` is repo-only and is not part of publish outputs.

## Code Style (match existing)
- Modern C# conventions already used in repo: nullable enabled, file-scoped namespaces, `required`/`init`, async-first.
- 4-space indentation, braces on new lines, early returns preferred.
Expand Down
63 changes: 63 additions & 0 deletions dotnet/scripts/pdf-to-images.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$InputPdf,

[Parameter(Position = 1)]
[string]$OutputDir,

[double]$Dpi = 144,

[ValidateSet("png", "jpg", "jpeg")]
[string]$Format = "png",

[double]$Scale
)

$ErrorActionPreference = "Stop"

$repoRoot = Split-Path -Parent $PSScriptRoot
$toolProject = Join-Path $repoRoot "tools\PdfToImages\PdfToImages.csproj"
$resolvedFormat = if ($Format -eq "jpeg") { "jpg" } else { $Format }

if (-not (Test-Path $InputPdf)) {
throw "Input PDF not found: $InputPdf"
}

function Get-DefaultOutputDir {
param([string]$PdfPath)

$sourceDir = Split-Path -Parent $PdfPath
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($PdfPath)
return Join-Path $sourceDir ($baseName + "_pages")
}

$resolvedInput = (Resolve-Path $InputPdf).Path
$resolvedOutputDir = if ($OutputDir) { $OutputDir } else { Get-DefaultOutputDir -PdfPath $resolvedInput }
$resolvedOutputDir = [System.IO.Path]::GetFullPath($resolvedOutputDir)
New-Item -ItemType Directory -Path $resolvedOutputDir -Force | Out-Null

Write-Host "Converting PDF pages to images..."
Write-Host "Tool project: $toolProject"
Write-Host "Input PDF : $resolvedInput"
Write-Host "Output dir : $resolvedOutputDir"

$arguments = @(
"run",
"--project", $toolProject,
"--",
"--input", $resolvedInput,
"--output-dir", $resolvedOutputDir,
"--format", $resolvedFormat
)

if ($PSBoundParameters.ContainsKey("Scale")) {
$arguments += @("--scale", $Scale.ToString([System.Globalization.CultureInfo]::InvariantCulture))
}
else {
$arguments += @("--dpi", $Dpi.ToString([System.Globalization.CultureInfo]::InvariantCulture))
}

& dotnet @arguments
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
6 changes: 5 additions & 1 deletion dotnet/src/Easydict.TextLayout/FontFitting/FontFitRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ namespace Easydict.TextLayout.FontFitting;

/// <summary>
/// Input for font size fitting. Supports two modes:
/// block-rect (MaxWidth + MaxHeight) and line-rect (LineWidths + LineHeights).
/// block-rect (MaxWidth + MaxHeight) and line-width
/// (LineWidths with optional MaxLineCount / MaxHeight / LineHeights).
/// </summary>
public sealed record FontFitRequest
{
Expand All @@ -27,6 +28,9 @@ public sealed record FontFitRequest
/// <summary>Line rect mode: per-line maximum widths.</summary>
public IReadOnlyList<double>? LineWidths { get; init; }

/// <summary>Line rect mode: maximum number of visible lines. Defaults to LineWidths.Count.</summary>
public int? MaxLineCount { get; init; }

/// <summary>Line rect mode: per-line heights (for font-size ceiling).</summary>
public IReadOnlyList<double>? LineHeights { get; init; }

Expand Down
15 changes: 13 additions & 2 deletions dotnet/src/Easydict.TextLayout/FontFitting/FontFitSolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,25 @@ private static bool TryFit(
var result = engine.Layout(prepared, lineWidths);
lineCount = result.LineCount;

if (lineCount > lineWidths.Count)
var maxLineCount = request.MaxLineCount ?? lineWidths.Count;
if (maxLineCount > 0 && lineCount > maxLineCount)
return false;

if (request.MaxHeight.HasValue)
{
var totalHeight = lineCount * lineHeight;
if (totalHeight > request.MaxHeight.Value + 0.01)
return false;
}

// Check font size against line heights
if (request.LineHeights is { Count: > 0 } lineHeights)
{
if (lineCount > lineHeights.Count)
return false;

var minHeight = double.MaxValue;
for (var i = 0; i < Math.Min(lineHeights.Count, lineCount); i++)
for (var i = 0; i < lineCount; i++)
minHeight = Math.Min(minHeight, lineHeights[i]);

if (fontSize > minHeight * 0.98)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Easydict.TranslationService.FormulaProtection;
using Easydict.TranslationService.LongDocument;

namespace Easydict.TranslationService.ContentPreservation;

/// <summary>
/// Evidence bundle for a single document block, used by <see cref="IContentPreservationService.Analyze"/>.
/// Carries all upstream signals (block type, fonts, character stats, text) without encoding policy.
/// </summary>
public sealed record BlockContext
{
/// <summary>Original text of the block.</summary>
public required string Text { get; init; }

/// <summary>Source block type from the document parser.</summary>
public required SourceBlockType BlockType { get; init; }

/// <summary>Whether the block was flagged as formula-like by the parser.</summary>
public bool IsFormulaLike { get; init; }

/// <summary>Font names detected in this block (may contain subset prefixes).</summary>
public IReadOnlyList<string>? DetectedFontNames { get; init; }

/// <summary>Character-level formula statistics from the PDF character stream.</summary>
public BlockFormulaCharacters? FormulaCharacters { get; init; }

/// <summary>Custom regex pattern for math font matching (user override).</summary>
public string? FormulaFontPattern { get; init; }

/// <summary>Custom regex pattern for math character matching (user override).</summary>
public string? FormulaCharPattern { get; init; }

/// <summary>
/// Character-level protected text (from CharacterParagraphBuilder), if available.
/// When set, FormulaPreservationService prefers this over regex-based detection.
/// </summary>
public string? CharacterLevelProtectedText { get; init; }

/// <summary>
/// Character-level formula tokens, paired with CharacterLevelProtectedText.
/// </summary>
public IReadOnlyList<FormulaToken>? CharacterLevelTokens { get; init; }

/// <summary>
/// Retry attempt number, incremented when the translation pipeline re-invokes
/// <see cref="IContentPreservationService.Protect"/> after detecting placeholder loss.
/// Level 0 (default) is the first attempt and uses strict confidence split.
/// Level ≥1 demotes ambiguous formula types (subscripts, superscripts, fractions,
/// square roots) from hard <c>{vN}</c> protection to soft <c>$...$</c> protection
/// and bypasses the character-level preemption path.
/// </summary>
public int RetryAttempt { get; init; } = 0;

/// <summary>
/// Optional source block identifier for DEBUG logging.
/// </summary>
public string? DebugBlockId { get; init; }

/// <summary>
/// Optional source page number for DEBUG logging.
/// </summary>
public int? DebugPageNumber { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using Easydict.TranslationService.FormulaProtection;

namespace Easydict.TranslationService.ContentPreservation;

/// <summary>
/// How a block's preserved content should be handled.
/// </summary>
public enum PreservationMode
{
/// <summary>Normal text — no special preservation needed.</summary>
None,

/// <summary>Text contains inline preserved spans (formulas, etc.) replaced with placeholders.</summary>
InlineProtected,

/// <summary>Entire block is opaque (standalone formula, table, etc.) — skip translation.</summary>
Opaque
}

/// <summary>
/// What kind of content is being preserved.
/// </summary>
public enum ProtectedSpanKind
{
Formula
}

/// <summary>
/// Describes evidence that a span should be protected.
/// </summary>
/// <param name="Start">Character offset in original text.</param>
/// <param name="Length">Span length.</param>
/// <param name="Kind">What kind of content this is.</param>
/// <param name="Confidence">0–1 confidence score.</param>
/// <param name="Source">Detection source: Regex, MathFont, UnicodeMath, ScriptShift, LayoutExcluded, VerticalTM, CID.</param>
public sealed record SpanEvidence(
int Start,
int Length,
ProtectedSpanKind Kind,
double Confidence,
string Source);

/// <summary>
/// The decision about how to handle a block's content preservation.
/// </summary>
public sealed record ProtectionPlan
{
public required PreservationMode Mode { get; init; }
public required bool SkipTranslation { get; init; }
public string? Reason { get; init; }
}

/// <summary>
/// Wrapper syntax used for a soft-protected span.
/// </summary>
public enum SoftProtectionWrapperKind
{
DollarMath,
EquationSoftTag
}

/// <summary>
/// A low-confidence protected span that remains inline in the translation request.
/// Stored so post-translation validation can check whether exact-preservation spans
/// survived unchanged.
/// </summary>
public sealed record SoftProtectedSpan
{
public required string RawText { get; init; }
public required FormulaTokenType TokenType { get; init; }
public required string WrappedText { get; init; }
public bool SyntheticDelimiters { get; init; }
public bool RequiresExactPreservation { get; init; }
public SoftProtectionWrapperKind WrapperKind { get; init; } = SoftProtectionWrapperKind.DollarMath;
}

/// <summary>
/// The result of applying content protection to a block.
/// </summary>
public sealed record ProtectedBlock
{
public required string OriginalText { get; init; }
public required string ProtectedText { get; init; }
public required IReadOnlyList<FormulaToken> Tokens { get; init; }
public required IReadOnlyList<SoftProtectedSpan> SoftSpans { get; init; }
public required ProtectionPlan Plan { get; init; }
}

/// <summary>
/// Status of a content restoration operation.
/// </summary>
public enum RestoreStatus
{
/// <summary>All placeholders restored successfully.</summary>
FullRestore,

/// <summary>Some placeholders missing but ≥50% present; best-effort restore performed.</summary>
PartialRestore,

/// <summary>Restoration failed; fell back to original text.</summary>
FallbackToOriginal
}

/// <summary>
/// Status of post-translation validation for soft-protected spans.
/// </summary>
public enum SoftValidationStatus
{
None,
Passed,
Normalized,
Failed
}

/// <summary>
/// The result of restoring protected content in translated text.
/// </summary>
public sealed record RestoreOutcome
{
public required string Text { get; init; }
public required RestoreStatus Status { get; init; }
public int MissingTokenCount { get; init; }
public SoftValidationStatus SoftValidationStatus { get; init; } = SoftValidationStatus.None;
public int SoftFailureCount { get; init; }
public int SyntheticDelimiterStripCount { get; init; }
}
Loading
Loading