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 MmdPdf.Services; namespace MmdPdf { 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), "MmdPdf", 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(); } } }