diff --git a/src/UglyToad.PdfPig.Tests/Writer/PdfDocumentBuilderTests.cs b/src/UglyToad.PdfPig.Tests/Writer/PdfDocumentBuilderTests.cs index 4d4521373..70d455d37 100644 --- a/src/UglyToad.PdfPig.Tests/Writer/PdfDocumentBuilderTests.cs +++ b/src/UglyToad.PdfPig.Tests/Writer/PdfDocumentBuilderTests.cs @@ -1295,6 +1295,78 @@ public void CanCreateDocumentWithOutline() } } + [Fact] + public void CanAddLinkToPage() + { + var builder = new PdfDocumentBuilder(); + var page = builder.AddPage(PageSize.A4); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + + var linkArea = new PdfRectangle(25, 690, 200, 720); + page.AddLink("https://github.com", linkArea); + + var bytes = builder.Build(); + WriteFile(nameof(CanAddLinkToPage), bytes); + + using (var document = PdfDocument.Open(bytes)) + { + Assert.Equal(1, document.NumberOfPages); + var page1 = document.GetPage(1); + + var annotations = page1.GetAnnotations().ToList(); + Assert.Single(annotations); + + var linkAnnotation = annotations[0]; + Assert.Equal(Annotations.AnnotationType.Link, linkAnnotation.Type); + Assert.Equal(linkArea, linkAnnotation.Rectangle); + + // Verify the URI link target + Assert.NotNull(linkAnnotation.Action); + var uriAction = Assert.IsType(linkAnnotation.Action); + Assert.Equal("https://github.com", uriAction.Uri); + } + } + + [Fact] + public void CanAddInternalLinkToPage() + { + var builder = new PdfDocumentBuilder(); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + + var page1 = builder.AddPage(PageSize.A4); + var page2 = builder.AddPage(PageSize.A4); + + var linkArea = new PdfRectangle(25, 690, 200, 720); + var coordinates = new ExplicitDestinationCoordinates(25, 750); + var destination = new ExplicitDestination(1, ExplicitDestinationType.XyzCoordinates, coordinates); + page2.AddLink(destination, linkArea); + + var bytes = builder.Build(); + WriteFile(nameof(CanAddInternalLinkToPage), bytes); + + using (var document = PdfDocument.Open(bytes)) + { + Assert.Equal(2, document.NumberOfPages); + + var page2Doc = document.GetPage(2); + + var annotations = page2Doc.GetAnnotations().ToList(); + Assert.Single(annotations); + + var linkAnnotation = annotations[0]; + Assert.Equal(Annotations.AnnotationType.Link, linkAnnotation.Type); + Assert.Equal(linkArea, linkAnnotation.Rectangle); + + // Verify the link destination + Assert.NotNull(linkAnnotation.Action); + var goToAction = Assert.IsType(linkAnnotation.Action); + Assert.Equal(1, goToAction.Destination.PageNumber); + Assert.Equal(ExplicitDestinationType.XyzCoordinates, goToAction.Destination.Type); + Assert.Equal(25, goToAction.Destination.Coordinates.Left); + Assert.Equal(750, goToAction.Destination.Coordinates.Top); + } + } + private static void WriteFile(string name, byte[] bytes, string extension = "pdf") { try diff --git a/src/UglyToad.PdfPig/Writer/LinkAnnotation.cs b/src/UglyToad.PdfPig/Writer/LinkAnnotation.cs new file mode 100644 index 000000000..b1994e4d7 --- /dev/null +++ b/src/UglyToad.PdfPig/Writer/LinkAnnotation.cs @@ -0,0 +1,178 @@ +namespace UglyToad.PdfPig.Writer +{ + using UglyToad.PdfPig.Actions; + using UglyToad.PdfPig.Annotations; + using UglyToad.PdfPig.Core; + using UglyToad.PdfPig.Tokens; + + /// + /// Represents a link annotation that can be added to a PDF page. + /// Link annotations provide clickable areas that can trigger actions such as navigating to another page or opening a URL. + /// + public class LinkAnnotation + { + /// + /// Gets the border style for the link annotation. + /// This is overwritten by the if both are provided. + /// + public AnnotationBorder? AnnotationBorder { get; } + + /// + /// Gets the border style for the link annotation. + /// + public BorderStyle? Border { get; } + + /// + /// Gets the width of the border for the link annotation. + /// + public int? BorderWidth { get; } + + /// + /// Gets the rectangle defining the location and size of the link annotation on the page. + /// + public PdfRectangle Rect { get; } + + /// + /// Gets the quadrilaterals defining the clickable regions of the link. + /// These are typically used to define precise clickable areas that may not be rectangular. + /// + public IReadOnlyList QuadPoints { get; } + + /// + /// Gets the action to be performed when the link is activated. + /// + public PdfAction Action { get; } + + /// + /// Specifies the border style for a link annotation. + /// + public enum BorderStyle + { + /// + /// A solid border. + /// + Solid, + + /// + /// A dashed border. + /// + Dashed, + + /// + /// A simulated embossed border that appears to be raised above the surface of the page. + /// + Beveled, + + /// + /// A simulated engraved border that appears to be recessed below the surface of the page. + /// + Inset, + + /// + /// An underline border drawn along the bottom of the annotation rectangle. + /// + Underline, + } + + /// + /// Creates a new instance. + /// + /// The action to be performed when the link is activated. + /// The rectangle defining the location and size of the link on the page. + /// The border style for the link annotation. Optional, overwritten by . + /// The border style for the link annotation. Optional. + /// The width of the border for the link annotation. Optional. + /// The quadrilaterals defining the clickable regions. Optional. + public LinkAnnotation( + PdfAction action, + PdfRectangle rect, + AnnotationBorder? annotationBorder = null, + BorderStyle? borderStyle = null, + int? borderWidth = null, + IReadOnlyList? quadPoints = null) + { + Action = action; + Rect = rect; + AnnotationBorder = annotationBorder; + Border = borderStyle; + BorderWidth = borderWidth; + QuadPoints = quadPoints ?? new List(); + } + + /// + /// Converts this link annotation to a PDF dictionary token representation. + /// + /// A representing this link annotation in PDF format. + public DictionaryToken ToToken() + { + var dict = new Dictionary + { + [NameToken.Type] = NameToken.Annot, + [NameToken.Subtype] = NameToken.Link, + [NameToken.Rect] = new ArrayToken([ + new NumericToken(Rect.BottomLeft.X), + new NumericToken(Rect.BottomLeft.Y), + new NumericToken(Rect.TopRight.X), + new NumericToken(Rect.TopRight.Y) + ]), + }; + + if (QuadPoints.Count > 0) + { + var quadPointsArray = new List(); + foreach (var quad in QuadPoints) + { + foreach (var point in quad.Points) + { + quadPointsArray.Add(new NumericToken(point.X)); + quadPointsArray.Add(new NumericToken(point.Y)); + } + } + + dict.Add(NameToken.Quadpoints, new ArrayToken(quadPointsArray)); + } + + if (AnnotationBorder != null) + { + var borderArray = new List + { + new NumericToken(AnnotationBorder.HorizontalCornerRadius), + new NumericToken(AnnotationBorder.VerticalCornerRadius), + new NumericToken(AnnotationBorder.BorderWidth), + }; + + if (AnnotationBorder.LineDashPattern != null && AnnotationBorder.LineDashPattern.Count > 0) + { + var dashArray = new List(); + foreach (var dash in AnnotationBorder.LineDashPattern) + { + dashArray.Add(new NumericToken(dash)); + } + borderArray.Add(new ArrayToken(dashArray)); + } + dict.Add(NameToken.Border, new ArrayToken(borderArray)); + } + + if (Border != null) + { + dict.Add(NameToken.Bs, new DictionaryToken(new Dictionary + { + [NameToken.S] = Border switch + { + BorderStyle.Solid => NameToken.S, + BorderStyle.Dashed => NameToken.D, + BorderStyle.Beveled => NameToken.B, + BorderStyle.Inset => NameToken.I, + BorderStyle.Underline => NameToken.U, + _ => NameToken.S, + }, + [NameToken.W] = new NumericToken(BorderWidth ?? 1) + })); + } + + + + return new DictionaryToken(dict); + } + } +} \ No newline at end of file diff --git a/src/UglyToad.PdfPig/Writer/PdfPageBuilder.cs b/src/UglyToad.PdfPig/Writer/PdfPageBuilder.cs index ff192d7cd..c3bacccdc 100644 --- a/src/UglyToad.PdfPig/Writer/PdfPageBuilder.cs +++ b/src/UglyToad.PdfPig/Writer/PdfPageBuilder.cs @@ -16,6 +16,7 @@ using Graphics.Operations.TextPositioning; using Graphics.Operations.TextShowing; using Graphics.Operations.TextState; + using Outline.Destinations; using Images; using PdfFonts; using Tokens; @@ -94,7 +95,7 @@ public class PdfPageBuilder private IPageContentStream currentStream; // links to be resolved when all page references are available - internal readonly List<(DictionaryToken token, PdfAction action)>? links; + internal readonly List<(DictionaryToken token, PdfAction action)> links = []; // maps fonts added using PdfDocumentBuilder to page font names private readonly Dictionary documentFonts = new Dictionary(); @@ -827,6 +828,39 @@ public AddedImage AddPng(Stream pngStream, PdfRectangle placementRectangle = def return new AddedImage(reference.Data, png.Width, png.Height); } + /// + /// Adds a URL link annotation to the page at the specified rectangle area. + /// + /// The URL to link to + /// The rectangular area on the page that will be clickable + /// This page builder for method chaining + public PdfPageBuilder AddLink(string url, PdfRectangle linkArea) + { + return AddLink(new LinkAnnotation(new UriAction(url), linkArea)); + } + + /// + /// Adds an internal document link annotation to the page at the specified rectangle area. + /// + /// The destination within the current document to link to + /// The rectangular area on the page that will be clickable + /// This page builder for method chaining + public PdfPageBuilder AddLink(ExplicitDestination destination, PdfRectangle linkArea) + { + return AddLink(new LinkAnnotation(new GoToAction(destination), linkArea)); + } + + /// + /// Adds a link annotation to the page. + /// + /// The link annotation to add + /// This page builder for method chaining + public PdfPageBuilder AddLink(LinkAnnotation link) + { + links.Add((link.ToToken(), link.Action)); + return this; + } + /// /// Copy a page from unknown source to this page ///