using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; namespace MmdPdf.Services { // ============================================================ // Raw-bitmap helpers - pure functions over BGRA pixel buffers, // no window state. Formerly a MainWindow partial (MmdPdfUI // refactor); shared by the render paths, thumbnails, page // export, OCR and the CLI. // ============================================================ internal static class BitmapHelpers { /// /// Rotates a raw BGRA (4 bytes/pixel) bitmap clockwise by degrees. /// Used because Docnet's FPDF_RenderPageBitmapWithMatrix uses a pure-scaling /// matrix, so PDFium renders the page in its MediaBox orientation (no rotation). /// We strip /Rotate from the temp file so content is never clipped, then rotate /// the pixel buffer here to match the intended visual orientation. /// internal static (byte[] bytes, int w, int h) RotateBitmap(byte[] src, int w, int h, int degrees) { degrees = ((degrees % 360) + 360) % 360; if (degrees == 0) return (src, w, h); int newW = (degrees == 90 || degrees == 270) ? h : w; int newH = (degrees == 90 || degrees == 270) ? w : h; byte[] dst = new byte[newW * newH * 4]; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int srcIdx = (y * w + x) * 4; int dstX, dstY; switch (degrees) { case 90: dstX = h - 1 - y; dstY = x; break; // CW case 180: dstX = w - 1 - x; dstY = h - 1 - y; break; default: dstX = y; dstY = w - 1 - x; break; // 270 CW } int dstIdx = (dstY * newW + dstX) * 4; dst[dstIdx] = src[srcIdx]; dst[dstIdx + 1] = src[srcIdx + 1]; dst[dstIdx + 2] = src[srcIdx + 2]; dst[dstIdx + 3] = src[srcIdx + 3]; } } return (dst, newW, newH); } // ============================================================ // Document color inversion (#135, "dark mode") // ============================================================ // Document dark-mode invert is PER PANE now (PdfViewer.DocInvert) so a split can read // one document inverted beside a normal one - the global static that used to live here // flipped both panes at once. DISPLAY ONLY either way: saves, prints, exports, OCR, // thumbnails, and tool previews all keep the document's true colors. // True = night mode inverts pictures along with everything else (the pre-carve-out // behavior, now opt-in from the moon button's right-click menu; default off). Loaded // from the "DocInvertImages" setting at startup. internal static bool DocInvertImages; /// In-place inversion for the display dark mode, applied at the Viewport render /// sites BEFORE the pixel-buffer rotation (via InvertBgraInPlaceExcept, which carves the /// image regions back out). PDF pages usually paint NO background - the "paper" is /// transparent pixels compositing over the white page slot - so a plain RGB flip left /// the page white and merely faded the ink. Composite over white and invert in one /// step: out = a*(255-c)/255 with alpha forced opaque. White (or unpainted) paper /// becomes black, dark ink becomes light, and opaque images get a true negative. internal static void InvertBgraInPlace(byte[] bgra) { for (int i = 0; i + 3 < bgra.Length; i += 4) { int a = bgra[i + 3]; bgra[i] = (byte)(a * (255 - bgra[i]) / 255); bgra[i + 1] = (byte)(a * (255 - bgra[i + 1]) / 255); bgra[i + 2] = (byte)(a * (255 - bgra[i + 2]) / 255); bgra[i + 3] = 255; } } /// An image's bounding box as FRACTIONS of the unrotated page (top-left origin), /// so one cached set serves every render resolution. Produced by PdfImages.GetFracRects. internal readonly record struct FracRect(double L, double T, double R, double B); /// /// #135 follow-up: dark mode that does NOT invert pictures. Inverts the whole page with /// the operator above, then applies the SAME operator once more over the image regions. /// That second pass is exact, not approximate: for an already-inverted opaque pixel, /// out = 255 - (a*(255-c)/255) = (a*c + (255-a)*255)/255 - the ORIGINAL pixel composited /// over white, which is precisely what the image looked like on the normal white page. /// Overlapping image boxes are merged per scanline so no pixel gets the operator twice. /// internal static void InvertBgraInPlaceExcept(byte[] bgra, int width, int height, FracRect[] keep) { InvertBgraInPlace(bgra); if (keep is null || keep.Length == 0 || width <= 0 || height <= 0) return; // Fractions -> pixel boxes, clamped. Floor/ceiling so a box never leaves a 1px // inverted sliver of the image at its edge. var px = new List<(int x0, int y0, int x1, int y1)>(keep.Length); foreach (var r in keep) { int x0 = Math.Max(0, (int)Math.Floor(r.L * width)); int x1 = Math.Min(width, (int)Math.Ceiling(r.R * width)); int y0 = Math.Max(0, (int)Math.Floor(r.T * height)); int y1 = Math.Min(height, (int)Math.Ceiling(r.B * height)); if (x1 > x0 && y1 > y0) px.Add((x0, y0, x1, y1)); } if (px.Count == 0) return; var spans = new List<(int x0, int x1)>(px.Count); for (int y = 0; y < height; y++) { spans.Clear(); foreach (var b in px) if (y >= b.y0 && y < b.y1) spans.Add((b.x0, b.x1)); if (spans.Count == 0) continue; spans.Sort((a, b) => a.x0.CompareTo(b.x0)); int row = y * width * 4; int curStart = spans[0].x0, curEnd = spans[0].x1; for (int s = 1; s <= spans.Count; s++) { if (s < spans.Count && spans[s].x0 <= curEnd) { if (spans[s].x1 > curEnd) curEnd = spans[s].x1; continue; } for (int x = curStart; x < curEnd; x++) { int i = row + x * 4; int a = bgra[i + 3]; bgra[i] = (byte)(a * (255 - bgra[i]) / 255); bgra[i + 1] = (byte)(a * (255 - bgra[i + 1]) / 255); bgra[i + 2] = (byte)(a * (255 - bgra[i + 2]) / 255); bgra[i + 3] = 255; } if (s < spans.Count) { curStart = spans[s].x0; curEnd = spans[s].x1; } } } } /// /// Encodes raw BGRA pixel data from pdfium to PNG without touching the UI thread. /// GDI+ Format32bppArgb is BGRA in memory - matches pdfium output exactly. /// internal static byte[] RenderToPng(byte[] bgra, int width, int height, double dpi = 96) { var pin = GCHandle.Alloc(bgra, GCHandleType.Pinned); try { using var bmp = new System.Drawing.Bitmap( width, height, width * 4, System.Drawing.Imaging.PixelFormat.Format32bppArgb, pin.AddrOfPinnedObject()); // #188: bake the render DPI into the file's metadata; GDI+ defaults to 96. bmp.SetResolution((float)dpi, (float)dpi); using var ms = new MemoryStream(); bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png); return ms.ToArray(); } finally { pin.Free(); } } // Builds a frozen bitmap sized so its baked DPI displays it at (dipW x dipH) DIPs. Shared by the // tile and the render cache so a cached tile bitmap reuses the exact same geometry. internal static BitmapSource BuildScaledBitmap(int w, int h, byte[] rawBytes, int dipW, int dipH) { var wb = new WriteableBitmap(w, h, 96.0 * w / Math.Max(1, dipW), 96.0 * h / Math.Max(1, dipH), PixelFormats.Bgra32, null); wb.WritePixels(new Int32Rect(0, 0, w, h), rawBytes, w * 4, 0); wb.Freeze(); return wb; } /// /// Encodes raw BGRA pixel data to JPEG (quality 90) via WPF's encoder. Born as the CLI's /// CliEncodeJpeg (no JPEG encoder existed before --to-image); homed here beside RenderToPng /// in the MmdPdfUI refactor, shared by the CLI and the GUI image export. /// internal static byte[] EncodeJpeg(byte[] bgra, int width, int height, double dpi = 96) { // #188: dpi lands in the JFIF density header; pixel dimensions are unaffected. var bmp = BitmapSource.Create(width, height, dpi, dpi, PixelFormats.Bgra32, null, bgra, width * 4); var encoder = new JpegBitmapEncoder { QualityLevel = 90 }; encoder.Frames.Add(BitmapFrame.Create(bmp)); using var ms = new MemoryStream(); encoder.Save(ms); return ms.ToArray(); } } }