319 lines
16 KiB
C#
319 lines
16 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using Docnet.Core;
|
|
using Docnet.Core.Models;
|
|
using PdfSharpCore.Drawing;
|
|
using PdfSharpCore.Pdf;
|
|
using PdfSharpCore.Pdf.IO;
|
|
using KillerPDF.Services;
|
|
|
|
namespace KillerPDF
|
|
{
|
|
public partial class MainWindow
|
|
{
|
|
// ============================================================
|
|
// Transform tool (rotate + scale; draggable corner handles + aspect-unlock next).
|
|
// The toolbar button opens a modal TransformWindow that renders the page on its own canvas (so the
|
|
// main view mode is irrelevant). Apply rasterizes at full resolution into an expanded white page
|
|
// (no cropped corners) and swaps the page in, with undo.
|
|
// ============================================================
|
|
|
|
private void ToolRotate_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null) { SetStatus(Loc("Str_Msg_OpenFirst")); return; }
|
|
OpenTransformWindow();
|
|
}
|
|
|
|
private void OpenTransformWindow()
|
|
{
|
|
if (_doc is null) return;
|
|
// Transform burns the live annotation layer into its preview and final page image. A text
|
|
// box that still has keyboard focus has not entered that layer yet, so opening Transform
|
|
// directly after typing used to preview (and apply) the page without the new text.
|
|
CommitActiveTextBox();
|
|
int pageIdx = PageList.SelectedIndex;
|
|
// Render the preview from a copy with this page's annotations baked in, so the preview matches
|
|
// what Apply will produce (otherwise annotations are invisible in the Transform window). Kept at a
|
|
// modest resolution (the preview only shows at ~600px) so the live scale/rotate compose stays
|
|
// fast; Apply re-renders at full resolution independently.
|
|
var src = RenderPageBitmap(pageIdx, 1100, BurnPageAnnotationsToTemp(pageIdx));
|
|
if (src is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
|
|
|
|
// First-use warning that a transform rasterizes the page; persists the opt-out.
|
|
if (App.GetSetting("RotateWarnAck") != "1")
|
|
{
|
|
var (res, dontWarn) = KillerDialog.ShowWithCheckbox(this,
|
|
Loc("Str_Tf_Warn"),
|
|
Loc("Str_Tf_DontWarn"), Loc("Str_Tf_Suffix"), MessageBoxButton.OKCancel);
|
|
if (res != MessageBoxResult.OK) return;
|
|
if (dontWarn) App.SetSetting("RotateWarnAck", "1");
|
|
}
|
|
|
|
var page = _doc.Pages[pageIdx];
|
|
var (pwpt, phpt) = EffectivePageSize(page); // CropBox-aware, so the readout matches the visible page
|
|
var win = new TransformWindow(this, src, pwpt, phpt);
|
|
win.ShowDialog();
|
|
if (win.Applied && (Math.Abs(win.Angle) > 0.01 || Math.Abs(win.Scale - 1.0) > 0.001 ||
|
|
win.FlipH || win.FlipV || !PerspectiveWarp.IsIdentity(win.PerspectiveCorners) ||
|
|
!TransformWindow.LevelsIdentity(win.LevelBlack, win.LevelWhite, win.LevelGamma)))
|
|
ApplyPageTransform(pageIdx, win.Angle, win.Scale, win.FixedPage, win.FlipH, win.FlipV,
|
|
win.PerspectiveCorners, win.LevelBlack, win.LevelWhite, win.LevelGamma);
|
|
}
|
|
|
|
// The page's visible size in points: the CropBox if one is set (so a cropped page reports its real,
|
|
// smaller size), otherwise the full MediaBox. PdfPage.Width/Height return the MediaBox only.
|
|
private static (double wpt, double hpt) EffectivePageSize(PdfPage page)
|
|
{
|
|
double wpt = page.Width.Point, hpt = page.Height.Point;
|
|
if (page.Elements.GetArray("/CropBox") is { Elements.Count: 4 } cb)
|
|
{
|
|
double x1 = cb.Elements.GetReal(0), y1 = cb.Elements.GetReal(1);
|
|
double x2 = cb.Elements.GetReal(2), y2 = cb.Elements.GetReal(3);
|
|
double cw = Math.Abs(x2 - x1), ch = Math.Abs(y2 - y1);
|
|
if (cw > 1 && ch > 1) { wpt = cw; hpt = ch; }
|
|
}
|
|
return (wpt, hpt);
|
|
}
|
|
|
|
// Rasterizes one page with the chosen rotate + scale and swaps it in for the original (undoable).
|
|
private void ApplyPageTransform(int pageIdx, double angleDeg, double scale, bool fixedPage,
|
|
bool flipH, bool flipV, Point[] perspectiveCorners,
|
|
int levelBlack = 0, int levelWhite = 255, double levelGamma = 1.0)
|
|
{
|
|
if (_doc is null || _currentFile is null) return;
|
|
if (pageIdx < 0 || pageIdx >= _doc.PageCount) return;
|
|
|
|
try
|
|
{
|
|
// Snapshot for undo BEFORE touching the document, so one Ctrl+Z reverts the transform.
|
|
PushDocUndo();
|
|
|
|
// If the page carries annotations, bake just that page's annotations into the PDF so they
|
|
// rotate/scale with the page (it is being rasterized anyway, and the user was warned). The
|
|
// helper is non-destructive (restores _doc); we then drop the now-baked annotations.
|
|
string? burned = BurnPageAnnotationsToTemp(pageIdx);
|
|
if (burned != null && _annotations.TryGetValue(pageIdx, out var pageAnns))
|
|
pageAnns.Clear(); // now part of the page image
|
|
|
|
var src = RenderPageBitmap(pageIdx, 2200, burned);
|
|
if (src is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
|
|
|
|
var perspective = PerspectiveWarp.IsIdentity(perspectiveCorners)
|
|
? src : PerspectiveWarp.Apply(src, perspectiveCorners);
|
|
var composed = ComposeTransform(perspective, angleDeg, scale, fixedPage, flipH, flipV);
|
|
// #174: levels last, on the final full-resolution pixels - same pass the preview shows.
|
|
composed = TransformWindow.ApplyLevels(composed, levelBlack, levelWhite, levelGamma);
|
|
byte[] png = EncodePng(composed);
|
|
|
|
var oldPage = _doc.Pages[pageIdx];
|
|
var (epw, eph) = EffectivePageSize(oldPage); // honor CropBox so a cropped page keeps its size
|
|
// #167: the bitmap is in VISUAL orientation - RenderPageBitmap applies the page's
|
|
// in-app rotation - but MediaBox/CropBox are always unrotated (the working file has
|
|
// /Rotate stripped into _pageRotations). On a quarter-turned page the two disagreed,
|
|
// so sx and sy came out different: the transformed page was squeezed back to portrait
|
|
// and stretched vertically. Swap the point dimensions to match what was rendered.
|
|
int visRot = _pageRotations.TryGetValue(pageIdx, out int vr) ? ((vr % 360) + 360) % 360 : 0;
|
|
if (visRot == 90 || visRot == 270) (epw, eph) = (eph, epw);
|
|
double sx = epw / src.PixelWidth;
|
|
double sy = eph / src.PixelHeight;
|
|
double newWpt = composed.PixelWidth * sx;
|
|
double newHpt = composed.PixelHeight * sy;
|
|
|
|
// Build a one-page PDF holding the transformed image (the proven image-page pattern).
|
|
string tmp = App.MakeTempFile("xfpage");
|
|
using (var one = new PdfDocument())
|
|
{
|
|
var np = one.AddPage();
|
|
np.Width = XUnit.FromPoint(newWpt);
|
|
np.Height = XUnit.FromPoint(newHpt);
|
|
using (var xi = XImage.FromStream(() => new MemoryStream(png)))
|
|
using (var gfx = XGraphics.FromPdfPage(np))
|
|
gfx.DrawImage(xi, 0, 0, np.Width.Point, np.Height.Point);
|
|
one.Save(tmp);
|
|
}
|
|
|
|
// Import that page and swap it in for the original (mirrors DuplicatePage's index dance).
|
|
using (var srcDoc = PdfReader.Open(tmp, PdfDocumentOpenMode.Import))
|
|
{
|
|
var imported = _doc.AddPage(srcDoc.Pages[0]);
|
|
_doc.Pages.RemoveAt(_doc.PageCount - 1);
|
|
_doc.Pages.Insert(pageIdx, imported);
|
|
_doc.Pages.RemoveAt(pageIdx + 1);
|
|
}
|
|
|
|
SaveTempAndReload(keepAnnotations: true);
|
|
SetStatus(string.Format(Loc("Str_Tf_Done"), pageIdx + 1));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KillerDialog.Show(this, string.Format(Loc("Str_Tf_Failed"), ex.Message), "KillerPDF",
|
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
|
}
|
|
}
|
|
|
|
// Saves the document with ONE page's annotations burned in, to a temp PDF, and returns its path
|
|
// (null if the page has no annotations - the caller then renders the normal source). Non-destructive:
|
|
// _doc is restored to its pre-burn state by reopening from a clean snapshot, mirroring the proven
|
|
// Save-Flattened pattern, so this is safe for the preview as well as Apply.
|
|
private string? BurnPageAnnotationsToTemp(int pageIdx)
|
|
{
|
|
if (_doc is null) return null;
|
|
if (!(_annotations.TryGetValue(pageIdx, out var pa) && pa.Count > 0)) return null;
|
|
|
|
var tempClean = App.MakeTempFile("xfclean");
|
|
var tempBurned = App.MakeTempFile("xfburn");
|
|
_doc.Save(tempClean);
|
|
// #142: a failed burn (one bad annotation) must not crash the tool that asked for the
|
|
// preview. The clean snapshot is already on disk, so on failure fall through to the
|
|
// restore below and render without the annotation layer instead.
|
|
bool burnOk = true;
|
|
try
|
|
{
|
|
DrawAnnotationsOnDocument(pageIdx);
|
|
_doc.Save(tempBurned);
|
|
}
|
|
catch { burnOk = false; }
|
|
_doc.Close();
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception xrefEx) when (PdfImport.IsXRefException(xrefEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("xffixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
|
|
throw;
|
|
tempClean = fixedPath;
|
|
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempClean;
|
|
return burnOk ? tempBurned : null;
|
|
}
|
|
|
|
// Renders a page to a white-backed bitmap (transparent page backgrounds show white, not the dark
|
|
// canvas), applying any in-app rotation so the preview matches the live view.
|
|
private BitmapSource? RenderPageBitmap(int pageIdx, int maxPx, string? sourceOverride = null)
|
|
{
|
|
if (_doc is null || _currentFile is null) return null;
|
|
if (pageIdx < 0 || pageIdx >= _doc.PageCount) return null;
|
|
try
|
|
{
|
|
string srcPath = sourceOverride ?? _currentFile;
|
|
using var docReader = DocLib.Instance.GetDocReader(srcPath, new PageDimensions(maxPx, maxPx));
|
|
using var pr = docReader.GetPageReader(pageIdx);
|
|
int w = pr.GetPageWidth();
|
|
int h = pr.GetPageHeight();
|
|
// #141: WithAnnotations - Transform rasterizes the page and REPLACES it, so
|
|
// without this the file's own markup would be dropped by transforming a page.
|
|
byte[] bgra = PdfiumInterop.RenderPageWithAnnotations(srcPath, pageIdx, w, h)
|
|
?? pr.GetImage();
|
|
if (_pageRotations.TryGetValue(pageIdx, out int prot) && prot != 0)
|
|
(bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, prot);
|
|
if (bgra == null || bgra.Length == 0 || w <= 0 || h <= 0) return null;
|
|
|
|
var raw = BitmapSource.Create(w, h, 96, 96, PixelFormats.Bgra32, null, bgra, w * 4);
|
|
var dv = new DrawingVisual();
|
|
using (var dc = dv.RenderOpen())
|
|
{
|
|
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, w, h));
|
|
dc.DrawImage(raw, new Rect(0, 0, w, h));
|
|
}
|
|
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
|
|
rtb.Render(dv);
|
|
rtb.Freeze();
|
|
return rtb;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
// Scale (per page-size mode) then rotate. Used by both the window preview and full-resolution Apply.
|
|
internal static BitmapSource ComposeTransform(BitmapSource src, double angleDeg, double scale, bool fixedPage, bool flipH, bool flipV)
|
|
{
|
|
var s = ApplyFlip(src, flipH, flipV);
|
|
var scaled = Math.Abs(scale - 1.0) < 0.001 ? s : ScaleCompose(s, scale, fixedPage);
|
|
return Math.Abs(angleDeg) < 0.001 ? scaled : RotateExpand(scaled, angleDeg);
|
|
}
|
|
|
|
private static BitmapSource ApplyFlip(BitmapSource src, bool flipH, bool flipV)
|
|
{
|
|
if (!flipH && !flipV) return src;
|
|
var tb = new TransformedBitmap(src, new ScaleTransform(flipH ? -1 : 1, flipV ? -1 : 1));
|
|
tb.Freeze();
|
|
return tb;
|
|
}
|
|
|
|
// fixedPage=true: keep the canvas size, shrink the content with white margins. false: resize the page
|
|
// (fewer pixels at the same points-per-pixel = a physically smaller page).
|
|
private static BitmapSource ScaleCompose(BitmapSource src, double scale, bool fixedPage)
|
|
{
|
|
int w = src.PixelWidth, h = src.PixelHeight;
|
|
int sw = Math.Max(1, (int)Math.Round(w * scale));
|
|
int sh = Math.Max(1, (int)Math.Round(h * scale));
|
|
|
|
var dv = new DrawingVisual();
|
|
if (fixedPage)
|
|
{
|
|
using (var dc = dv.RenderOpen())
|
|
{
|
|
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, w, h));
|
|
dc.DrawImage(src, new Rect((w - sw) / 2.0, (h - sh) / 2.0, sw, sh));
|
|
}
|
|
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
|
|
rtb.Render(dv);
|
|
rtb.Freeze();
|
|
return rtb;
|
|
}
|
|
else
|
|
{
|
|
using (var dc = dv.RenderOpen())
|
|
dc.DrawImage(src, new Rect(0, 0, sw, sh));
|
|
var rtb = new RenderTargetBitmap(sw, sh, 96, 96, PixelFormats.Pbgra32);
|
|
rtb.Render(dv);
|
|
rtb.Freeze();
|
|
return rtb;
|
|
}
|
|
}
|
|
|
|
// Rotates a bitmap by angleDeg about its center into a canvas grown to the rotated bounding box, with
|
|
// the new corners filled white.
|
|
internal static BitmapSource RotateExpand(BitmapSource src, double angleDeg)
|
|
{
|
|
double w = src.PixelWidth, h = src.PixelHeight;
|
|
double rad = angleDeg * Math.PI / 180.0;
|
|
double cos = Math.Abs(Math.Cos(rad));
|
|
double sin = Math.Abs(Math.Sin(rad));
|
|
int nw = (int)Math.Ceiling(w * cos + h * sin);
|
|
int nh = (int)Math.Ceiling(w * sin + h * cos);
|
|
|
|
var dv = new DrawingVisual();
|
|
using (var dc = dv.RenderOpen())
|
|
{
|
|
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, nw, nh));
|
|
dc.PushTransform(new TranslateTransform(nw / 2.0, nh / 2.0));
|
|
dc.PushTransform(new RotateTransform(angleDeg));
|
|
dc.DrawImage(src, new Rect(-w / 2.0, -h / 2.0, w, h));
|
|
dc.Pop();
|
|
dc.Pop();
|
|
}
|
|
var rtb = new RenderTargetBitmap(nw, nh, 96, 96, PixelFormats.Pbgra32);
|
|
rtb.Render(dv);
|
|
rtb.Freeze();
|
|
return rtb;
|
|
}
|
|
|
|
private static byte[] EncodePng(BitmapSource bmp)
|
|
{
|
|
var enc = new PngBitmapEncoder();
|
|
enc.Frames.Add(BitmapFrame.Create(bmp));
|
|
using var ms = new MemoryStream();
|
|
enc.Save(ms);
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
}
|