diff --git a/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs b/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs index 4e627a6..75bbd4c 100644 --- a/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs +++ b/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs @@ -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(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")) diff --git a/src/Synercoding.FileFormats.Pdf/Generation/WriteablePage.cs b/src/Synercoding.FileFormats.Pdf/Generation/WriteablePage.cs index 1dd64aa..276a83e 100644 --- a/src/Synercoding.FileFormats.Pdf/Generation/WriteablePage.cs +++ b/src/Synercoding.FileFormats.Pdf/Generation/WriteablePage.cs @@ -12,6 +12,7 @@ namespace Synercoding.FileFormats.Pdf.Generation; public class WriteablePage : IDisposable { private readonly TableBuilder _tableBuilder; + private readonly List> _annotations = []; internal WriteablePage(TableBuilder tableBuilder, CachedResources cachedResources, int pageNumber) { @@ -26,6 +27,9 @@ internal WriteablePage(TableBuilder tableBuilder, CachedResources cachedResource internal PageResources Resources { get; } + internal IReadOnlyList> Annotations + => _annotations; + /// /// The rotation of how the page is displayed, must be in increments of 90 /// @@ -77,6 +81,47 @@ public PageRotation? Rotation /// public IPageContentContext Content { get; } + /// + /// Adds a clickable hyperlink annotation to the page that opens an external URI. + /// + /// The absolute URI to open. Must be an absolute URI. + /// + /// The clickable area, expressed in default user space. This is not affected by any + /// transformation applied through . + /// + /// Returns this to chain calls. + /// Thrown when is . + /// Thrown when is not an absolute URI. + 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() + { + Id = _tableBuilder.ReserveId(), + Value = annotation + }); + + return this; + } + internal PdfDictionary ToDictionary() { var dictionary = new PdfDictionary() @@ -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().ToArray()); return dictionary; } diff --git a/src/Synercoding.FileFormats.Pdf/PdfWriter.cs b/src/Synercoding.FileFormats.Pdf/PdfWriter.cs index 0c5312a..8c841b4 100644 --- a/src/Synercoding.FileFormats.Pdf/PdfWriter.cs +++ b/src/Synercoding.FileFormats.Pdf/PdfWriter.cs @@ -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()); } diff --git a/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs b/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs index 4b43947..53fd2a7 100644 --- a/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs +++ b/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/Synercoding.FileFormats.Pdf.Tests/PdfWriterTests.cs b/tests/Synercoding.FileFormats.Pdf.Tests/PdfWriterTests.cs index 44145e9..a903c40 100644 --- a/tests/Synercoding.FileFormats.Pdf.Tests/PdfWriterTests.cs +++ b/tests/Synercoding.FileFormats.Pdf.Tests/PdfWriterTests.cs @@ -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(() => + { + writer.AddPage(page => + { + page.AddHyperlink(new Uri("/relative", UriKind.Relative), new Rectangle(0, 0, 10, 10, Unit.Points)); + }); + }); + } }