Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/UglyToad.PdfPig.Tests/Writer/PdfDocumentBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Actions.UriAction>(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<Actions.GoToAction>(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
Expand Down
178 changes: 178 additions & 0 deletions src/UglyToad.PdfPig/Writer/LinkAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
namespace UglyToad.PdfPig.Writer
{
using UglyToad.PdfPig.Actions;
using UglyToad.PdfPig.Annotations;
using UglyToad.PdfPig.Core;
using UglyToad.PdfPig.Tokens;

/// <summary>
/// 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.
/// </summary>
public class LinkAnnotation
{
/// <summary>
/// Gets the border style for the link annotation.
/// This is overwritten by the <see cref="AnnotationBorder"/> if both are provided.
/// </summary>
public AnnotationBorder? AnnotationBorder { get; }

/// <summary>
/// Gets the border style for the link annotation.
/// </summary>
public BorderStyle? Border { get; }

/// <summary>
/// Gets the width of the border for the link annotation.
/// </summary>
public int? BorderWidth { get; }

/// <summary>
/// Gets the rectangle defining the location and size of the link annotation on the page.
/// </summary>
public PdfRectangle Rect { get; }

/// <summary>
/// Gets the quadrilaterals defining the clickable regions of the link.
/// These are typically used to define precise clickable areas that may not be rectangular.
/// </summary>
public IReadOnlyList<QuadPointsQuadrilateral> QuadPoints { get; }

/// <summary>
/// Gets the action to be performed when the link is activated.
/// </summary>
public PdfAction Action { get; }

/// <summary>
/// Specifies the border style for a link annotation.
/// </summary>
public enum BorderStyle
{
/// <summary>
/// A solid border.
/// </summary>
Solid,

/// <summary>
/// A dashed border.
/// </summary>
Dashed,

/// <summary>
/// A simulated embossed border that appears to be raised above the surface of the page.
/// </summary>
Beveled,

/// <summary>
/// A simulated engraved border that appears to be recessed below the surface of the page.
/// </summary>
Inset,

/// <summary>
/// An underline border drawn along the bottom of the annotation rectangle.
/// </summary>
Underline,
}

/// <summary>
/// Creates a new <see cref="LinkAnnotation"/> instance.
/// </summary>
/// <param name="action">The action to be performed when the link is activated.</param>
/// <param name="rect">The rectangle defining the location and size of the link on the page.</param>
/// <param name="annotationBorder">The border style for the link annotation. Optional, overwritten by <see cref="Border"/>.</param>
/// <param name="borderStyle">The border style for the link annotation. Optional.</param>
/// <param name="borderWidth">The width of the border for the link annotation. Optional.</param>
/// <param name="quadPoints">The quadrilaterals defining the clickable regions. Optional.</param>
public LinkAnnotation(
PdfAction action,
PdfRectangle rect,
AnnotationBorder? annotationBorder = null,
BorderStyle? borderStyle = null,
int? borderWidth = null,
IReadOnlyList<QuadPointsQuadrilateral>? quadPoints = null)
{
Action = action;
Rect = rect;
AnnotationBorder = annotationBorder;
Border = borderStyle;
BorderWidth = borderWidth;
QuadPoints = quadPoints ?? new List<QuadPointsQuadrilateral>();
}

/// <summary>
/// Converts this link annotation to a PDF dictionary token representation.
/// </summary>
/// <returns>A <see cref="DictionaryToken"/> representing this link annotation in PDF format.</returns>
public DictionaryToken ToToken()
{
var dict = new Dictionary<NameToken, IToken>
{
[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<NumericToken>();
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<IToken>
{
new NumericToken(AnnotationBorder.HorizontalCornerRadius),
new NumericToken(AnnotationBorder.VerticalCornerRadius),
new NumericToken(AnnotationBorder.BorderWidth),
};

if (AnnotationBorder.LineDashPattern != null && AnnotationBorder.LineDashPattern.Count > 0)
{
var dashArray = new List<NumericToken>();
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, IToken>
{
[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);
}
}
}
36 changes: 35 additions & 1 deletion src/UglyToad.PdfPig/Writer/PdfPageBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Guid, NameToken> documentFonts = new Dictionary<Guid, NameToken>();
Expand Down Expand Up @@ -827,6 +828,39 @@ public AddedImage AddPng(Stream pngStream, PdfRectangle placementRectangle = def
return new AddedImage(reference.Data, png.Width, png.Height);
}

/// <summary>
/// Adds a URL link annotation to the page at the specified rectangle area.
/// </summary>
/// <param name="url">The URL to link to</param>
/// <param name="linkArea">The rectangular area on the page that will be clickable</param>
/// <returns>This page builder for method chaining</returns>
public PdfPageBuilder AddLink(string url, PdfRectangle linkArea)
{
return AddLink(new LinkAnnotation(new UriAction(url), linkArea));
}

/// <summary>
/// Adds an internal document link annotation to the page at the specified rectangle area.
/// </summary>
/// <param name="destination">The destination within the current document to link to</param>
/// <param name="linkArea">The rectangular area on the page that will be clickable</param>
/// <returns>This page builder for method chaining</returns>
public PdfPageBuilder AddLink(ExplicitDestination destination, PdfRectangle linkArea)
{
return AddLink(new LinkAnnotation(new GoToAction(destination), linkArea));
}

/// <summary>
/// Adds a link annotation to the page.
/// </summary>
/// <param name="link">The link annotation to add</param>
/// <returns>This page builder for method chaining</returns>
public PdfPageBuilder AddLink(LinkAnnotation link)
{
links.Add((link.ToToken(), link.Action));
return this;
}

/// <summary>
/// Copy a page from unknown source to this page
/// </summary>
Expand Down