using System.IO;
using System.Windows;
using System.Windows.Media;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
namespace KillerPDF.Services
{
// ============================================================
// Burn-to-document core - draws the overlay annotation layer
// and the stamp layer (page numbers / watermark) into a
// PdfDocument via XGraphics, in PDF-point space. Static and
// window-free by design (split out of Shell/Annotations.cs and
// Shell/Stamps.cs in the KillerUI refactor): the print flow
// runs these on a background thread against a throwaway copy
// of the document, and being static means the compiler
// guarantees no live UI state is touched.
// ============================================================
internal static class PdfBurn
{
// Builds the geometry for a carved highlight: the painted rectangle MINUS the union of the eraser
// strokes (each widened to its brush radius with round caps) - one smooth, anti-aliased shape. Used
// for both on-screen rendering and PDF export. Null when the highlight hasn't been carved.
internal static Geometry? HighlightEraseGeometry(HighlightAnnotation h)
{
if (h.Erases is not { Count: > 0 } erases) return null;
var holes = new GeometryGroup { FillRule = FillRule.Nonzero };
foreach (var e in erases)
{
if (e.Points.Count == 0) continue;
if (e.Points.Count == 1)
{
holes.Children.Add(new EllipseGeometry(e.Points[0], e.Radius, e.Radius));
continue;
}
var fig = new PathFigure { StartPoint = e.Points[0], IsClosed = false, IsFilled = false };
for (int i = 1; i < e.Points.Count; i++) fig.Segments.Add(new LineSegment(e.Points[i], true));
var pg = new PathGeometry();
pg.Figures.Add(fig);
var pen = new Pen(Brushes.Black, Math.Max(0.5, e.Radius * 2))
{ StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round, LineJoin = PenLineJoin.Round };
holes.Children.Add(pg.GetWidenedPathGeometry(pen));
}
if (holes.Children.Count == 0) return null;
return new CombinedGeometry(GeometryCombineMode.Exclude, new RectangleGeometry(h.DrawRect()), holes);
}
// ── The rotated-page frame (#169, thanks terada-d) ────────────────────────────────────
// A page's rotation lives OUTSIDE the working document: TempReload strips /Rotate to 0 and
// keeps the angle in the shell's _pageRotations, and the render path rotates the bitmap
// instead. So the canvas - and everything measured against it - is in the VISUAL frame,
// while XGraphics draws in the page's own unrotated frame. Every path that writes canvas
// coordinates back into a page has to bridge the two; these two helpers are that bridge,
// shared by the annotation burn and the stamp burn.
//
// Both frames are top-left origin with y down (XGraphics' default page direction and the
// bitmap convention), and the render path rotates CLOCKWISE by the angle, so the mapping
// is a plain quarter-turn about the page box.
/// The page's size as the user sees it: the point dimensions swap on a quarter turn.
private static (double w, double h) VisualPageSize(double pw, double ph, int rot)
=> rot == 90 || rot == 270 ? (ph, pw) : (pw, ph);
/// Maps VISUAL-frame points onto the unrotated page frame XGraphics draws in.
/// Null for an unrotated page (nothing to apply). Prepend it to the graphics transform and
/// every subsequent draw call can keep passing visual coordinates unchanged.
private static XMatrix? VisualToPageMatrix(int rot, double pw, double ph) => rot switch
{
// Derived from the render path's clockwise bitmap rotation, then inverted:
// 90: x_page = y_vis, y_page = ph - x_vis
// 180: x_page = pw - x_vis, y_page = ph - y_vis
// 270: x_page = pw - y_vis, y_page = x_vis
// XMatrix is (m11, m12, m21, m22, dx, dy) with x' = x*m11 + y*m21 + dx.
90 => new XMatrix(0, -1, 1, 0, 0, ph),
180 => new XMatrix(-1, 0, 0, -1, pw, ph),
270 => new XMatrix(0, 1, -1, 0, pw, 0),
_ => null,
};
private static int NormalizeRot(IReadOnlyDictionary? rotations, int pageIdx, PdfPage page)
{
// KillerPDF-managed rotations live in the map after SaveTempAndReload strips them
// from the working file. A newly opened PDF still carries its native /Rotate on the
// page, so use that value whenever the map has no entry for this page.
int r = rotations is not null && rotations.TryGetValue(pageIdx, out int stored)
? stored
: page.Rotate;
return ((r % 360) + 360) % 360;
}
// Burns annotations into the given document using only the supplied annotation + render-dim data and
// nothing from the live UI state (static, so the compiler guarantees it). This makes it safe to run on
// a background thread against a throwaway copy of the document - the print flow uses that to keep the
// UI responsive while annotated pages are flattened.
//
// rotations: the out-of-document page angles (the shell's _pageRotations). Omitting them was
// #169 - annotations were burned in the unrotated frame, so on a rotated page they landed
// turned 90 degrees from where they were placed, offset, and scaled on swapped axes.
internal static void DrawAnnotationsIntoDoc(
PdfDocument? doc,
IReadOnlyDictionary> annotations,
IReadOnlyDictionary renderDims,
int? onlyPage = null,
IReadOnlyDictionary? rotations = null)
{
if (doc is null) return;
// Strip link annotation borders so they don't render as colored rectangles
// (e.g. strikethrough-like lines) in other PDF viewers.
PdfScrub.StripLinkAnnotationBorders(doc);
foreach (var kvp in annotations)
{
int pageIdx = kvp.Key;
if (onlyPage.HasValue && pageIdx != onlyPage.Value) continue;
var annots = kvp.Value;
if (annots.Count == 0 || pageIdx >= doc.PageCount) continue;
if (!renderDims.ContainsKey(pageIdx)) continue;
var page = doc.Pages[pageIdx];
var (renderW, renderH) = renderDims[pageIdx];
// #169: the render dims are in the VISUAL frame (that is what the user drew on),
// so scale against the visual page size, not the raw page box - on a quarter-turned
// page those are on swapped axes.
int rot = NormalizeRot(rotations, pageIdx, page);
double pwPt = page.Width.Point, phPt = page.Height.Point;
var (visW, visH) = VisualPageSize(pwPt, phPt, rot);
double sx = visW / renderW;
double sy = visH / renderH;
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
// ...then turn the whole drawing back into the page's own frame, so every draw
// call below can keep working in visual coordinates exactly as it always has.
if (VisualToPageMatrix(rot, pwPt, phPt) is XMatrix m) gfx.MultiplyTransform(m);
foreach (var annot in annots)
{
switch (annot)
{
case TextAnnotation ta:
{
double tboxX = ta.Position.X * sx;
double tboxY = ta.Position.Y * sy;
double tboxW = ta.Width * sx;
double tboxH = ta.Height * sy;
// Background fill (whiteout) first, behind the text.
if (ta.HasFill)
{
var fc = ta.GetFill();
gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(fc.A, fc.R, fc.G, fc.B)),
tboxX, tboxY, Math.Max(1, tboxW), Math.Max(1, tboxH));
}
// Match the on-screen typeface + B/I/S. Strikeout is a font-style flag PDFsharp
// draws as a line. Fall back to Segoe UI if the font can't be resolved/embedded.
var xstyle = XFontStyle.Regular;
if (ta.Bold) xstyle |= XFontStyle.Bold;
if (ta.Italic) xstyle |= XFontStyle.Italic;
if (ta.Strike) xstyle |= XFontStyle.Strikeout;
if (ta.Underline) xstyle |= XFontStyle.Underline;
XFont font;
// #168: the editor is WPF and falls back per character, so anything
// typed looks right on screen; PdfSharpCore resolves one face and
// boxes whatever it lacks. Pick a family that actually covers this
// text - the user's own font whenever it can carry it.
string wantFamily = string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName;
string useFamily = FontCoverage.PickFamily(wantFamily, ta.Content);
try { font = new XFont(useFamily, ta.FontSize * sy, xstyle); }
catch { font = new XFont("Segoe UI", ta.FontSize * sy, xstyle); }
var taColor = ta.GetColor();
var taBrush = new XSolidBrush(XColor.FromArgb(taColor.A, taColor.R, taColor.G, taColor.B));
// Wrap inside the box, matching the on-screen TextWrapping=Wrap. The 2px editor
// padding is scaled into the layout rect so wrap points line up with the canvas.
double padX = 2 * sx, padY = 2 * sy;
var layoutRect = new XRect(tboxX + padX, tboxY + padY,
Math.Max(1, tboxW - 2 * padX), Math.Max(1, tboxH));
// #142 root cause (PR #144, thanks Ryokoxx): the short DrawString
// overload hardcodes Justify, whose draw path NREd on the null-Text
// LineBreak blocks a newline produces - any font, any machine. The
// formatter is fixed, and the alignment is now stated explicitly:
// the on-screen TextBlock sets no TextAlignment, so WPF renders it
// Left - burned output must match, not silently justify.
// The retry/skip below stays as the net for GENUINE font failures:
// DrawString resolves the typeface lazily, so a font that CONSTRUCTED
// fine can still throw at first draw on machines missing that face.
var taAlign = new PdfSharpCore.Drawing.Layout.TextFormatAlignment
{
Horizontal = PdfSharpCore.Drawing.Layout.XParagraphAlignment.Left
};
if (!string.IsNullOrEmpty(ta.Content))
{
try
{
var tf = new PdfSharpCore.Drawing.Layout.XTextFormatter(gfx);
tf.DrawString(ta.Content, font, taBrush, layoutRect, taAlign);
}
catch
{
try
{
var tf2 = new PdfSharpCore.Drawing.Layout.XTextFormatter(gfx);
// Retry with the same COVERING family (#168) - this catch is
// for the formatter's null-LineBreak crash (#142), not a font
// problem, so dropping to Segoe UI here would only add boxes.
tf2.DrawString(ta.Content, new XFont(useFamily, ta.FontSize * sy, xstyle), taBrush, layoutRect, taAlign);
}
catch { /* skip this annotation rather than fail the whole save (#142) */ }
}
}
break;
}
case HighlightAnnotation ha:
var hc = ha.GetColor();
var hBrush = new XSolidBrush(XColor.FromArgb(hc.A, hc.R, hc.G, hc.B));
// #200: fill highlights burn with the Multiply blend mode, so the color
// darkens the paper and the text stays crisp underneath instead of being
// washed out by an alpha rectangle - the way other PDF viewers highlight.
// Strikethrough/underline bands stay normal draws.
bool multiply = ha.Style == HighlightStyle.Fill;
XGraphicsState? hState = null;
if (multiply) { hState = gfx.Save(); gfx.SetPdfBlendMode("Multiply"); }
if (HighlightEraseGeometry(ha) is { } hgeo)
{
// Carved highlight: flatten the rect-minus-strokes geometry to polygons and
// draw as one filled path so the smooth hole survives into the saved PDF.
var flat = hgeo.GetFlattenedPathGeometry();
var hpath = new XGraphicsPath();
foreach (var fig in flat.Figures)
{
var poly = new System.Collections.Generic.List { new(fig.StartPoint.X * sx, fig.StartPoint.Y * sy) };
foreach (var seg in fig.Segments)
if (seg is PolyLineSegment pls) foreach (var p in pls.Points) poly.Add(new XPoint(p.X * sx, p.Y * sy));
else if (seg is LineSegment ls) poly.Add(new XPoint(ls.Point.X * sx, ls.Point.Y * sy));
if (poly.Count >= 3) hpath.AddPolygon([.. poly]);
}
hpath.FillMode = XFillMode.Winding;
gfx.DrawPath(hBrush, hpath);
}
else
{
var hdr = ha.DrawRect();
gfx.DrawRectangle(hBrush,
hdr.X * sx, hdr.Y * sy,
hdr.Width * sx, hdr.Height * sy);
}
if (hState != null) gfx.Restore(hState);
break;
case InkAnnotation ia:
if (ia.Points.Count < 2) break;
var ic = ia.GetColor();
if (ia.HasFill)
{
// Filled shape (#127 Phase 3): fill the enclosed region first, then stroke.
var fc = ia.GetFillColor();
var fillPts = ia.Points.Select(p => new XPoint(p.X * sx, p.Y * sy)).ToArray();
gfx.DrawPolygon(new XSolidBrush(XColor.FromArgb(fc.A, fc.R, fc.G, fc.B)),
fillPts, XFillMode.Alternate);
}
var pen = new XPen(XColor.FromArgb(ic.A, ic.R, ic.G, ic.B), ia.StrokeWidth * sx)
{
LineJoin = XLineJoin.Round,
LineCap = XLineCap.Round
};
for (int i = 0; i < ia.Points.Count - 1; i++)
{
gfx.DrawLine(pen,
ia.Points[i].X * sx, ia.Points[i].Y * sy,
ia.Points[i + 1].X * sx, ia.Points[i + 1].Y * sy);
}
break;
case SignatureAnnotation sa:
if (sa.ImageData is not null)
{
try
{
var imgBytes = Convert.FromBase64String(sa.ImageData);
var xImg = XImage.FromStream(() => new System.IO.MemoryStream(imgBytes));
double imgX = sa.Position.X * sx;
double imgY = sa.Position.Y * sy;
double imgW = sa.SourceWidth * sa.Scale * sx;
double imgH = sa.SourceHeight * sa.Scale * sy;
gfx.DrawImage(xImg, imgX, imgY, imgW, imgH);
}
catch { /* skip broken image */ }
}
else
{
var sigPen = new XPen(XColors.Black, sa.StrokeWidth * sa.Scale * sx)
{
LineJoin = XLineJoin.Round,
LineCap = XLineCap.Round
};
foreach (var stroke in sa.Strokes)
{
for (int i = 0; i < stroke.Count - 1; i++)
{
double x1 = (sa.Position.X + stroke[i].X * sa.Scale) * sx;
double y1 = (sa.Position.Y + stroke[i].Y * sa.Scale) * sy;
double x2 = (sa.Position.X + stroke[i + 1].X * sa.Scale) * sx;
double y2 = (sa.Position.Y + stroke[i + 1].Y * sa.Scale) * sy;
gfx.DrawLine(sigPen, x1, y1, x2, y2);
}
}
}
break;
case ImageAnnotation ia:
try
{
var iaBytes = Convert.FromBase64String(ia.ImageData);
var xia = XImage.FromStream(() => new System.IO.MemoryStream(iaBytes));
double iaX = ia.Position.X * sx;
double iaY = ia.Position.Y * sy;
double iaW = ia.SourceWidth * ia.Scale * sx;
double iaH = ia.SourceHeight * ia.Scale * sy;
gfx.DrawImage(xia, iaX, iaY, iaW, iaH);
}
catch { /* skip broken image */ }
break;
}
}
}
}
// ---- Stamp layer (page numbers / watermark) ------------------------------------------
// 0-based page indices for a 1-based "1-3,5" range string ("" = all pages). Shared with
// the shell's on-screen stamp renderer.
internal static IEnumerable StampPageRange(string range, int pageCount)
{
var set = new SortedSet();
if (string.IsNullOrWhiteSpace(range))
{
for (int i = 0; i < pageCount; i++) set.Add(i);
return set;
}
foreach (var part in range.Split(','))
{
var p = part.Trim();
if (p.Length == 0) continue;
int dash = p.IndexOf('-');
if (dash > 0)
{
if (int.TryParse(p[..dash].Trim(), out int a) && int.TryParse(p[(dash + 1)..].Trim(), out int b))
{
// Clamp the ends rather than testing each i: an unclamped "1-2147483647" wrapped
// i++ to int.MinValue at the top and never terminated. Same fix as ParseRange.
int lo = Math.Max(1, Math.Min(a, b)), hi = Math.Min(pageCount, Math.Max(a, b));
for (int i = lo; i <= hi; i++) set.Add(i - 1);
}
}
else if (int.TryParse(p, out int single) && single >= 1 && single <= pageCount) set.Add(single - 1);
}
return set;
}
// Draws the active stamps into the doc via XGraphics, in PDF-point space. Called BEFORE
// DrawAnnotationsIntoDoc at each save site so stamps sit beneath annotations. Static so the
// print flow can run it on a background thread against a throwaway document copy.
// rotations: same story as the annotation burn (#169) - stamp positions are corners of the
// page as the USER sees it, so on a rotated page they have to be laid out in the visual
// frame and mapped back. The stamp PREVIEW already swapped the dimensions, so preview and
// output disagreed until this landed.
internal static void DrawStampsIntoDoc(PdfDocument? doc, StampSpec? spec, int? onlyPage = null,
IReadOnlyDictionary? rotations = null)
{
if (doc is null || spec is null || (!spec.NumbersEnabled && !spec.WmEnabled)) return;
int n = doc.PageCount;
HashSet numPages = spec.NumbersEnabled ? [.. StampPageRange(spec.NumRange, n)] : [];
HashSet wmPages = spec.WmEnabled ? [.. StampPageRange(spec.WmRange, n)] : [];
int firstNumPage = int.MaxValue;
foreach (int p in numPages) if (p < firstNumPage) firstNumPage = p;
if (firstNumPage == int.MaxValue) firstNumPage = 0;
// Pre-fade the watermark image once (reused for all pages).
XImage? wmImg = null;
if (spec.WmEnabled && spec.WmIsImage && !string.IsNullOrEmpty(spec.WmImagePath) && System.IO.File.Exists(spec.WmImagePath))
wmImg = LoadStampImage(spec.WmImagePath!, spec.WmOpacity);
for (int i = 0; i < n && i < doc.PageCount; i++)
{
if (onlyPage.HasValue && i != onlyPage.Value) continue;
bool doNum = numPages.Contains(i);
bool doWm = wmPages.Contains(i);
if (!doNum && !doWm) continue;
var page = doc.Pages[i];
int rot = NormalizeRot(rotations, i, page);
double pwPt = page.Width.Point, phPt = page.Height.Point;
var (pw, ph) = VisualPageSize(pwPt, phPt, rot); // #169: lay out on the visual page
double mx = pw * 0.05, my = ph * 0.04;
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
if (VisualToPageMatrix(rot, pwPt, phPt) is XMatrix m) gfx.MultiplyTransform(m);
if (doWm) DrawWatermarkPdf(gfx, spec, pw, ph, mx, my, wmImg); // watermark first (underneath)
if (doNum) DrawNumberPdf(gfx, spec, i, firstNumPage, n, pw, ph, mx, my);
}
}
private static void DrawNumberPdf(XGraphics gfx, StampSpec spec, int pageIndex, int firstNumPage, int total, double pw, double ph, double mx, double my)
{
int number = spec.StartNumber + Math.Max(0, pageIndex - firstNumPage);
string text = (string.IsNullOrEmpty(spec.Format) ? "{n}" : spec.Format)
.Replace("{n}", number.ToString()).Replace("{N}", total.ToString());
if (text.Length == 0) return;
// #168: the format string is user text - a "Page {n}" written in Japanese or Bengali
// has to survive the save the same as an annotation does.
var font = new XFont(FontCoverage.PickFamily("Segoe UI", text), Math.Max(1, spec.NumFontPt), XFontStyle.Regular);
var c = spec.NumColor;
var brush = new XSolidBrush(XColor.FromArgb(255, c.R, c.G, c.B));
var size = gfx.MeasureString(text, font);
double w = size.Width, h = size.Height;
int posH = spec.NumPosH;
double x, y;
if (posH < 0) // custom
{
double cx = spec.NumCustomX;
if (spec.NumMirror && (pageIndex % 2 == 1)) cx = 1 - cx;
x = cx * pw - w / 2; y = spec.NumCustomY * ph - h / 2;
}
else
{
if (spec.NumMirror && posH != 1 && (pageIndex % 2 == 1)) posH = 2 - posH;
x = posH == 0 ? mx : posH == 2 ? pw - w - mx : (pw - w) / 2;
y = spec.NumPosV == 0 ? my : spec.NumPosV == 1 ? (ph - h) / 2 : ph - h - my;
}
gfx.DrawString(text, font, brush, new XRect(x, y, w, h), XStringFormats.TopLeft);
}
private static void DrawWatermarkPdf(XGraphics gfx, StampSpec spec, double pw, double ph, double mx, double my, XImage? img)
{
double w, h;
XFont? font = null;
if (spec.WmIsImage)
{
if (img is null) return;
w = pw * 0.5 * spec.WmScale;
h = w * img.PixelHeight / Math.Max(1, img.PixelWidth);
}
else
{
if (string.IsNullOrEmpty(spec.WmText)) return;
// #168: same as the page numbers - a watermark is user text in any script.
string wmWant = string.IsNullOrWhiteSpace(spec.WmFont) ? "Segoe UI" : spec.WmFont;
try { font = new XFont(FontCoverage.PickFamily(wmWant, spec.WmText), Math.Max(1, spec.WmFontPt), XFontStyle.Bold); }
catch { font = new XFont("Segoe UI", Math.Max(1, spec.WmFontPt), XFontStyle.Bold); }
var size = gfx.MeasureString(spec.WmText, font);
w = size.Width; h = size.Height;
}
double cx, cy;
if (spec.WmPosH < 0) { cx = spec.WmCustomX * pw; cy = spec.WmCustomY * ph; }
else
{
cx = spec.WmPosH == 0 ? mx + w / 2 : spec.WmPosH == 2 ? pw - mx - w / 2 : pw / 2;
cy = spec.WmPosV == 0 ? my + h / 2 : spec.WmPosV == 1 ? ph / 2 : ph - my - h / 2;
}
var state = gfx.Save();
gfx.TranslateTransform(cx, cy);
gfx.RotateTransform(-spec.WmAngle);
if (spec.WmIsImage)
{
gfx.DrawImage(img, -w / 2, -h / 2, w, h);
}
else
{
byte a = (byte)Math.Max(0, Math.Min(255, spec.WmOpacity * 255));
var c = spec.WmColor;
gfx.DrawString(spec.WmText, font, new XSolidBrush(XColor.FromArgb(a, c.R, c.G, c.B)), new XRect(-w / 2, -h / 2, w, h), XStringFormats.Center);
}
gfx.Restore(state);
}
// Loads a watermark image as an XImage, pre-faded to the requested opacity (PdfSharpCore has no
// per-draw image opacity, so we bake it into the pixels).
private static XImage? LoadStampImage(string path, double opacity)
{
try
{
byte[] bytes;
if (opacity >= 0.999)
{
bytes = System.IO.File.ReadAllBytes(path);
}
else
{
using var src = System.Drawing.Image.FromFile(path);
using var bmp = new System.Drawing.Bitmap(src.Width, src.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = System.Drawing.Graphics.FromImage(bmp))
{
var cm = new System.Drawing.Imaging.ColorMatrix { Matrix33 = (float)Math.Max(0, Math.Min(1, opacity)) };
using var ia = new System.Drawing.Imaging.ImageAttributes();
ia.SetColorMatrix(cm);
g.DrawImage(src, new System.Drawing.Rectangle(0, 0, src.Width, src.Height), 0, 0, src.Width, src.Height, System.Drawing.GraphicsUnit.Pixel, ia);
}
using var ms = new System.IO.MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
bytes = ms.ToArray();
}
return XImage.FromStream(() => new System.IO.MemoryStream(bytes));
}
catch { return null; }
}
}
}