feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit

- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
This commit is contained in:
2026-08-27 07:37:09 +02:00
parent 532485a830
commit 6610acfa44
230 changed files with 9362 additions and 11508 deletions
+114
View File
@@ -0,0 +1,114 @@
using System.Windows;
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
// #169: rotating a page must keep its overlay annotations and turn them with the page.
// The mapping runs in render-dim space; a +90 turn takes frame (W, H) to (H, W), so
// round-trip tests swap the frame between passes exactly as the live reload does.
public sealed class AnnotationRotateTests
{
private const double W = 800, H = 600;
[Fact]
public void Highlight_QuarterTurn_TurnsWithTheContent()
{
var ha = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
AnnotationRotate.Remap([ha], 90, W, H);
// Corners (10,20)-(110,60) map to (580,10)-(540,110): the region swaps its axes.
Assert.Equal(540, ha.Bounds.X, 3);
Assert.Equal(10, ha.Bounds.Y, 3);
Assert.Equal(40, ha.Bounds.Width, 3);
Assert.Equal(100, ha.Bounds.Height, 3);
}
[Fact]
public void Highlight_FourQuarterTurns_IsIdentity()
{
var start = new Rect(10, 20, 100, 40);
var ha = new HighlightAnnotation { Bounds = start };
AnnotationRotate.Remap([ha], 90, W, H);
AnnotationRotate.Remap([ha], 90, H, W);
AnnotationRotate.Remap([ha], 90, W, H);
AnnotationRotate.Remap([ha], 90, H, W);
Assert.Equal(start, ha.Bounds);
}
[Fact]
public void Highlight_TurnThenCounterTurn_IsIdentity()
{
var start = new Rect(33, 44, 55, 66);
var ha = new HighlightAnnotation { Bounds = start };
AnnotationRotate.Remap([ha], 90, W, H);
AnnotationRotate.Remap([ha], -90, H, W);
Assert.Equal(start, ha.Bounds);
}
[Fact]
public void Highlight_TwoQuarterTurns_MatchHalfTurn()
{
var a = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
var b = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
AnnotationRotate.Remap([a], 90, W, H);
AnnotationRotate.Remap([a], 90, H, W);
AnnotationRotate.Remap([b], 180, W, H);
Assert.Equal(b.Bounds, a.Bounds);
}
[Fact]
public void TextBox_KeepsSize_CenterFollowsThePage()
{
var ta = new TextAnnotation { Position = new Point(10, 70), Width = 100, Height = 40 };
AnnotationRotate.Remap([ta], 90, W, H);
// Center (60,90) maps to (510,60); the box keeps 100x40 around it.
Assert.Equal(460, ta.Position.X, 3);
Assert.Equal(40, ta.Position.Y, 3);
Assert.Equal(100, ta.Width, 3);
Assert.Equal(40, ta.Height, 3);
}
[Fact]
public void UprightTextBoxNearLongEdge_IsClampedInsideRotatedPage()
{
// Fully inside the old 800x600 page. Keeping this tall box upright after a clockwise turn
// would put its bottom at 870 on the new 600x800 page unless the remap clamps it.
var ta = new TextAnnotation { Position = new Point(750, 100), Width = 40, Height = 200 };
AnnotationRotate.Remap([ta], 90, W, H);
Assert.Equal(380, ta.Position.X, 3);
Assert.Equal(600, ta.Position.Y, 3);
Assert.InRange(ta.Position.X + ta.Width, 0, H);
Assert.InRange(ta.Position.Y + ta.Height, 0, W);
}
[Fact]
public void UprightPlacedItemNearLongEdge_IsClampedInsideRotatedPage()
{
var image = new ImageAnnotation
{
Position = new Point(750, 100),
SourceWidth = 40,
SourceHeight = 200,
Scale = 1
};
AnnotationRotate.Remap([image], 90, W, H);
Assert.Equal(380, image.Position.X, 3);
Assert.Equal(600, image.Position.Y, 3);
Assert.InRange(image.Position.X + image.SourceWidth * image.Scale, 0, H);
Assert.InRange(image.Position.Y + image.SourceHeight * image.Scale, 0, W);
}
[Fact]
public void InkPoints_MapPerPoint()
{
var ia = new InkAnnotation { Points = [new Point(0, 0), new Point(100, 50)] };
AnnotationRotate.Remap([ia], 90, W, H);
Assert.Equal(new Point(H, 0), ia.Points[0]);
Assert.Equal(new Point(H - 50, 100), ia.Points[1]);
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Windows;
using Xunit;
namespace MmdPdf.Tests
{
public class FlyoutPlacementTests
{
[Fact]
public void LeftRailUsesBottomLeftContentCorner()
{
var placement = FlyoutPlacement.PaneCorner(
new Size(244, 460), new Size(1200, 700), alignRight: false)[0];
Assert.Equal(new Point(-16, 260), placement.Point);
}
[Fact]
public void RightRailMirrorsFlyoutToBottomRightContentCorner()
{
var placement = FlyoutPlacement.PaneCorner(
new Size(244, 460), new Size(1200, 700), alignRight: true)[0];
Assert.Equal(new Point(972, 260), placement.Point);
}
[Fact]
public void TallFlyoutStaysBelowToolbarOnEitherSide()
{
var left = FlyoutPlacement.PaneCorner(
new Size(244, 800), new Size(1200, 700), alignRight: false)[0];
var right = FlyoutPlacement.PaneCorner(
new Size(244, 800), new Size(1200, 700), alignRight: true)[0];
Assert.Equal(0, left.Point.Y);
Assert.Equal(0, right.Point.Y);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Xunit;
namespace MmdPdf.Tests;
public sealed class LocalizationParityTests
{
private static readonly string StringsDirectory = FindStringsDirectory();
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
[Fact]
public void EveryLocaleHasTheEnglishKeysAndPlaceholders()
{
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
foreach (var file in Directory.GetFiles(StringsDirectory, "*.xaml"))
{
var localized = ReadStrings(file);
Assert.True(english.Keys.OrderBy(x => x).SequenceEqual(localized.Keys.OrderBy(x => x)),
$"{Path.GetFileName(file)} does not contain exactly the English resource-key set.");
foreach (var key in english.Keys)
{
Assert.False(string.IsNullOrWhiteSpace(localized[key]),
$"{Path.GetFileName(file)} has an empty value for {key}.");
Assert.True(Placeholders(english[key]).SequenceEqual(Placeholders(localized[key])),
$"{Path.GetFileName(file)} has different placeholders for {key}.");
}
}
}
[Fact]
public void Issue227SharedSurfacesHaveResourceKeys()
{
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
string[] required =
[
"Str_Btn_OK", "Str_Btn_Yes", "Str_Btn_No", "Str_Btn_Cancel",
"Str_RecentMissing",
"Str_St_CopiedAnnotationOne", "Str_St_CopiedAnnotationMany",
"Str_St_PastedAnnotationOne", "Str_St_PastedAnnotationMany",
"Str_St_DeletedAnnotationOne", "Str_St_DeletedAnnotationMany",
"Str_Search_NoMatches", "Str_Search_Error", "Str_Search_Summary",
"Str_Search_PreviousTT", "Str_Search_NextTT", "Str_Search_CloseTT",
"Str_Busy_FlattenPage", "Str_Busy_ExportPage", "Str_Busy_CancelHint",
"Str_Busy_DownloadingMany"
];
foreach (string key in required)
Assert.True(english.ContainsKey(key), $"Missing #227 resource key: {key}");
}
[Fact]
public void Issue227ReportedEnglishIsNotHardcodedInItsUiPaths()
{
string root = Directory.GetParent(StringsDirectory)!.FullName;
var checks = new Dictionary<string, string[]>
{
["Controls/KillerDialog.cs"] = ["MakeBtn(\"Yes\"", "MakeBtn(\"No\"", "MakeBtn(\"Cancel\""],
["Shell/FileOperations.cs"] = [": \"missing\"", "\"Exporting\"", "\"Flattening\""],
["Shell/ContextMenu.cs"] = ["Copied 1 annotation", "Pasted 1 annotation"],
["Controls/Viewer/PdfViewer.Annotations.cs"] = ["Deleted selected annotation"],
["Shell/Search.cs"] = ["Previous Match (Shift+Enter)", "Next Match (Enter)", "Close (Esc)"],
["Features/Search/SearchController.cs"] = ["SetResultText(\"No matches\")", "SetResultText(\"Search error\")"],
["Services/OcrLanguages.cs"] = ["(Esc to cancel)"]
};
foreach (var check in checks)
{
string source = File.ReadAllText(Path.Combine(root, check.Key.Replace('/', Path.DirectorySeparatorChar)));
foreach (string text in check.Value)
Assert.DoesNotContain(text, source);
}
}
private static Dictionary<string, string> ReadStrings(string path) =>
XDocument.Load(path).Root!.Elements()
.Where(e => e.Attribute(Xaml + "Key") is not null)
.ToDictionary(e => e.Attribute(Xaml + "Key")!.Value, e => e.Value);
private static IEnumerable<string> Placeholders(string value) =>
Regex.Matches(value, @"\{\d+(?::[^}]*)?\}").Cast<Match>().Select(m => m.Value).OrderBy(x => x);
private static string FindStringsDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Strings")))
directory = directory.Parent;
Assert.NotNull(directory);
return Path.Combine(directory!.FullName, "Strings");
}
}
+66
View File
@@ -0,0 +1,66 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<!-- Inert on PolySharp 1.15.0, whose targets never make this property compiler-visible.
Set here so bumping this project to 1.16.0 cannot reintroduce the CS8336 reserved-name
break on net48 that MmdPdf.csproj works around. -->
<PolySharpUseEmbeddedAttributeForGeneratedTypes>false</PolySharpUseEmbeddedAttributeForGeneratedTypes>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<UseWPF>true</UseWPF>
<!-- Linked data-only model fields are populated by the application, not these focused tests. -->
<NoWarn>$(NoWarn);CS0649</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="PolySharp" Version="1.15.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PdfPig" Version="0.1.14" />
<PackageReference Include="System.Text.Json" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Models\EditingTypes.cs" Link="Models\EditingTypes.cs" />
<Compile Include="..\Services\SignatureStore.cs" Link="Services\SignatureStore.cs" />
<Compile Include="..\Services\SearchService.cs" Link="Services\SearchService.cs" />
<Compile Include="..\Services\TextRunService.cs" Link="Services\TextRunService.cs" />
<Compile Include="..\Services\ProtocolRegistrar.cs" Link="Services\ProtocolRegistrar.cs" />
<Compile Include="..\Services\PerspectiveWarp.cs" Link="Services\PerspectiveWarp.cs" />
<Compile Include="..\Services\PdfFontStyle.cs" Link="Services\PdfFontStyle.cs" />
<Compile Include="..\Services\PdfScrub.cs" Link="Services\PdfScrub.cs" />
<Compile Include="..\Services\PdfBurn.cs" Link="Services\PdfBurn.cs" />
<Compile Include="..\Services\AnnotationRotate.cs" Link="Services\AnnotationRotate.cs" />
<Compile Include="..\Services\WheelPageFlipGate.cs" Link="Services\WheelPageFlipGate.cs" />
<Compile Include="..\Services\FontCoverage.cs" Link="Services\FontCoverage.cs" />
<Compile Include="..\Services\CmapCoverage.cs" Link="Services\CmapCoverage.cs" />
<Compile Include="..\Services\PdfFonts.cs" Link="Services\PdfFonts.cs" />
<Compile Include="..\Models\StampModels.cs" Link="Models\StampModels.cs" />
<Compile Include="..\Controls\FlyoutPlacement.cs" Link="Controls\FlyoutPlacement.cs" />
<!-- Data only, by design: OcrLanguages.cs itself reaches App and OcrNativeBootstrap, which a
catalog check has no business dragging in. See OcrCatalogTests. -->
<Compile Include="..\Services\OcrCatalog.cs" Link="Services\OcrCatalog.cs" />
<!-- Data only, same reasoning as OcrCatalog: the shortcut table is deliberately free of WPF
and of MainWindow so the invariants can be checked without standing up a window. -->
<Compile Include="..\Services\ShortcutTable.cs" Link="Services\ShortcutTable.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\third_party\PdfSharpCore\PdfSharpCore.csproj" />
</ItemGroup>
</Project>
+109
View File
@@ -0,0 +1,109 @@
using System;
using System.IO;
using System.Linq;
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
// The rule this file exists to enforce: OCR LANGUAGES TRACK INTERFACE LANGUAGES.
// If MmdPdf's UI is offered in a language, OCR is offered in it too. There is no such thing as
// an interface language whose text the app cannot read.
//
// Nothing enforced it before, so it drifted: the UI shipped hu-HU while the catalog stayed at
// eleven entries, and baobab-ts.com went on telling people OCR covered ten languages and that
// Polish and Hungarian "do not require OCR models".
//
// These read the real Strings folder instead of a second hardcoded list, so adding a locale fails
// the build until its model is registered. release.ps1 runs the suite, so it cannot ship broken.
public sealed class OcrCatalogTests
{
// The test binary sits at MmdPdf.Tests\bin\<cfg>\net48; the repo root is the ancestor that
// holds Strings\. Walking up beats a pile of ..\..\.. that breaks whenever the layout moves.
private static string StringsDir()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Strings")))
dir = dir.Parent;
Assert.True(dir != null, "could not locate the repo's Strings folder from " + AppContext.BaseDirectory);
return Path.Combine(dir!.FullName, "Strings");
}
private static string[] ShippedLocales() =>
Directory.GetFiles(StringsDir(), "*.xaml")
.Select(f => Path.GetFileNameWithoutExtension(f)!)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToArray();
[Fact]
public void TheStringsFolderIsActuallyFound()
{
// Guards the guard: if the walk-up ever failed silently, every other test here would
// compare against an empty list and pass while checking nothing.
Assert.NotEmpty(ShippedLocales());
}
[Fact]
public void EveryShippedLocaleHasAnOcrModel()
{
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Locale).ToArray();
var missing = ShippedLocales()
.Where(l => !mapped.Contains(l, StringComparer.OrdinalIgnoreCase))
.ToArray();
Assert.True(missing.Length == 0,
"These locales ship an interface but have no OCR model in OcrCatalog.LocaleToCode: " +
string.Join(", ", missing));
}
[Fact]
public void NoModelIsRegisteredForALocaleThatIsNotShipped()
{
var locales = ShippedLocales();
var stale = OcrCatalog.LocaleToCode
.Where(x => !locales.Contains(x.Locale, StringComparer.OrdinalIgnoreCase))
.Select(x => x.Locale + " -> " + x.Code)
.ToArray();
Assert.True(stale.Length == 0,
"These OCR entries point at locales no longer in Strings\\: " + string.Join(", ", stale));
}
[Fact]
public void CatalogAndLocaleMapAgree()
{
var catalog = OcrCatalog.Languages.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
Assert.Equal(mapped, catalog);
}
[Fact]
public void CatalogHasNoDuplicatesAndNoBlanks()
{
var codes = OcrCatalog.Languages.Select(x => x.Code).ToArray();
Assert.Equal(codes.Length, codes.Distinct(StringComparer.Ordinal).Count());
Assert.All(OcrCatalog.Languages, e =>
{
Assert.False(string.IsNullOrWhiteSpace(e.Code));
Assert.False(string.IsNullOrWhiteSpace(e.Name));
});
}
[Fact]
public void OcrLanguageCountEqualsInterfaceLanguageCount()
{
// The number baobab-ts.com quotes in prose. Pinning it here is what stops the site and
// the app from disagreeing again.
Assert.Equal(ShippedLocales().Length, OcrCatalog.Languages.Length);
}
[Theory]
[InlineData("hu-HU", "hun")]
[InlineData("pl-PL", "pol")]
public void TheOnesThatWereMissingAreRegistered(string locale, string code)
{
Assert.Contains(OcrCatalog.LocaleToCode, x =>
string.Equals(x.Locale, locale, StringComparison.OrdinalIgnoreCase) && x.Code == code);
Assert.Contains(OcrCatalog.Languages, x => x.Code == code);
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.Diagnostics;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using Xunit;
namespace MmdPdf.Tests
{
/// <summary>
/// Regression cover for bookmarks whose /Dest is a *named destination* rather than a literal
/// array. wkhtmltopdf writes /Dest /__WKANCHOR_n plus a flat catalog /Dests dictionary, and
/// since most HTML-to-PDF invoice generators are wkhtmltopdf underneath, that shape is common.
/// PdfSharpCore's PdfOutline.Initialize() used to hit Debug.Assert(false, "See what to do when
/// this happened.") on it - a modal dialog in Debug, a silently dead bookmark in Release.
/// </summary>
public class OutlineDestinationTests
{
const string DestName = "/__WKANCHOR_2";
/// <summary>
/// Debug.Assert writes to a trace listener and shows a modal box - it never throws, so an
/// assert regression would HANG this test rather than fail it. Swap in a listener that
/// turns Fail into an exception.
/// </summary>
sealed class ThrowingListener : TraceListener
{
public override void Write(string? message) { }
public override void WriteLine(string? message) { }
public override void Fail(string? message) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message);
public override void Fail(string? message, string? detail) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message + " | " + detail);
}
[Fact]
public void OpenOutlines_NamedDestination_DoesNotAssert()
{
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(dir);
var file = System.IO.Path.Combine(dir, "named_dest.pdf");
var original = new TraceListener[Trace.Listeners.Count];
Trace.Listeners.CopyTo(original, 0);
Trace.Listeners.Clear();
Trace.Listeners.Add(new ThrowingListener());
try
{
WriteNamedDestPdf(file);
var doc = PdfReader.Open(file, PdfDocumentOpenMode.Modify);
var outlines = doc.Outlines; // threw before the fix
Assert.Single(outlines);
Assert.Equal("Tax Invoice", outlines[0].Title);
// Guard the fixture itself: if a future PdfSharpCore writer rewrote the name into a
// literal array on save, this test would still pass while no longer covering the
// regression at all.
Assert.IsType<PdfName>(outlines[0].Elements.GetValue("/Dest"));
}
finally
{
Trace.Listeners.Clear();
Trace.Listeners.AddRange(original);
System.IO.Directory.Delete(dir, recursive: true);
}
}
/// <summary>
/// Builds a one-page PDF with a single bookmark pointing at a named destination.
/// ponytail: the /Dests entry holds the destination array DIRECTLY; the invoice that
/// surfaced this held it indirectly. Both take the same /Dest-is-a-PdfName branch, which is
/// the regression under test. Make it indirect if that path ever breaks on its own.
/// </summary>
static void WriteNamedDestPdf(string path)
{
var doc = new PdfDocument();
doc.AddPage();
// [page /XYZ left top zoom] - integer page number, as wkhtmltopdf emits.
var dest = new PdfArray(doc,
new PdfInteger(0), new PdfName("/XYZ"),
new PdfInteger(0), new PdfInteger(800), new PdfInteger(0));
var dests = new PdfDictionary(doc);
dests.Elements[DestName] = dest;
doc.Internals.AddObject(dests);
var root = new PdfDictionary(doc);
root.Elements["/Type"] = new PdfName("/Outlines");
doc.Internals.AddObject(root);
var item = new PdfDictionary(doc);
item.Elements["/Title"] = new PdfString("Tax Invoice");
item.Elements["/Dest"] = new PdfName(DestName);
item.Elements.SetReference("/Parent", root);
doc.Internals.AddObject(item);
root.Elements.SetReference("/First", item);
root.Elements.SetReference("/Last", item);
doc.Internals.Catalog.Elements.SetReference("/Outlines", root);
doc.Internals.Catalog.Elements.SetReference("/Dests", dests);
doc.Save(path);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using MmdPdf.Services;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using Xunit;
namespace MmdPdf.Tests;
// #169: content placed on a rotated page must burn where the user placed it. The burn draws in
// the VISUAL frame and maps back through a quarter-turn matrix. Two parts have regressed
// independently and each is pinned here by reading the saved content stream:
// 1. the scale basis - sx/sy must come from the visual page size, not the raw page box
// (a regression squeezes the rect by exactly the page's aspect ratio), and
// 2. the quarter-turn matrix reaching both burn paths (annotations AND stamps) - dropping
// it leaves the rect numbers right but the content turned 90 degrees on the page.
public sealed class PdfBurnRotationTests
{
// A5-ish landscape map on a portrait MediaBox, the shape from the #169 repro files.
private const double BoxW = 842, BoxH = 1191;
private static string BurnHighlightContent(int nativeRotate, Dictionary<int, int>? rotations,
double pageW, double pageH, int renderW, int renderH, Rect bounds)
{
using var doc = new PdfDocument();
doc.Options.NoCompression = true;
var page = doc.AddPage();
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(pageW, pageH));
if (nativeRotate != 0) page.Rotate = nativeRotate;
var annots = new Dictionary<int, List<PageAnnotation>>
{
[0] = [new HighlightAnnotation { PageIndex = 0, Bounds = bounds, Style = HighlightStyle.Fill }],
};
var dims = new Dictionary<int, (int w, int h)> { [0] = (renderW, renderH) };
PdfBurn.DrawAnnotationsIntoDoc(doc, annots, dims, null, rotations);
return SaveToText(doc);
}
private static string SaveToText(PdfDocument doc)
{
using var ms = new MemoryStream();
doc.Save(ms, false);
// PdfSharpCore may Flate-compress content streams even when NoCompression is set;
// that option governs document structure, not every page stream. Inspect the decoded
// page content instead of depending on the writer's storage choice.
var content = doc.Pages[0].Contents.CreateSingleContent();
return Encoding.GetEncoding("ISO-8859-1").GetString(content.Stream.UnfilteredValue);
}
// Every `x y w h re` operator in the saved file, as (w, h).
private static List<(double w, double h)> RectSizes(string pdf)
{
var list = new List<(double, double)>();
foreach (Match m in Regex.Matches(pdf,
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) re"))
{
list.Add((double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture),
double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture)));
}
return list;
}
// True when the content carries a quarter-turn cm (a and d zero, b and c unit) - the visual-to-page
// mapping the rotated burn must emit. The unrotated base transform is axis-aligned and never matches.
private static bool HasQuarterTurnCm(string pdf)
{
foreach (Match m in Regex.Matches(pdf,
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) cm"))
{
double a = double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
double b = double.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
double c = double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture);
double d = double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture);
if (Math.Abs(a) < 0.001 && Math.Abs(d) < 0.001 &&
Math.Abs(Math.Abs(b) - 1) < 0.001 && Math.Abs(Math.Abs(c) - 1) < 0.001)
return true;
}
return false;
}
[Fact]
public void UnrotatedPage_BurnsAtRenderScale_NoTurn()
{
// Letter page rendered at 2x: a 300x60 canvas rect must burn as 150x30 points.
string pdf = BurnHighlightContent(0, null, 612, 792, 1224, 1584, new Rect(100, 200, 300, 60));
var rects = RectSizes(pdf);
var r = Assert.Single(rects);
Assert.Equal(150, r.w, 2);
Assert.Equal(30, r.h, 2);
Assert.False(HasQuarterTurnCm(pdf));
}
// Native /Rotate on a freshly opened file (the 1.7.1 fallback - the rotation map is empty),
// and both quarter turns.
[Theory]
[InlineData(90)]
[InlineData(270)]
public void NativeRotate_UsesVisualScaleAndTurns(int rotate)
{
// Visual frame is 1191x842, rendered at 2x. A 300x60 canvas rect must burn as 150x30.
// The #169 regression scaled against the raw 842x1191 box instead, which burns exactly
// 106.07x42.45 - the aspect-ratio squeeze from terada-d's measurements.
string pdf = BurnHighlightContent(rotate, null, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
var rects = RectSizes(pdf);
var r = Assert.Single(rects);
Assert.Equal(150, r.w, 2);
Assert.Equal(30, r.h, 2);
Assert.True(HasQuarterTurnCm(pdf), "rotated burn emitted no quarter-turn cm - content will land turned 90 degrees");
}
[Fact]
public void InAppRotation_MapOverridesStrippedPage()
{
// In-app rotation: the working copy has /Rotate stripped to 0 and the angle lives in the
// shell's rotation map. The burn must honor the map exactly as it honors a native /Rotate.
var rotations = new Dictionary<int, int> { [0] = 90 };
string pdf = BurnHighlightContent(0, rotations, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
var rects = RectSizes(pdf);
var r = Assert.Single(rects);
Assert.Equal(150, r.w, 2);
Assert.Equal(30, r.h, 2);
Assert.True(HasQuarterTurnCm(pdf));
}
[Fact]
public void Rotate180_ScalesUnswappedAndTurns()
{
// 180 keeps the axes (no dimension swap) but still needs its half-turn mapping.
string pdf = BurnHighlightContent(180, null, BoxW, BoxH, 1684, 2382, new Rect(200, 400, 300, 60));
var rects = RectSizes(pdf);
var r = Assert.Single(rects);
Assert.Equal(150, r.w, 2);
Assert.Equal(30, r.h, 2);
// A half turn is (-1 0 0 -1) pre-flip; composed with the base flip it is axis-aligned,
// so assert on the rect numbers plus the annotation surviving - not on the cm shape.
}
[Fact]
public void StampBurn_RotatedPage_GetsTheTurnToo()
{
// Stamps share the visual-frame helpers; the original #169 gap was the stamp burn never
// receiving the angle at all, so preview and output disagreed.
using var doc = new PdfDocument();
doc.Options.NoCompression = true;
var page = doc.AddPage();
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(BoxW, BoxH));
page.Rotate = 90;
var spec = new StampSpec { NumbersEnabled = true, Format = "{n} / {N}" };
PdfBurn.DrawStampsIntoDoc(doc, spec);
string pdf = SaveToText(doc);
Assert.True(HasQuarterTurnCm(pdf), "stamp burn on a rotated page emitted no quarter-turn cm");
}
}
+28
View File
@@ -0,0 +1,28 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests
{
public class PdfFontStyleTests
{
[Theory]
// #187: families are normalized to the installed Windows family the PostScript name
// means - the raw PS name resolves to nothing in WPF and read as "all formatting lost".
[InlineData("ABCDEF+Helvetica-Bold", "Arial", true, false)]
[InlineData("Helvetica-Oblique", "Arial", false, true)]
[InlineData("TimesNewRomanPS-BoldItalicMT", "Times New Roman", true, true)]
[InlineData("Arial-Regular", "Arial", false, false)]
[InlineData("ArialMT", "Arial", false, false)]
[InlineData("TimesNewRomanPSMT", "Times New Roman", false, false)]
[InlineData("CourierNewPS-BoldMT", "Courier New", true, false)]
[InlineData("GHIJKL+BookAntiqua", "Book Antiqua", false, false)]
public void DetectsFaceStyleFromPdfFontName(string source, string family, bool bold, bool italic)
{
var detected = PdfFontStyle.FromPdfName(source);
Assert.Equal(family, detected.Family);
Assert.Equal(bold, detected.Bold);
Assert.Equal(italic, detected.Italic);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using MmdPdf.Services;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using Xunit;
namespace MmdPdf.Tests;
public sealed class PdfScrubTests
{
[Fact]
public void ScrubDegenerateCropBoxes_RemovesCropOutsideMediaBox()
{
using var doc = new PdfDocument();
var page = doc.AddPage();
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(1191, 842));
page.CropBox = new PdfRectangle(new XPoint(0, 0), new XPoint(842, 1191));
page.Rotate = 90;
PdfScrub.ScrubDegenerateCropBoxes(doc);
Assert.Null(page.Elements["/CropBox"]);
}
[Fact]
public void ScrubDegenerateCropBoxes_PreservesValidInsetCrop()
{
using var doc = new PdfDocument();
var page = doc.AddPage();
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(612, 792));
page.CropBox = new PdfRectangle(new XPoint(18, 24), new XPoint(594, 768));
PdfScrub.ScrubDegenerateCropBoxes(doc);
Assert.NotNull(page.Elements["/CropBox"]);
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Text;
using UglyToad.PdfPig;
using Xunit;
namespace MmdPdf.Tests
{
/// <summary>
/// Pins the PdfPig property that in-place text editing reads its font size from (#163).
/// Letter.FontSize is the size as written in the content stream, so a generator that emits
/// "/F1 1 Tf" and applies the scale through the text matrix reports 1 no matter how large the
/// glyphs actually draw. Detection used that value, which collapsed the replacement text onto
/// its lower clamp and read back as 3pt. Letter.PointSize is the size in points and is right
/// for both spellings, so these tests fail if a PdfPig upgrade changes either meaning.
/// </summary>
public class PdfTextSizeTests
{
// Two runs that draw at the same visual size: the first sized by Tf with an identity text
// matrix, the second sized entirely by a 12x text matrix.
const string Content =
"BT\n/F1 12 Tf\n1 0 0 1 72 700 Tm\n(Conventional) Tj\nET\n" +
"BT\n/F1 1 Tf\n12 0 0 12 72 660 Tm\n(Scaled) Tj\nET\n";
/// <summary>A single-page PDF holding <see cref="Content"/>, built by hand so the two runs
/// keep their exact Tf and Tm operands - no library would emit the second one.</summary>
static byte[] BuildPdf()
{
string[] objects =
[
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
+ "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n",
$"4 0 obj\n<< /Length {Content.Length} >>\nstream\n{Content}\nendstream\nendobj\n",
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
+ "/Encoding /WinAnsiEncoding >>\nendobj\n",
];
var pdf = new StringBuilder("%PDF-1.4\n");
var offsets = new int[objects.Length];
for (int i = 0; i < objects.Length; i++)
{
offsets[i] = pdf.Length;
pdf.Append(objects[i]);
}
int startXref = pdf.Length;
pdf.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
foreach (int offset in offsets)
pdf.Append($"{offset:D10} 00000 n \n");
pdf.Append($"trailer\n<< /Size {objects.Length + 1} /Root 1 0 R >>\n")
.Append($"startxref\n{startXref}\n%%EOF\n");
// The content is pure ASCII, so string length above is also the byte offset.
return Encoding.ASCII.GetBytes(pdf.ToString());
}
static (double FontSize, double PointSize) FirstLetterOf(string word)
{
using var doc = PdfDocument.Open(BuildPdf());
var letter = doc.GetPage(1).GetWords().Single(w => w.Text == word).Letters[0];
return (letter.FontSize, letter.PointSize);
}
[Fact]
public void FontSize_MatchesPoints_OnlyWhenTfCarriesTheScale()
{
Assert.Equal(12, FirstLetterOf("Conventional").FontSize, 3);
}
[Fact]
public void FontSize_ReportsTheRawOperand_WhenTheTextMatrixCarriesTheScale()
{
// The glyphs draw at 12pt, but Tf said 1. This is the value that produced #163.
Assert.Equal(1, FirstLetterOf("Scaled").FontSize, 3);
}
[Fact]
public void PointSize_IsTheVisualSize_ForBothSpellings()
{
Assert.Equal(12, FirstLetterOf("Conventional").PointSize, 3);
Assert.Equal(12, FirstLetterOf("Scaled").PointSize, 3);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
public sealed class PerspectiveWarpTests
{
[Fact]
public void IdentityCornersPreserveDimensionsAndCornerPixels()
{
byte[] pixels =
{
1,2,3,255, 4,5,6,255,
7,8,9,255, 10,11,12,255,
};
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, pixels, 8);
Point[] corners = { new(0,0), new(1,0), new(1,1), new(0,1) };
var result = PerspectiveWarp.Apply(source, corners);
byte[] actual = new byte[16];
result.CopyPixels(actual, 8, 0);
Assert.Equal(2, result.PixelWidth);
Assert.Equal(2, result.PixelHeight);
Assert.Equal(pixels, actual);
}
[Fact]
public void RejectsCollapsedQuadrilateral()
{
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[16], 8);
Point[] corners = { new(0,0), new(0,0), new(0,0), new(0,0) };
Assert.Throws<System.InvalidOperationException>(() => PerspectiveWarp.Apply(source, corners));
}
}
+23
View File
@@ -0,0 +1,23 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
public sealed class ProtocolRegistrarTests
{
[Fact]
public void ParsesEncodedHttpsPdfUrl()
{
Assert.True(ProtocolRegistrar.TryGetTargetUrl(
"mmdpdf://open?url=https%3A%2F%2Fexample.com%2Ffile.pdf%3Fx%3D1", out var target));
Assert.Equal("https://example.com/file.pdf?x=1", target!.AbsoluteUri);
}
[Theory]
[InlineData("mmdpdf://open?url=http%3A%2F%2Fexample.com%2Ffile.pdf")]
[InlineData("mmdpdf://open?url=file%3A%2F%2Fc%3A%2Fsecret.pdf")]
[InlineData("mmdpdf://wrong?url=https%3A%2F%2Fexample.com%2Ffile.pdf")]
[InlineData("https://example.com/file.pdf")]
public void RejectsUnsafeOrUnrelatedLaunches(string value)
=> Assert.False(ProtocolRegistrar.TryGetTargetUrl(value, out _));
}
+52
View File
@@ -0,0 +1,52 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests
{
public class SearchServiceTests
{
private readonly SearchService _svc = new();
[Fact]
public void Search_EmptyQuery_ReturnsEmpty()
{
var result = _svc.Search("irrelevant.pdf", "");
Assert.Empty(result.ResultPages);
Assert.Equal(0, result.TotalHits);
}
[Fact]
public void Search_WhitespaceQuery_ReturnsEmpty()
{
var result = _svc.Search("irrelevant.pdf", " ");
Assert.Empty(result.ResultPages);
Assert.Equal(0, result.TotalHits);
}
[Fact]
public void Search_EmptyFilePath_ReturnsEmpty()
{
var result = _svc.Search("", "hello");
Assert.Empty(result.ResultPages);
Assert.Equal(0, result.TotalHits);
}
[Fact]
public void Search_MissingFile_ReturnsEmpty()
{
// Should not throw; non-existent file produces no results.
var result = _svc.Search(@"C:\does\not\exist.pdf", "hello");
Assert.Empty(result.ResultPages);
Assert.Equal(0, result.TotalHits);
}
[Fact]
public void SearchResult_PageRects_EmptyByDefault()
{
var result = new SearchResult();
Assert.Empty(result.PageRects);
Assert.Empty(result.ResultPages);
Assert.Equal(0, result.TotalHits);
}
}
}
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using MmdPdf;
using Xunit;
namespace MmdPdf.Tests
{
/// <summary>
/// Invariants of the one shortcut table (Services/ShortcutTable.cs).
///
/// The table was unified in 1.7.5 from two hand maintained copies that had quietly drifted:
/// Alt+M existed in the list and not on the keyboard map, Home and End were captioned
/// differently in each view, and Ctrl+B was documented as bold in one section of the list and
/// as the sidebar in another while the code did neither consistently. Unifying removes the
/// opportunity for that; these tests are what keep it removed.
///
/// No WPF here on purpose. The table is plain data, which is why the csproj links the single
/// file rather than referencing the app, the same arrangement OcrCatalogTests uses.
/// </summary>
public class ShortcutTableTests
{
private static readonly string StringsDir =
Path.Combine(RepoRoot(), "Strings");
private static string RepoRoot()
{
// Walk up from the test binary until the folder holding Strings\en-US.xaml appears.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Strings", "en-US.xaml")))
dir = dir.Parent;
Assert.True(dir != null, "could not locate the repo root from the test output folder");
return dir!.FullName;
}
private static HashSet<string> EnglishKeys()
{
var doc = XDocument.Load(Path.Combine(StringsDir, "en-US.xaml"));
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
return doc.Root!.Elements()
.Select(e => (string?)e.Attribute(x + "Key"))
.Where(k => k != null)
.Select(k => k!)
.ToHashSet();
}
/// <summary>
/// THE one that matters. Two bindings claiming the same key on the same layer means the
/// keyboard map silently shows whichever was declared last, which is exactly how Ctrl+B
/// managed to mean two different things without anyone noticing.
/// </summary>
[Fact]
public void NoKeyIsClaimedTwiceOnTheSameLayer()
{
var duplicates = ShortcutTable.AllCapClaims()
.GroupBy(c => c)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.True(duplicates.Count == 0,
"these keys are claimed by more than one binding: " + string.Join(", ", duplicates));
}
/// <summary>Every cap the table lights has to exist on the drawn board, or the binding is
/// invisible: the map only renders ids present in KbRows. Alt+M was missing the other way
/// round, absent from the table while the list advertised it.</summary>
[Fact]
public void EveryCapExistsOnTheDrawnKeyboard()
{
// KbRows lives in the WPF half, so the ids are mirrored here deliberately: this test is
// the thing that fails if the two ever disagree, which is the point.
var board = new HashSet<string>
{
"Esc","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12",
"Grave","D1","D2","D3","D4","D5","D6","D7","D8","D9","D0","Minus","Equals","Back",
"Ins","Home","PgUp",
"Tab","Q","W","E","R","T","Y","U","I","O","P","LBr","RBr","Bslash","Del","End","PgDn",
"Caps","A","S","D","F","G","H","J","K","L","Semi","Quote","Enter",
"Shift","Z","X","C","V","B","N","M","Comma","Period","Slash","RShift","Up",
"Ctrl","Win","Alt","Space","RAlt","Menu","RCtrl","Left","Down","Right",
};
var missing = ShortcutTable.KsAll
.SelectMany(b => b.Caps)
.Select(c => c.Id)
.Distinct()
.Where(id => !board.Contains(id))
.ToList();
Assert.True(missing.Count == 0,
"these cap ids are not on the drawn keyboard: " + string.Join(", ", missing));
}
[Fact]
public void EveryLabelKeyExistsInEnglish()
{
var english = EnglishKeys();
var missing = ShortcutTable.KsAll
.SelectMany(b => new[] { b.LabelKey }.Concat(b.Caps.Select(c => c.LabelKey)))
.Where(k => k.Length > 0)
.Distinct()
.Where(k => !english.Contains(k))
.ToList();
Assert.True(missing.Count == 0,
"these resource keys are referenced but not defined in en-US.xaml: " + string.Join(", ", missing));
}
[Fact]
public void EveryBindingBelongsToADeclaredSection()
{
var groups = ShortcutTable.KsGroups.Select(g => g.Cat).ToHashSet();
var orphans = ShortcutTable.KsAll.Select(b => b.Cat).Distinct()
.Where(c => !groups.Contains(c)).ToList();
Assert.True(orphans.Count == 0,
"these categories have bindings but no section: " + string.Join(", ", orphans));
}
/// <summary>A section with no bindings would render as a heading over nothing.</summary>
[Fact]
public void EverySectionHasAtLeastOneBinding()
{
var used = ShortcutTable.KsAll.Select(b => b.Cat).ToHashSet();
var empty = ShortcutTable.KsGroups.Select(g => g.Cat).Where(c => !used.Contains(c)).ToList();
Assert.True(empty.Count == 0,
"these sections would render empty: " + string.Join(", ", empty));
}
/// <summary>The layer prefix is the only thing separating Shift+F3 from F3. A typo in the
/// prefix silently lands the cap on the base layer, so the parse gets its own check.</summary>
// Layer passed by name: KbLayer is internal, and an InlineData parameter has to be at least
// as accessible as the public test method.
[Theory]
[InlineData("F3", "Base", "F3")]
[InlineData("Ctrl:O", "Ctrl", "O")]
[InlineData("CtrlShift:D1", "CtrlShift", "D1")]
[InlineData("Shift:F9", "Shift", "F9")]
[InlineData("Alt:M", "Alt", "M")]
public void CapParsesItsLayerPrefix(string id, string layerName, string bare)
{
var cap = ShortcutTable.Cap(id);
Assert.Equal(layerName, cap.Layer.ToString());
Assert.Equal(bare, cap.Id);
}
/// <summary>A cap without its own label inherits the row's; one with a label keeps it. That
/// is what lets "Home / End" stay one row while Home and End caption separately.</summary>
[Fact]
public void CapLabelOverridesTheRowAndOtherwiseInherits()
{
var map = ShortcutTable.BuildMap();
Assert.Equal("Str_Kb_FirstPage", map[KbLayer.Base]["Home"].Label);
Assert.Equal("Str_Kb_LastPage", map[KbLayer.Base]["End"].Label);
Assert.Equal("Str_KS_Open", map[KbLayer.Ctrl]["O"].Label); // inherited
}
/// <summary>The three bindings 1.7.5 changed, pinned so a later edit cannot quietly undo
/// them: the sidebar is F9 and Shift+F9, and Ctrl+B is bold rather than the sidebar.</summary>
[Fact]
public void TheSidebarIsOnF9AndCtrlBIsBold()
{
var map = ShortcutTable.BuildMap();
Assert.Equal("Str_KS_ToggleSidebar", map[KbLayer.Base]["F9"].Label);
Assert.Equal("Str_KS_SidebarSide", map[KbLayer.Shift]["F9"].Label);
Assert.Equal("Str_Lbl_Bold", map[KbLayer.Ctrl]["B"].Label);
Assert.False(map[KbLayer.Ctrl].ContainsKey("B") &&
map[KbLayer.Ctrl]["B"].Label == "Str_KS_ToggleSidebar",
"Ctrl+B must not be the sidebar again");
}
/// <summary>Alt+M was in the list and missing from the map for as long as it existed.</summary>
[Fact]
public void AltMIsOnTheMap()
{
Assert.Equal("Str_Toolbar_Hide", ShortcutTable.BuildMap()[KbLayer.Alt]["M"].Label);
}
/// <summary>Gesture-only rows (the wheel ones, Ctrl+Scroll, Shift+Click, Middle drag) have
/// no keycap by design. Anything else with no caps is likely an oversight, so the count is
/// pinned rather than left to drift.</summary>
[Fact]
public void OnlyMouseGesturesHaveNoCaps()
{
// Asserted by label key, not by the Keys text: the key column is tokenized, so pinning
// its literal spelling here would just break every time a token is renamed.
var capless = ShortcutTable.KsAll.Where(b => b.Caps.Length == 0)
.Select(b => b.LabelKey).ToList();
Assert.Equal(
new[] { "Str_KS_ZoomCursor", "Str_KS_PanView", "Str_KS_CycleView",
"Str_KS_AppSize", "Str_KS_MultiSelect" }
.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
capless.OrderBy(s => s, StringComparer.Ordinal).ToArray());
}
/// <summary>
/// Every token in the key column must be one the renderer knows, or it prints raw to the
/// user as "%shft%". The zoom pair is substituted from the keyboard layout rather than the
/// locale (Services/KeyLayout.cs), so it is known-good without a resource key.
/// </summary>
[Fact]
public void EveryKeyTokenIsResolvable()
{
var known = ShortcutTable.KeyTokens.Select(t => t.Token)
.Concat(new[] { "%zin%", "%zout%" })
.ToHashSet();
var unknown = ShortcutTable.KsAll
.SelectMany(b => Regex.Matches(b.Keys, "%[a-z]+%").Cast<Match>().Select(m => m.Value))
.Distinct()
.Where(t => !known.Contains(t))
.ToList();
Assert.True(unknown.Count == 0,
"these tokens have no resource key and would render raw: " + string.Join(", ", unknown));
}
[Fact]
public void EveryKeyTokenResourceExistsInEnglish()
{
var english = EnglishKeys();
var missing = ShortcutTable.KeyTokens.Select(t => t.Key)
.Where(k => !english.Contains(k)).ToList();
Assert.True(missing.Count == 0,
"key-name resources missing from en-US.xaml: " + string.Join(", ", missing));
}
/// <summary>The point of #230: no bare English key name is left sitting in the table. Only
/// letters, digits, F-numbers, arrows and the chord punctuation may appear literally.</summary>
[Fact]
public void NoUntranslatedKeyNamesRemainInTheTable()
{
var offenders = ShortcutTable.KsAll
.Where(b => Regex.IsMatch(Regex.Replace(b.Keys, "%[a-z]+%", ""),
@"\b(Ctrl|Alt|Shift|Delete|Enter|Escape|Esc|Menu|Home|End|PgUp|PgDn|Tab|Scroll|Click|Wheel|Space|Middle|drag|or)\b"))
.Select(b => b.Keys)
.ToList();
Assert.True(offenders.Count == 0,
"these rows still carry hardcoded English key names: " + string.Join(" | ", offenders));
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests
{
public class SignatureStoreTests
{
[Fact]
public void NewStore_HasEmptySignatures()
{
var store = new SignatureStore();
Assert.Empty(store.Signatures);
}
[Fact]
public void Add_IncreasesCount()
{
var store = new SignatureStore();
store.Add(new SavedSignature { Name = "Test" });
Assert.Single(store.Signatures);
}
[Fact]
public void Remove_DecreasesCount()
{
var store = new SignatureStore();
var sig = new SavedSignature { Name = "Test" };
store.Add(sig);
store.Remove(sig);
Assert.Empty(store.Signatures);
}
[Fact]
public void RoundTrip_PersistAndLoad()
{
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(dir);
var file = System.IO.Path.Combine(dir, "sig_test.json");
try
{
var store1 = new SignatureStore(dir, file);
store1.Add(new SavedSignature { Name = "Alpha", CanvasWidth = 400, CanvasHeight = 150 });
store1.Add(new SavedSignature { Name = "Beta", CanvasWidth = 300, CanvasHeight = 100 });
store1.Persist();
var store2 = new SignatureStore(dir, file);
store2.Load();
Assert.Equal(2, store2.Signatures.Count);
Assert.Equal("Alpha", store2.Signatures[0].Name);
Assert.Equal("Beta", store2.Signatures[1].Name);
}
finally
{
System.IO.Directory.Delete(dir, recursive: true);
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using PdfSharpCore.Drawing;
using PdfSharpCore.Drawing.Layout;
using PdfSharpCore.Pdf;
using Xunit;
namespace MmdPdf.Tests
{
/// <summary>
/// Regression cover for burning multi-line text annotations into a document (#142).
/// CreateBlocks emits a Block(BlockType.LineBreak) per newline, and that constructor never sets
/// Text. CreateLayout then skips assigning those blocks a Location, so they keep XPoint(0,0) and
/// GetLines' GroupBy lumps them in with the first line. The Justify branch of DrawString called
/// block.Text.Trim() on them and threw NullReferenceException.
/// Only the Justify path was affected - the other alignments join blocks with string.Join, which
/// tolerates nulls. The short DrawString overload defaults to Justify, so MmdPdf hit it.
/// </summary>
public class TextFormatterTests
{
static void Draw(string content, TextFormatAlignment? alignment = null)
{
var doc = new PdfDocument();
var page = doc.AddPage();
var gfx = XGraphics.FromPdfPage(page);
var font = new XFont("Segoe UI", 12, XFontStyle.Regular);
var rect = new XRect(20, 20, 300, 200);
var tf = new XTextFormatter(gfx);
if (alignment == null)
tf.DrawString(content, font, XBrushes.Black, rect);
else
tf.DrawString(content, font, XBrushes.Black, rect, alignment);
}
[Fact]
public void DrawString_SingleLine_DoesNotThrow()
{
Draw("Hello world");
}
[Fact]
public void DrawString_MultiLine_DoesNotThrow()
{
// The short overload defaults to Justify - this is the exact call MmdPdf makes when
// burning a text annotation, and the one that crashed.
Draw("Hello\nworld");
}
[Fact]
public void DrawString_MultiLineCrLf_DoesNotThrow()
{
Draw("Hello\r\nworld");
}
[Fact]
public void DrawString_MultiLineLeftAligned_DoesNotThrow()
{
// What the annotation burn now passes explicitly, matching the on-screen TextBlock.
Draw("Hello\nworld", new TextFormatAlignment { Horizontal = XParagraphAlignment.Left });
}
[Fact]
public void DrawString_ConsecutiveNewlines_DoesNotThrow()
{
// A blank line produces two adjacent LineBreak blocks, so the line group is entirely
// non-drawable. Guards the empty-after-filter path.
Draw("Hello\n\n\nworld");
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
public sealed class TextRunServiceTests
{
[Theory]
[InlineData("English text", false)]
[InlineData("متن فارسی", true)]
[InlineData("نص عربي", true)]
[InlineData("טקסט עברי", true)]
[InlineData("1234", false)]
public void DetectsLineDirection(string text, bool expected)
=> Assert.Equal(expected, TextRunService.IsRightToLeftText(new[] { text }));
[Fact]
public void RightToLeftCaretMovesFromRightEdgeToLeftEdge()
{
var runs = new PageTextRuns();
runs.Chars.Add(new RunChar("א", 90, 100, 0, 0));
runs.Chars.Add(new RunChar("ב", 80, 90, 0, 0));
runs.Chars.Add(new RunChar("ג", 70, 80, 0, 0));
runs.Lines.Add(new RunLine
{
Start = 0,
Count = 3,
Top = 20,
Bottom = 10,
Left = 70,
Right = 100,
RightToLeft = true,
});
Assert.Equal(0, TextRunService.CaretFromPoint(runs, 101, 15));
Assert.Equal(1, TextRunService.CaretFromPoint(runs, 89, 15));
Assert.Equal(3, TextRunService.CaretFromPoint(runs, 69, 15));
}
}
+47
View File
@@ -0,0 +1,47 @@
using MmdPdf.Services;
using Xunit;
namespace MmdPdf.Tests;
public sealed class WheelPageFlipGateTests
{
private static readonly DateTime Start = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void TwoQuickNotchesAtEdge_ConfirmPageFlip()
{
var gate = new WheelPageFlipGate();
Assert.False(gate.TryConfirm(-120, Start));
Assert.True(gate.TryConfirm(-120, Start.AddMilliseconds(100)));
}
[Fact]
public void MomentumImmediatelyAfterContentScroll_DoesNotFlip()
{
var gate = new WheelPageFlipGate();
gate.NoteContentScroll(Start);
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(50)));
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(150)));
}
[Fact]
public void OppositeDirection_RestartsConfirmation()
{
var gate = new WheelPageFlipGate();
Assert.False(gate.TryConfirm(-120, Start));
Assert.False(gate.TryConfirm(120, Start.AddMilliseconds(100)));
Assert.True(gate.TryConfirm(120, Start.AddMilliseconds(200)));
}
[Fact]
public void SlowNotches_DoNotCombineIntoPageFlip()
{
var gate = new WheelPageFlipGate();
Assert.False(gate.TryConfirm(-120, Start));
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(700)));
}
}