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
19 changes: 19 additions & 0 deletions samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,25 @@ private static void _writePdf(string fileName, bool enableSubsetting = true)

page.Content.AddImage(forestImage, matrix);
}
})
// Add hyperlink
.AddPage(page =>
{
page.MediaBox = mediaBox;
page.TrimBox = trimBox;

using (var forestStream = File.OpenRead("Pexels_com/android-wallpaper-art-backlit-1114897.jpg"))
using (var forestImage = SixLabors.ImageSharp.Image.Load<Rgba32>(forestStream))
{
var scale = (double)forestImage.Width / forestImage.Height;

var matrix = Matrix.CreateScaleMatrix(new Value(scale * 303, Unit.Millimeters).AsRaw(Unit.Points), new Value(303, Unit.Millimeters).AsRaw(Unit.Points))
.Translate(new Value(-100, Unit.Millimeters).AsRaw(Unit.Points), new Value(0, Unit.Millimeters).AsRaw(Unit.Points));

page.Content.AddImage(forestImage, matrix);

page.AddHyperlink(new Uri("https://www.github.com/synercoder/fileformats.pdf"), new Rectangle(new Point(Mm(-100), Mm(0)), new Size(scale * 303, 303, Unit.Millimeters)));
}
});

using (var blurStream = File.OpenRead("Pexels_com/4k-wallpaper-blur-bokeh-1484253.jpg"))
Expand Down
47 changes: 47 additions & 0 deletions src/Synercoding.FileFormats.Pdf/Generation/WriteablePage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Synercoding.FileFormats.Pdf.Generation;
public class WriteablePage : IDisposable
{
private readonly TableBuilder _tableBuilder;
private readonly List<PdfObject<PdfDictionary>> _annotations = [];

internal WriteablePage(TableBuilder tableBuilder, CachedResources cachedResources, int pageNumber)
{
Expand All @@ -26,6 +27,9 @@ internal WriteablePage(TableBuilder tableBuilder, CachedResources cachedResource

internal PageResources Resources { get; }

internal IReadOnlyList<PdfObject<PdfDictionary>> Annotations
=> _annotations;

/// <summary>
/// The rotation of how the page is displayed, must be in increments of 90
/// </summary>
Expand Down Expand Up @@ -77,6 +81,47 @@ public PageRotation? Rotation
/// </summary>
public IPageContentContext Content { get; }

/// <summary>
/// Adds a clickable hyperlink annotation to the page that opens an external URI.
/// </summary>
/// <param name="uri">The absolute URI to open. Must be an absolute URI.</param>
/// <param name="rectangle">
/// The clickable area, expressed in default user space. This is <b>not</b> affected by any
/// transformation applied through <see cref="Content"/>.
/// </param>
/// <returns>Returns this <see cref="WriteablePage"/> to chain calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="uri"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="uri"/> is not an absolute URI.</exception>
public WriteablePage AddHyperlink(Uri uri, Rectangle rectangle)
{
ArgumentNullException.ThrowIfNull(uri);
if (!uri.IsAbsoluteUri)
throw new ArgumentException("The provided uri must be an absolute uri.", nameof(uri));

var annotation = new PdfDictionary()
{
[PdfNames.Type] = PdfNames.Annot,
[PdfNames.Subtype] = PdfNames.Link,
[PdfNames.Rect] = rectangle.ToArray(),
[PdfNames.Border] = new PdfArray(new[] { 0, 0, 0 }), // no visible border
[PdfNames.F] = new PdfNumber(4), // Print flag
[PdfNames.A] = new PdfDictionary()
{
[PdfNames.S] = PdfNames.URI,
// Written as a hex string so characters like '(' ')' '\' in the uri can not corrupt the string literal.
[PdfNames.URI] = new PdfString(System.Text.Encoding.ASCII.GetBytes(uri.AbsoluteUri), isHex: true),
}
};

_annotations.Add(new PdfObject<PdfDictionary>()
{
Id = _tableBuilder.ReserveId(),
Value = annotation
});

return this;
}

internal PdfDictionary ToDictionary()
{
var dictionary = new PdfDictionary()
Expand All @@ -97,6 +142,8 @@ internal PdfDictionary ToDictionary()
dictionary[PdfNames.ArtBox] = ArtBox.Value.ToArray();
if (Rotation.HasValue)
dictionary[PdfNames.Rotate] = new PdfNumber((int)Rotation.Value);
if (_annotations.Count > 0)
dictionary[PdfNames.Annots] = new PdfArray(_annotations.Select(a => a.Id.GetReference()).Cast<IPdfPrimitive>().ToArray());

return dictionary;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Synercoding.FileFormats.Pdf/PdfWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ private void _processPage(WriteablePage page)
Value = streamObj
});

// Write annotations
foreach (var annotation in page.Annotations)
_objectWriter.Write(annotation);

_pages.Add(page.ToDictionary());
}

Expand Down
18 changes: 18 additions & 0 deletions src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ public static PdfName AESV2
=> PdfName.Get(nameof(AESV2));
public static PdfName AESV3
=> PdfName.Get(nameof(AESV3));
public static PdfName A
=> PdfName.Get(nameof(A));
public static PdfName Annot
=> PdfName.Get(nameof(Annot));
public static PdfName Annots
=> PdfName.Get(nameof(Annots));
public static PdfName ASCII85Decode
=> PdfName.Get(nameof(ASCII85Decode));
public static PdfName ASCIIHexDecode
Expand All @@ -32,6 +38,8 @@ public static PdfName BleedBox
=> PdfName.Get(nameof(BleedBox));
public static PdfName BM
=> PdfName.Get(nameof(BM));
public static PdfName Border
=> PdfName.Get(nameof(Border));
public static PdfName CA
=> PdfName.Get(nameof(CA));
public static PdfName ca
Expand Down Expand Up @@ -114,6 +122,8 @@ public static PdfName Exclusion
=> PdfName.Get(nameof(Exclusion));
public static PdfName ExtGState
=> PdfName.Get(nameof(ExtGState));
public static PdfName F
=> PdfName.Get(nameof(F));
public static PdfName Filter
=> PdfName.Get(nameof(Filter));
public static PdfName First
Expand Down Expand Up @@ -168,6 +178,8 @@ public static PdfName Length1
=> PdfName.Get(nameof(Length1));
public static PdfName Lighten
=> PdfName.Get(nameof(Lighten));
public static PdfName Link
=> PdfName.Get(nameof(Link));
public static PdfName Luminosity
=> PdfName.Get(nameof(Luminosity));
public static PdfName LZWDecode
Expand Down Expand Up @@ -232,6 +244,8 @@ public static PdfName Properties
=> PdfName.Get(nameof(Properties));
public static PdfName R
=> PdfName.Get(nameof(R));
public static PdfName Rect
=> PdfName.Get(nameof(Rect));
public static PdfName Registry
=> PdfName.Get(nameof(Registry));
public static PdfName Resources
Expand All @@ -242,6 +256,8 @@ public static PdfName Rotate
=> PdfName.Get(nameof(Rotate));
public static PdfName RunLengthDecode
=> PdfName.Get(nameof(RunLengthDecode));
public static PdfName S
=> PdfName.Get(nameof(S));
public static PdfName Saturation
=> PdfName.Get(nameof(Saturation));
public static PdfName Screen
Expand Down Expand Up @@ -298,6 +314,8 @@ public static PdfName U
=> PdfName.Get(nameof(U));
public static PdfName UE
=> PdfName.Get(nameof(UE));
public static PdfName URI
=> PdfName.Get(nameof(URI));
public static PdfName UseAttachments
=> PdfName.Get(nameof(UseAttachments));
public static PdfName UseNone
Expand Down
48 changes: 48 additions & 0 deletions tests/Synercoding.FileFormats.Pdf.Tests/PdfWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,52 @@ public void Test_PdfWriter_WithOnlyPageLayout_WritesOnlyPageLayout()
Assert.DoesNotContain("/PageMode", pdfContent);
Assert.Contains("/PageLayout/TwoPageRight", pdfContent);
}

