Support adding outline bookmarks to existing pdf document (#552)

Support adding outline bookmarks to existing pdf document
This commit is contained in:
Yufei Huang 2023-03-23 18:21:11 +08:00 committed by GitHub
parent a486114c8d
commit a3a9d1a2b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 287 additions and 27 deletions

View File

@ -208,6 +208,7 @@
"UglyToad.PdfPig.Outline.BookmarkNode",
"UglyToad.PdfPig.Outline.DocumentBookmarkNode",
"UglyToad.PdfPig.Outline.ExternalBookmarkNode",
"UglyToad.PdfPig.Outline.UriBookmarkNode",
"UglyToad.PdfPig.Outline.Destinations.ExplicitDestination",
"UglyToad.PdfPig.Outline.Destinations.ExplicitDestinationCoordinates",
"UglyToad.PdfPig.Outline.Destinations.ExplicitDestinationType",

View File

@ -13,6 +13,8 @@
using Xunit;
using System;
using UglyToad.PdfPig.Graphics.Operations.InlineImages;
using UglyToad.PdfPig.Outline;
using UglyToad.PdfPig.Outline.Destinations;
public class PdfDocumentBuilderTests
{
@ -1179,11 +1181,81 @@
var exampleCopiedDictionary = dictCopy.FirstOrDefault();
Assert.NotNull(exampleCopiedDictionary);
Assert.True(exampleCopiedDictionary.Count>0);
Assert.True(exampleCopiedDictionary.Count > 0);
}
}
}
}
[Fact]
public void CanCreateDocumentWithOutline()
{
var builder = new PdfDocumentBuilder();
builder.Bookmarks = new Bookmarks(new BookmarkNode[]
{
new DocumentBookmarkNode(
"1", 0, new ExplicitDestination(1, ExplicitDestinationType.XyzCoordinates, ExplicitDestinationCoordinates.Empty),
new[]
{
new DocumentBookmarkNode("1.1", 0, new ExplicitDestination(2, ExplicitDestinationType.FitPage, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
}),
new DocumentBookmarkNode(
"2", 0, new ExplicitDestination(3, ExplicitDestinationType.FitRectangle, ExplicitDestinationCoordinates.Empty),
new[]
{
new DocumentBookmarkNode("2.1", 0, new ExplicitDestination(4, ExplicitDestinationType.FitBoundingBox, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
new DocumentBookmarkNode("2.2", 0, new ExplicitDestination(5, ExplicitDestinationType.FitBoundingBoxHorizontally, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
new DocumentBookmarkNode("2.3", 0, new ExplicitDestination(6, ExplicitDestinationType.FitBoundingBoxVertically, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
new DocumentBookmarkNode("2.4", 0, new ExplicitDestination(7, ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
new DocumentBookmarkNode("2.5", 0, new ExplicitDestination(8, ExplicitDestinationType.FitVertically, ExplicitDestinationCoordinates.Empty), Array.Empty<BookmarkNode>()),
}),
new UriBookmarkNode("3", 0, "https://github.com", Array.Empty<BookmarkNode>()),
});
var font = builder.AddStandard14Font(Standard14Font.Helvetica);
foreach (var node in builder.Bookmarks.GetNodes())
{
builder.AddPage(PageSize.A4).AddText(node.Title, 12, new PdfPoint(25, 800), font);
}
var file = builder.Build();
WriteFile(nameof(CanCreateDocumentWithOutline), file);
using (var document = PdfDocument.Open(file))
{
Assert.True(document.TryGetBookmarks(out var bookmarks));
Assert.Equal(
new[] { "1", "1.1", "2", "2.1", "2.2", "2.3", "2.4", "2.5", "3" },
bookmarks.GetNodes().Select(node => node.Title));
Assert.Equal(
new[] { 0, 1, 0, 1, 1, 1, 1, 1, 0 },
bookmarks.GetNodes().Select(node => node.Level));
Assert.Equal(
new[] { false, true, false, true, true, true, true, true, true },
bookmarks.GetNodes().Select(node => node.IsLeaf));
Assert.Equal(
new[] { "https://github.com" },
bookmarks.GetNodes().OfType<UriBookmarkNode>().Select(node => node.Uri));
Assert.Equal(
new[]
{
ExplicitDestinationType.XyzCoordinates,
ExplicitDestinationType.FitPage,
ExplicitDestinationType.FitRectangle,
ExplicitDestinationType.FitBoundingBox,
ExplicitDestinationType.FitBoundingBoxHorizontally,
ExplicitDestinationType.FitBoundingBoxVertically,
ExplicitDestinationType.FitHorizontally,
ExplicitDestinationType.FitVertically,
},
bookmarks.GetNodes().OfType<DocumentBookmarkNode>().Select(node => node.Destination.Type));
}
}
private static void WriteFile(string name, byte[] bytes, string extension = "pdf")
{
try

View File

@ -105,20 +105,9 @@
}
}
else if (nodeDictionary.TryGet(NameToken.A, pdfScanner, out DictionaryToken actionDictionary)
&& TryGetAction(actionDictionary, catalog, pdfScanner, namedDestinations, log, out var actionResult))
&& TryGetAction(actionDictionary, catalog, pdfScanner, namedDestinations, log, title, level, children, out var actionResult))
{
if (actionResult.isExternal)
{
bookmark = new ExternalBookmarkNode(title, level, actionResult.externalFileName, children);
}
else if (actionResult.destination != null)
{
bookmark = new DocumentBookmarkNode(title, level, actionResult.destination, children);
}
else
{
return;
}
bookmark = actionResult;
}
else
{
@ -361,10 +350,9 @@
private static bool TryGetAction(DictionaryToken actionDictionary, Catalog catalog, IPdfTokenScanner pdfScanner,
IReadOnlyDictionary<string, ExplicitDestination> namedDestinations,
ILog log,
out (bool isExternal, string externalFileName, ExplicitDestination destination) result)
ILog log, string title, int level, List<BookmarkNode> children, out BookmarkNode result)
{
result = (false, null, null);
result = null;
if (!actionDictionary.TryGet(NameToken.S, pdfScanner, out NameToken actionType))
{
@ -376,7 +364,7 @@
if (actionDictionary.TryGet(NameToken.D, pdfScanner, out ArrayToken destinationArray)
&& TryGetExplicitDestination(destinationArray, catalog, log, out var destination))
{
result = (false, null, destination);
result = new DocumentBookmarkNode(title, level, destination, children);
return true;
}
@ -384,7 +372,7 @@
if (actionDictionary.TryGet(NameToken.D, pdfScanner, out IDataToken<string> destinationName)
&& namedDestinations.TryGetValue(destinationName.Data, out destination))
{
result = (false, null, destination);
result = new DocumentBookmarkNode(title, level, destination, children);
return true;
}
@ -393,11 +381,22 @@
{
if (actionDictionary.TryGetOptionalStringDirect(NameToken.F, pdfScanner, out var filename))
{
result = (true, filename, null);
result = new ExternalBookmarkNode(title, level, filename, children);
return true;
}
result = (true, string.Empty, null);
result = new ExternalBookmarkNode(title, level, string.Empty, children);
return true;
}
else if (actionType.Equals(NameToken.Uri))
{
if (actionDictionary.TryGetOptionalStringDirect(NameToken.Uri, pdfScanner, out var uri))
{
result = new UriBookmarkNode(title, level, uri, children);
return true;
}
result = new UriBookmarkNode(title, level, string.Empty, children);
return true;
}

View File

@ -0,0 +1,33 @@
namespace UglyToad.PdfPig.Outline
{
using System;
using System.Collections.Generic;
/// <inheritdoc />
/// <summary>
/// A node in the <see cref="Bookmarks" /> of a PDF document which corresponds
/// to a uniform resource identifier on the Internet.
/// </summary>
public class UriBookmarkNode : BookmarkNode
{
/// <summary>
/// The uniform resource identifier to resolve.
/// </summary>
public string Uri { get; }
/// <inheritdoc />
/// <summary>
/// Create a new <see cref="ExternalBookmarkNode" />.
/// </summary>
public UriBookmarkNode(string title, int level, string uri, IReadOnlyList<BookmarkNode> children) : base(title, level, children)
{
Uri = uri ?? throw new ArgumentNullException(nameof(uri));
}
/// <inheritdoc />
public override string ToString()
{
return $"URI '{Uri}', {Level}, {Title}";
}
}
}

View File

@ -11,6 +11,8 @@ namespace UglyToad.PdfPig.Writer
using PdfPig.Fonts.TrueType;
using PdfPig.Fonts.Standard14Fonts;
using PdfPig.Fonts.TrueType.Parser;
using PdfPig.Outline;
using PdfPig.Outline.Destinations;
using System.Runtime.CompilerServices;
using Tokenization.Scanner;
using Tokens;
@ -52,6 +54,11 @@ namespace UglyToad.PdfPig.Writer
/// </summary>
public DocumentInformationBuilder DocumentInformation { get; set; } = new DocumentInformationBuilder();
/// <summary>
/// The bookmark nodes to include in the document outline dictionary.
/// </summary>
public Bookmarks Bookmarks { get; set; }
/// <summary>
/// The current page builders in the document and the corresponding 1 indexed page numbers. Use <see cref="AddPage(double,double)"/>
/// or <see cref="AddPage(PageSize,bool)"/> to add a new page.
@ -103,7 +110,7 @@ namespace UglyToad.PdfPig.Writer
}
context.InitializePdf(version);
}
/// <summary>
/// Determines whether the bytes of the TrueType font file provided can be used in a PDF document.
/// </summary>
@ -408,7 +415,7 @@ namespace UglyToad.PdfPig.Writer
}
val = tk.Data;
}
if (!(val is ArrayToken arr))
{
// should be array... ignore and remove bad dict
@ -547,6 +554,7 @@ namespace UglyToad.PdfPig.Writer
}
int leafNum = 0;
var pageReferences = new Dictionary<int, IndirectReferenceToken>(pages.Count);
foreach (var page in pages)
{
@ -589,9 +597,10 @@ namespace UglyToad.PdfPig.Writer
}
pageDictionary[NameToken.Contents] = new ArrayToken(streams);
}
context.AttemptDeduplication = prev;;
context.AttemptDeduplication = prev;
leafChildren[leafNum].Add(context.WriteToken(new DictionaryToken(pageDictionary)));
pageReferences[page.Key] = context.WriteToken(new DictionaryToken(pageDictionary));
leafChildren[leafNum].Add(pageReferences[page.Key]);
if (leafChildren[leafNum].Count >= desiredLeafSize)
{
@ -624,6 +633,20 @@ namespace UglyToad.PdfPig.Writer
catalogDictionary[NameToken.Pages] = rootPageInfo.Ref;
}
if (Bookmarks != null && Bookmarks.Roots.Count > 0)
{
var bookmarks = CreateBookmarkTree(Bookmarks.Roots, pageReferences, null);
var outline = new Dictionary<NameToken, IToken>
{
{NameToken.Type, NameToken.Outlines},
{NameToken.Count, new NumericToken(Bookmarks.Roots.Count)},
{NameToken.First, bookmarks[0]},
{NameToken.Last, bookmarks[bookmarks.Length - 1]},
};
catalogDictionary[NameToken.Outlines] = context.WriteToken(new DictionaryToken(outline));
}
if (ArchiveStandard != PdfAStandard.None)
{
Func<IToken, IndirectReferenceToken> writerFunc = x => context.WriteToken(x);
@ -750,6 +773,138 @@ namespace UglyToad.PdfPig.Writer
});
}
private IndirectReferenceToken[] CreateBookmarkTree(IReadOnlyList<BookmarkNode> nodes, Dictionary<int, IndirectReferenceToken> pageReferences, IndirectReferenceToken parent)
{
var childObjectNumbers = new IndirectReferenceToken[nodes.Count];
for (var i = 0; i < nodes.Count; i++)
{
childObjectNumbers[i] = context.ReserveObjectNumber();
}
for (var i = 0; i < nodes.Count; i++)
{
var node = nodes[i];
var objectNumber = childObjectNumbers[i];
var data = new Dictionary<NameToken, IToken>
{
{NameToken.Title, new StringToken(node.Title)},
{NameToken.Count, new NumericToken(node.Children.Count)}
};
if (parent != null)
{
data[NameToken.Parent] = parent;
}
if (i > 0)
{
data[NameToken.Prev] = childObjectNumbers[i - 1];
}
if (i < childObjectNumbers.Length - 1)
{
data[NameToken.Next] = childObjectNumbers[i + 1];
}
if (node.Children.Count > 0)
{
var children = CreateBookmarkTree(node.Children, pageReferences, objectNumber);
data[NameToken.First] = children[0];
data[NameToken.Last] = children[children.Length - 1];
}
switch (node)
{
case DocumentBookmarkNode documentBookmarkNode:
if (!pageReferences.TryGetValue(documentBookmarkNode.PageNumber, out var pageReference))
{
throw new KeyNotFoundException($"Page {documentBookmarkNode.PageNumber} was not found in the source document.");
}
data[NameToken.Dest] = CreateExplicitDestinationToken(documentBookmarkNode.Destination, pageReference);
break;
case UriBookmarkNode uriBookmarkNode:
data[NameToken.A] = new DictionaryToken(new Dictionary<NameToken, IToken>()
{
[NameToken.S] = NameToken.Uri,
[NameToken.Uri] = new StringToken(uriBookmarkNode.Uri),
});
break;
default:
throw new NotSupportedException($"{node.GetType().Name} is not a supported bookmark node type.");
}
context.WriteToken(new DictionaryToken(data), objectNumber);
}
return childObjectNumbers;
static ArrayToken CreateExplicitDestinationToken(ExplicitDestination destination, IndirectReferenceToken page)
{
return destination.Type switch
{
ExplicitDestinationType.XyzCoordinates => new ArrayToken(new IToken[]
{
page,
NameToken.XYZ,
new NumericToken(destination.Coordinates.Left ?? 0),
new NumericToken(destination.Coordinates.Top ?? 0),
}),
ExplicitDestinationType.FitPage => new ArrayToken(new IToken[]
{
page,
NameToken.Fit,
}),
ExplicitDestinationType.FitHorizontally => new ArrayToken(new IToken[]
{
page,
NameToken.FitH,
new NumericToken(destination.Coordinates.Top ?? 0),
}),
ExplicitDestinationType.FitVertically => new ArrayToken(new IToken[]
{
page,
NameToken.FitV,
new NumericToken(destination.Coordinates.Left ?? 0),
}),
ExplicitDestinationType.FitRectangle => new ArrayToken(new IToken[]
{
page,
NameToken.FitR,
new NumericToken(destination.Coordinates.Left ?? 0),
new NumericToken(destination.Coordinates.Top ?? 0),
new NumericToken(destination.Coordinates.Right ?? 0),
new NumericToken(destination.Coordinates.Bottom ?? 0),
}),
ExplicitDestinationType.FitBoundingBox => new ArrayToken(new IToken[]
{
page,
NameToken.FitB,
}),
ExplicitDestinationType.FitBoundingBoxHorizontally => new ArrayToken(new IToken[]
{
page,
NameToken.FitBH,
new NumericToken(destination.Coordinates.Left ?? 0),
}),
ExplicitDestinationType.FitBoundingBoxVertically => new ArrayToken(new IToken[]
{
page,
NameToken.FitBV,
new NumericToken(destination.Coordinates.Left ?? 0),
}),
_ => throw new NotSupportedException($"{destination.Type} is not a supported bookmark destination type."),
};
}
}
internal class FontStored
{