Files
mmd-pdf/MmdPdf.Tests/PdfBurnRotationTests.cs
T
kua-agent 6610acfa44 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.
2026-08-27 07:37:09 +02:00

164 lines
7.3 KiB
C#

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");
}
}