[Fact]
public void Test_PdfWriter_AddHyperlink_WritesLinkAnnotation()
{
// Arrange
using var stream = new MemoryStream();

// Act
using (var writer = new PdfWriter(stream))
{
writer.AddPage(page =>
{
page.MediaBox = Sizes.A4.AsRectangle();
page.AddHyperlink(new Uri("https://example.com/"), new Rectangle(100, 100, 200, 130, Unit.Points));
});
}

// Assert
stream.Position = 0;
var pdfContent = System.Text.Encoding.ASCII.GetString(stream.ToArray());

Assert.Contains("/Annots", pdfContent); // page references the annotation
Assert.Contains("/Subtype/Link", pdfContent); // it is a link annotation
Assert.Contains("/S/URI", pdfContent); // with a URI action
Assert.Contains("/Border[0 0 0]", pdfContent); // no visible border
Assert.Contains("/F 4", pdfContent); // print flag
// The uri itself is written as a hex string, so assert on the key rather than the raw url.
Assert.Contains("/URI<", pdfContent);
}

[Fact]
public void Test_PdfWriter_AddHyperlink_WithRelativeUri_Throws()
{
// Arrange
using var stream = new MemoryStream();

// Act & Assert
// Note: the writer is intentionally not disposed here. A pageAction that throws leaves the
// page's reserved objects unwritten, which would make writing the trailer (on Dispose) fail.
var writer = new PdfWriter(stream);
Assert.Throws<ArgumentException>(() =>
{
writer.AddPage(page =>
{
page.AddHyperlink(new Uri("/relative", UriKind.Relative), new Rectangle(0, 0, 10, 10, Unit.Points));
});
});
}
}
Loading