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/Content/BlendMode.cs b/src/Synercoding.FileFormats.Pdf/Content/BlendMode.cs new file mode 100644 index 0000000..1faf32d --- /dev/null +++ b/src/Synercoding.FileFormats.Pdf/Content/BlendMode.cs @@ -0,0 +1,106 @@ +namespace Synercoding.FileFormats.Pdf.Content; + +/// +/// Specifies the blend mode to be used in the transparent imaging model. +/// +public enum BlendMode +{ + /// + /// Selects the source color, ignoring the backdrop. + /// + Normal, + /// + /// Multiplies the backdrop and source color values. + /// + /// + /// The result color is always at least as dark as either of the two constituent + /// colors. Multiplying any color with black produces black; multiplying with + /// white leaves the original color unchanged. Painting successive overlapping + /// objects with a color other than black or white produces progressively darker + /// colors. + /// + Multiply, + /// + /// Multiplies the complements of the backdrop and source color values, + /// then complements the result + /// + /// + /// The result color is always at least as light as either of the two constituent + /// colors. Screening any color with white produces white; screening with black + /// leaves the original color unchanged. The effect is similar to projecting + /// multiple photographic slides simultaneously onto a single screen. + /// + Screen, + /// + /// Multiplies or screens the colors, depending on the backdrop color value. + /// Source colors overlay the backdrop while preserving its highlights and + /// shadows. The backdrop color is not replaced but is mixed with the source + /// color to reflect the lightness or darkness of the backdrop. + /// + Overlay, + /// + /// Selects the darker of the backdrop and source colors. + /// + /// + /// The backdrop is replaced with the source where the source is darker; + /// otherwise, it is left unchanged. + /// + Darken, + /// + /// Selects the lighter of the backdrop and source colors. + /// + /// + /// The backdrop is replaced with the source where the source is lighter; + /// otherwise, it is left unchanged. + /// + Lighten, + /// + /// Brightens the backdrop color to reflect the source color. + /// Painting with black produces no changes. + /// + ColorDodge, + /// + /// Darkens the backdrop color to reflect the source color. + /// Painting with white produces no change. + /// + ColorBurn, + /// + /// Multiplies or screens the colors, depending on the source color value. + /// The effect is similar to shining a harsh spotlight on the backdrop. + /// + HardLight, + /// + /// Darkens or lightens the colors, depending on the source color value. + /// The effect is similar to shining a diffused spotlight on the backdrop. + /// + SoftLight, + /// + /// Subtracts the darker of the two constituent colors from the lighter color. + /// + /// + /// Painting with white inverts the backdrop color; + /// painting with black produces no change. + /// + Difference, + /// + /// Produces an effect similar to that of the mode but lower in contrast. + /// Painting with white inverts the backdrop color; painting with black produces no change. + /// + Exclusion, + /// + /// Creates a color with the hue of the source color and the saturation and luminosity of the backdrop color. + /// + Hue, + /// + /// Creates a color with the saturation of the source color and the hue and luminosity of the backdrop color. Painting with this mode in an area of the backdrop that is a pure gray (no saturation) produces no change. + /// + Saturation, + /// + /// Creates a color with the hue and saturation of the source color and the luminosity of the backdrop color. This preserves the gray levels of the backdrop and is useful for coloring monochrome images or tinting color images. + /// + Color, + /// + /// Creates a color with the luminosity of the source color and the hue and saturation of the backdrop color. This produces an inverse effect to that of the mode. + /// + Luminosity +} diff --git a/src/Synercoding.FileFormats.Pdf/Content/ExtendedGraphicsState.cs b/src/Synercoding.FileFormats.Pdf/Content/ExtendedGraphicsState.cs index 27ff9ef..d9efaad 100644 --- a/src/Synercoding.FileFormats.Pdf/Content/ExtendedGraphicsState.cs +++ b/src/Synercoding.FileFormats.Pdf/Content/ExtendedGraphicsState.cs @@ -1,3 +1,4 @@ +using Synercoding.FileFormats.Pdf.Content.Extensions; using Synercoding.FileFormats.Pdf.Primitives; namespace Synercoding.FileFormats.Pdf.Content; @@ -22,6 +23,43 @@ public sealed record class ExtendedGraphicsState /// public bool? OverprintNonStroking { get; init; } + /// + /// The current stroking alpha constant, specifying the constant shape or constant opacity value + /// to be used for stroking operations in the transparent imaging model. + /// + public double? CurrentAlphaConstantStroking + { + get; + init + { + if (value < 0 || value > 1) + throw new ArgumentOutOfRangeException(nameof(CurrentAlphaConstantStroking), "Value must be between 0 and 1."); + + field = value; + } + } + + /// + /// The current non-stroking alpha constant, specifying the constant shape or constant opacity value + /// to be used for non-stroking operations in the transparent imaging model. + /// + public double? CurrentAlphaConstantNonStroking + { + get; + init + { + if (value < 0 || value > 1) + throw new ArgumentOutOfRangeException(nameof(CurrentAlphaConstantNonStroking), "Value must be between 0 and 1."); + + field = value; + } + } + + /// + /// The current blend mode to be used in the transparent imaging model. + /// + public BlendMode? BlendMode { get; init; } + internal IPdfDictionary ToPdfDictionary() { var dictionary = new PdfDictionary() @@ -33,8 +71,13 @@ internal IPdfDictionary ToPdfDictionary() dictionary[PdfNames.OP] = new PdfBoolean(Overprint.Value); if (OverprintNonStroking.HasValue) dictionary[PdfNames.op] = new PdfBoolean(OverprintNonStroking.Value); + if (CurrentAlphaConstantStroking.HasValue) + dictionary[PdfNames.CA] = new PdfNumber(CurrentAlphaConstantStroking.Value); + if (CurrentAlphaConstantNonStroking.HasValue) + dictionary[PdfNames.ca] = new PdfNumber(CurrentAlphaConstantNonStroking.Value); + if (BlendMode.HasValue) + dictionary[PdfNames.BM] = BlendMode.Value.ToPdfName(); return dictionary; } } - diff --git a/src/Synercoding.FileFormats.Pdf/Content/Extensions/BlendModeExtensions.cs b/src/Synercoding.FileFormats.Pdf/Content/Extensions/BlendModeExtensions.cs new file mode 100644 index 0000000..f16692b --- /dev/null +++ b/src/Synercoding.FileFormats.Pdf/Content/Extensions/BlendModeExtensions.cs @@ -0,0 +1,28 @@ +using Synercoding.FileFormats.Pdf.Primitives; + +namespace Synercoding.FileFormats.Pdf.Content.Extensions; + +internal static class BlendModeExtensions +{ + public static PdfName ToPdfName(this BlendMode blendMode) + => blendMode switch + { + BlendMode.Normal => PdfNames.Normal, + BlendMode.Multiply => PdfNames.Multiply, + BlendMode.Screen => PdfNames.Screen, + BlendMode.Overlay => PdfNames.Overlay, + BlendMode.Darken => PdfNames.Darken, + BlendMode.Lighten => PdfNames.Lighten, + BlendMode.ColorDodge => PdfNames.ColorDodge, + BlendMode.ColorBurn => PdfNames.ColorBurn, + BlendMode.HardLight => PdfNames.HardLight, + BlendMode.SoftLight => PdfNames.SoftLight, + BlendMode.Difference => PdfNames.Difference, + BlendMode.Exclusion => PdfNames.Exclusion, + BlendMode.Hue => PdfNames.Hue, + BlendMode.Saturation => PdfNames.Saturation, + BlendMode.Color => PdfNames.Color, + BlendMode.Luminosity => PdfNames.Luminosity, + _ => throw new ArgumentOutOfRangeException(nameof(blendMode), blendMode, null) + }; +} 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/PackageDetails.props b/src/Synercoding.FileFormats.Pdf/PackageDetails.props index 66c138d..60b217e 100644 --- a/src/Synercoding.FileFormats.Pdf/PackageDetails.props +++ b/src/Synercoding.FileFormats.Pdf/PackageDetails.props @@ -10,7 +10,7 @@ Synercoding.FileFormats.Pdf Synercoding.FileFormats.Pdf Contains classes which makes it easy to quickly create a pdf file. - Changed GrayScaleMethod from an enum to a delegate, giving users full control over how separation images are converted to grayscale (breaking change). Fixed issue #87 where CID text bytes containing 0x0D were incorrectly unescaped in string literals, causing parsers to misread them as 0x0A and render the wrong glyph; string literals now escape more characters and CID text in content streams is written as hex strings. + Added support for the current alpha constant and blend mode via the extended graphic state. And added basic support for clickable hyperlinks on a page. README.md LICENSE 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/PdfName.cs b/src/Synercoding.FileFormats.Pdf/Primitives/PdfName.cs index 8027b6a..4c12755 100644 --- a/src/Synercoding.FileFormats.Pdf/Primitives/PdfName.cs +++ b/src/Synercoding.FileFormats.Pdf/Primitives/PdfName.cs @@ -23,6 +23,9 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "BitsPerComponent", new PdfName("BitsPerComponent") }, { "Black", new PdfName("Black") }, { "BleedBox", new PdfName("BleedBox") }, + { "BM", new PdfName("BM") }, + { "CA", new PdfName("CA") }, + { "ca", new PdfName("ca") }, { "CapHeight", new PdfName("CapHeight") }, { "Catalog", new PdfName("Catalog") }, { "CCITTFaxDecode", new PdfName("CCITTFaxDecode") }, @@ -31,6 +34,9 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "CIDFontType2", new PdfName("CIDFontType2") }, { "CIDSystemInfo", new PdfName("CIDSystemInfo") }, { "CIDToGIDMap", new PdfName("CIDToGIDMap") }, + { "Color", new PdfName("Color") }, + { "ColorBurn", new PdfName("ColorBurn") }, + { "ColorDodge", new PdfName("ColorDodge") }, { "ColorSpace", new PdfName("ColorSpace") }, { "Colors", new PdfName("Colors") }, { "Columns", new PdfName("Columns") }, @@ -41,6 +47,7 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "CropBox", new PdfName("CropBox") }, { "Crypt", new PdfName("Crypt") }, { "Cyan", new PdfName("Cyan") }, + { "Darken", new PdfName("Darken") }, { "DCTDecode", new PdfName("DCTDecode") }, { "Decode", new PdfName("Decode") }, { "DecodeParms", new PdfName("DecodeParms") }, @@ -50,11 +57,13 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "DeviceCMYK", new PdfName("DeviceCMYK") }, { "DeviceGray", new PdfName("DeviceGray") }, { "DeviceRGB", new PdfName("DeviceRGB") }, + { "Difference", new PdfName("Difference") }, { "EarlyChange", new PdfName("EarlyChange") }, { "Encoding", new PdfName("Encoding") }, { "EFF", new PdfName("EFF") }, { "Encrypt", new PdfName("Encrypt") }, { "EncryptMetadata", new PdfName("EncryptMetadata") }, + { "Exclusion", new PdfName("Exclusion") }, { "ExtGState", new PdfName("ExtGState") }, { "Filter", new PdfName("Filter") }, { "First", new PdfName("First") }, @@ -66,7 +75,9 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "FlateDecode", new PdfName("FlateDecode") }, { "Font", new PdfName("Font") }, { "FullScreen", new PdfName("FullScreen") }, + { "HardLight", new PdfName("HardLight") }, { "Height", new PdfName("Height") }, + { "Hue", new PdfName("Hue") }, { "ID", new PdfName("ID") }, { "Identity", new PdfName("Identity") }, { "Identity-H", new PdfName("Identity-H") }, @@ -80,13 +91,17 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "Kids", new PdfName("Kids") }, { "Length", new PdfName("Length") }, { "Length1", new PdfName("Length1") }, + { "Lighten", new PdfName("Lighten") }, + { "Luminosity", new PdfName("Luminosity") }, { "LZWDecode", new PdfName("LZWDecode") }, { "Magenta", new PdfName("Magenta") }, { "MediaBox", new PdfName("MediaBox") }, { "Metadata", new PdfName("Metadata") }, { "ModDate", new PdfName("ModDate") }, + { "Multiply", new PdfName("Multiply") }, { "N", new PdfName("N") }, { "None", new PdfName("None") }, + { "Normal", new PdfName("Normal") }, { "O", new PdfName("O") }, { "ObjStm", new PdfName("ObjStm") }, { "OE", new PdfName("OE") }, @@ -94,6 +109,7 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "OP", new PdfName("OP") }, { "op", new PdfName("op") }, { "Ordering", new PdfName("Ordering") }, + { "Overlay", new PdfName("Overlay") }, { "P", new PdfName("P") }, { "Page", new PdfName("Page") }, { "PageLayout", new PdfName("PageLayout") }, @@ -113,11 +129,14 @@ public sealed class PdfName : IPdfPrimitive, IEquatable { "Root", new PdfName("Root") }, { "Rotate", new PdfName("Rotate") }, { "RunLengthDecode", new PdfName("RunLengthDecode") }, + { "Saturation", new PdfName("Saturation") }, + { "Screen", new PdfName("Screen") }, { "Separation", new PdfName("Separation") }, { "Shading", new PdfName("Shading") }, { "SinglePage", new PdfName("SinglePage") }, { "Size", new PdfName("Size") }, { "SMask", new PdfName("SMask") }, + { "SoftLight", new PdfName("SoftLight") }, { "Standard", new PdfName("Standard") }, { "StemV", new PdfName("StemV") }, { "StmF", new PdfName("StmF") }, diff --git a/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs b/src/Synercoding.FileFormats.Pdf/Primitives/PdfNames.cs index 8905634..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 @@ -30,6 +36,14 @@ public static PdfName Black => PdfName.Get(nameof(Black)); 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 + => PdfName.Get(nameof(ca)); public static PdfName CapHeight => PdfName.Get(nameof(CapHeight)); public static PdfName Catalog @@ -46,6 +60,12 @@ public static PdfName CIDSystemInfo => PdfName.Get(nameof(CIDSystemInfo)); public static PdfName CIDToGIDMap => PdfName.Get(nameof(CIDToGIDMap)); + public static PdfName Color + => PdfName.Get(nameof(Color)); + public static PdfName ColorBurn + => PdfName.Get(nameof(ColorBurn)); + public static PdfName ColorDodge + => PdfName.Get(nameof(ColorDodge)); public static PdfName ColorSpace => PdfName.Get(nameof(ColorSpace)); public static PdfName Colors @@ -66,6 +86,8 @@ public static PdfName Crypt => PdfName.Get(nameof(Crypt)); public static PdfName Cyan => PdfName.Get(nameof(Cyan)); + public static PdfName Darken + => PdfName.Get(nameof(Darken)); public static PdfName DCTDecode => PdfName.Get(nameof(DCTDecode)); public static PdfName Decode @@ -82,6 +104,8 @@ public static PdfName DeviceGray => PdfName.Get(nameof(DeviceGray)); public static PdfName DeviceRGB => PdfName.Get(nameof(DeviceRGB)); + public static PdfName Difference + => PdfName.Get(nameof(Difference)); public static PdfName DW => PdfName.Get(nameof(DW)); public static PdfName EarlyChange @@ -94,8 +118,12 @@ public static PdfName Encrypt => PdfName.Get(nameof(Encrypt)); public static PdfName EncryptMetadata => PdfName.Get(nameof(EncryptMetadata)); + 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 @@ -116,8 +144,12 @@ public static PdfName FontName => PdfName.Get(nameof(FontName)); public static PdfName FullScreen => PdfName.Get(nameof(FullScreen)); + public static PdfName HardLight + => PdfName.Get(nameof(HardLight)); public static PdfName Height => PdfName.Get(nameof(Height)); + public static PdfName Hue + => PdfName.Get(nameof(Hue)); public static PdfName ID => PdfName.Get(nameof(ID)); public static PdfName Identity @@ -144,6 +176,12 @@ public static PdfName Length => PdfName.Get(nameof(Length)); 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 => PdfName.Get(nameof(LZWDecode)); public static PdfName Magenta @@ -154,10 +192,14 @@ public static PdfName Metadata => PdfName.Get(nameof(Metadata)); public static PdfName ModDate => PdfName.Get(nameof(ModDate)); + public static PdfName Multiply + => PdfName.Get(nameof(Multiply)); public static PdfName N => PdfName.Get(nameof(N)); public static PdfName None => PdfName.Get(nameof(None)); + public static PdfName Normal + => PdfName.Get(nameof(Normal)); public static PdfName O => PdfName.Get(nameof(O)); public static PdfName ObjStm @@ -172,6 +214,8 @@ public static PdfName op => PdfName.Get(nameof(op)); public static PdfName Ordering => PdfName.Get(nameof(Ordering)); + public static PdfName Overlay + => PdfName.Get(nameof(Overlay)); public static PdfName P => PdfName.Get(nameof(P)); public static PdfName Page @@ -200,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 @@ -210,6 +256,12 @@ 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 + => PdfName.Get(nameof(Screen)); public static PdfName Separation => PdfName.Get(nameof(Separation)); public static PdfName Shading @@ -220,6 +272,8 @@ public static PdfName Size => PdfName.Get(nameof(Size)); public static PdfName SMask => PdfName.Get(nameof(SMask)); + public static PdfName SoftLight + => PdfName.Get(nameof(SoftLight)); public static PdfName Standard => PdfName.Get(nameof(Standard)); public static PdfName StemV @@ -260,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)); + }); + }); + } }