Skip to main content

Navigation and viewer preferences

Some settings describe how a reader presents a document rather than what is on its pages. They say what a reader calls each page, how many pages it shows side by side, which panel is open, what the window title says, and which language a screen reader speaks. None of them changes how any page looks. They are requests, and a reader is free to ignore any of them.

All of these are properties of PdfDocument in the core PdfPinata package. The types are in the PdfPinata.Pdf namespace unless this page says otherwise.

Page labels

A page label is what a reader shows in its page-number box instead of the page's position. A book with three pages of front matter can number them i, ii, iii and start the body again at 1.

Labels are set in ranges. Each PageLabels.Add says where a range starts (a page index, counting from 0) and how its pages are numbered. A range lasts until the next one begins.

src/SampleApp/Demos/NavigationDemo.cs
// A reader shows these where it shows a page number, so the fourth sheet of this document
// reads "iv" and the tenth would read "1". They are ranges: each Add says where a run
// starts and how it is numbered, and the run lasts until the next one begins.
document.PageLabels.Add(0, PdfPageLabelStyle.LowercaseRoman);
document.PageLabels.Add(3, PdfPageLabelStyle.Decimal, prefix: null, start: 1);

PdfPageLabelStyle has six values: Decimal, UppercaseRoman, LowercaseRoman, UppercaseLetters, LowercaseLetters and None. The longer overload of Add also takes a prefix, which goes in front of every label in the range, and the number the range starts at. None with a prefix labels every page of the range with the prefix alone, which suits a run of unnumbered plates.

GetLabel(pageIndex) returns the label a reader will show, so you can check the result without opening a reader:

src/SampleApp/Demos/NavigationDemo.cs
using var probe = new PdfDocument();
for (var index = 0; index < 4; index++)
_ = probe.AddPage();
probe.PageLabels.Add(0, style.Style, style.Prefix, 1);

var labels = new List<string>();
for (var index = 0; index < 4; index++)
labels.Add(probe.PageLabels.GetLabel(index));

Remove(startPageIndex) takes one range away and Clear() takes them all away. GetRange(pageIndex) returns the range that covers a page.

Page layout and page mode

PageLayout decides how the reader arranges pages when the document opens:

PdfPageLayoutWhat the reader shows
SinglePageOne page at a time
OneColumnOne continuous column
TwoColumnLeftTwo continuous columns, odd pages on the left
TwoColumnRightTwo continuous columns, odd pages on the right
TwoPageLeftTwo pages at a time, odd pages on the left
TwoPageRightTwo pages at a time, odd pages on the right

PageMode decides which panel is open: UseNone, UseOutlines (bookmarks), UseThumbs (page thumbnails), FullScreen, UseOC (layers) or UseAttachments.

Viewer preferences

ViewerPreferences holds the settings for the reader's window:

src/SampleApp/Demos/NavigationDemo.cs
// What a reader does when the document opens. None of it changes a pixel of any page.
document.PageLayout = PdfPageLayout.TwoColumnRight;
document.PageMode = PdfPageMode.UseOutlines;

document.ViewerPreferences.CenterWindow = true;
document.ViewerPreferences.FitWindow = true;
document.ViewerPreferences.DisplayDocTitle = true;
document.ViewerPreferences.HideToolbar = false;
document.ViewerPreferences.HideMenubar = false;
PropertyEffect when true
HideToolbarHides the reader's toolbars
HideMenubarHides the reader's menu bar
HideWindowUIHides scroll bars and navigation controls, leaving only the page
FitWindowSizes the window to the first page
CenterWindowPuts the window in the middle of the screen
DisplayDocTitleShows Info.Title in the title bar instead of the file name

Direction takes a PdfReadingDirection, LeftToRight or RightToLeft. It tells the reader which way to lay out pages side by side; it does not change the text.

Document language

Language is the document's main language, as a language tag such as en-GB or de-DE. A screen reader uses it to choose a voice, and a reader can use it to choose hyphenation rules.

src/SampleApp/Demos/NavigationDemo.cs
// What a screen reader announces the document in, and what a reader uses to pick
// hyphenation rules. A single tag, and nothing else in the file records it.
document.Language = "en-GB";

A document that claims PDF/UA must have a language and a title in Info.Title, or saving it throws. The save sets DisplayDocTitle to true for you. See Accessibility.

Document information

Info holds the fields a reader shows in its document properties dialog:

document.Info.Title = "Annual report 2026";
document.Info.Author = "Finance team";
document.Info.Subject = "Results for the year to March";
document.Info.Keywords = "annual report, finance";

Creator names the application that made the document. Producer is read-only; PdfPinata sets it when it saves. CreationDate and ModificationDate are settable too. A date that is not in the file reads as DateTime.MinValue.

PdfPinata also writes the same information as an XMP metadata packet when the document claims PDF/A or PDF/UA. To write one without a claim, set document.Options.WriteXmpMetadata = true. To add your own properties to the packet, call document.AddMetadataContributor. See PDF/A.

Named destinations

A named destination is a place in the document with a name. A link to the name keeps working when pages are inserted in front of the target, because the name, not a page number, is what the link points at.

src/SampleApp/Demos/NavigationDemo.cs
// A destination named rather than numbered, so a link can point at "chapter-two" and go on
// pointing at it after pages are inserted in front of it. The table is the document-wide
// one; the Text demo makes a single named destination on its own page.
document.NamedDestinations.Add("half-title", made[0]);
document.NamedDestinations.Add("contents", made[2]);
document.NamedDestinations.Add("chapter-one", made[3]);
document.NamedDestinations.Add("chapter-two", made[4], top: 200);

The third argument is how far up the page to land, in points from the bottom. Without it, the reader goes to the page but keeps its current scroll position. NamedDestinations is in the PdfPinata.Pdf.Advanced namespace and also has Contains, Remove, Names and Count.

To link to a name, call gfx.AddNamedLink(rect, name) or page.AddNamedLink(rect, name). To name a place you have drawn, call gfx.AddNamedDestination(name, point), which converts the point for you. See Annotations for links and Bookmarks and outlines for a contents page built this way.

Private application data

CustomValues stores bytes in the document under a key you choose. No reader shows them, and they survive a save and a reopen. Use them to mark a file so that your own software can recognise it later, for example with the step of a pipeline that produced it.

src/SampleApp/Demos/NavigationDemo.cs
// Anything the producer wants to carry that is not part of the page. It lives in the
// catalog under a key of the caller's choosing, no reader displays it, and it survives a
// round trip - which is exactly what a pipeline needs to recognize its own output later.
var pipeline = "{\"stage\":\"demonstration\",\"run\":42}"u8.ToArray();
document.CustomValues["/Pipeline"] = new PdfCustomValue(pipeline);

To read the bytes back, open the document and read Value:

byte[] pipeline = document.CustomValues["/Pipeline"]?.Value;

The indexer returns null when the key is not there. Assigning null to a key removes it, and assigning null to CustomValues itself removes every value.

Things to know

  • Nothing here changes a page. Every setting on this page is a request to the reader. A reader that does not support one ignores it.
  • Page indexes start at 0, labels start at 1. PageLabels.Add(3, …) starts a range on the fourth page. The number a range starts at must be 1 or more.
  • The first page always has a label. If you add a range that does not start at page 0, PdfPinata also labels the pages before it 1, 2, 3, which is what a reader would show anyway.
  • The bookmark panel needs bookmarks. PdfPageMode.UseOutlines opens a panel with nothing in it if the document has no outline. See Bookmarks and outlines.
  • DisplayDocTitle needs a title. Without Info.Title, the reader has nothing to show and falls back to the file name.
  • Custom values are PdfPinata's own. They are stored in one private dictionary in the document catalog, and other PDF software does not read them.
  • These settings need a document you can change. Setting PageLayout, PageMode or Language on a document opened in ReadOnly or Import mode throws InvalidOperationException.

See it in action

The Navigation demo makes a six-page document with roman and arabic page labels, a two-column layout, the bookmark panel open, a language, private data and four named destinations. Its last page lists each setting, every label style, and what survives a save and a reopen.

The full Navigation demo
src/SampleApp/Demos/NavigationDemo.cs
var document = new PdfDocument();
document.Info.Title = "Navigation";

var heading = new XFont("Liberation Sans", 16, XFontStyle.Bold);
var label = new XFont("Liberation Sans", 9, XFontStyle.Bold);
var body = new XFont("Liberation Sans", 9);
var mono = new XFont("Source Code Pro", 8.5);
var big = new XFont("Liberation Sans", 40, XFontStyle.Bold);

// Six pages: three of front matter and three of body, which is what gives the page labels
// below something to label differently.
(string Kind, string Title)[] pages =
[
("front", "Half title"),
("front", "Title page"),
("front", "Contents"),
("body", "Chapter one"),
("body", "Chapter two"),
("body", "What the settings are")
];

var made = new List<PdfPage>();
for (var index = 0; index < pages.Length; index++)
{
var page = document.AddPage();
made.Add(page);

using var gfx = XGraphics.FromPdfPage(page);
gfx.DrawString(pages[index].Title, big, XBrushes.Black,
new XRect(0, 120, page.Width.Point, 60), XStringFormats.TopCenter);
gfx.DrawString(
pages[index].Kind == "front" ? "front matter" : "body",
body, XBrushes.DimGray,
new XRect(0, 190, page.Width.Point, 20), XStringFormats.TopCenter);
}

// ----- page labels -----

// A reader shows these where it shows a page number, so the fourth sheet of this document
// reads "iv" and the tenth would read "1". They are ranges: each Add says where a run
// starts and how it is numbered, and the run lasts until the next one begins.
document.PageLabels.Add(0, PdfPageLabelStyle.LowercaseRoman);
document.PageLabels.Add(3, PdfPageLabelStyle.Decimal, prefix: null, start: 1);

// ----- viewer preferences, layout and mode -----

// What a reader does when the document opens. None of it changes a pixel of any page.
document.PageLayout = PdfPageLayout.TwoColumnRight;
document.PageMode = PdfPageMode.UseOutlines;

document.ViewerPreferences.CenterWindow = true;
document.ViewerPreferences.FitWindow = true;
document.ViewerPreferences.DisplayDocTitle = true;
document.ViewerPreferences.HideToolbar = false;
document.ViewerPreferences.HideMenubar = false;

// What a screen reader announces the document in, and what a reader uses to pick
// hyphenation rules. A single tag, and nothing else in the file records it.
document.Language = "en-GB";

// ----- private data and named destinations -----

// Anything the producer wants to carry that is not part of the page. It lives in the
// catalog under a key of the caller's choosing, no reader displays it, and it survives a
// round trip - which is exactly what a pipeline needs to recognize its own output later.
var pipeline = "{\"stage\":\"demonstration\",\"run\":42}"u8.ToArray();
document.CustomValues["/Pipeline"] = new PdfCustomValue(pipeline);

// A destination named rather than numbered, so a link can point at "chapter-two" and go on
// pointing at it after pages are inserted in front of it. The table is the document-wide
// one; the Text demo makes a single named destination on its own page.
document.NamedDestinations.Add("half-title", made[0]);
document.NamedDestinations.Add("contents", made[2]);
document.NamedDestinations.Add("chapter-one", made[3]);
document.NamedDestinations.Add("chapter-two", made[4], top: 200);

// Outlines, so that PageMode.UseOutlines has something to open.
document.Outlines.Add("Front matter", made[0], true);
document.Outlines[0].Outlines.Add("Contents", made[2]);
document.Outlines.Add("Chapter one", made[3], true);
document.Outlines.Add("Chapter two", made[4], true);

// ----- the last page reports it all back -----

using (var gfx = XGraphics.FromPdfPage(made[5]))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("What this document asks a reader for", heading, XBrushes.Black,
new XPoint(50, 250));

prose.DrawString(
"None of the settings below changes a pixel of any page. They are the document "
+ "telling a reader how it would like to be presented, and a reader is free to "
+ "ignore every one of them.",
body, XBrushes.Black, new XRect(50, 268, 495, 40));

(string Setting, string Value, string Effect)[] rows =
[
("PageLabels", "i-iii then 1-3", "The page box reads iv on sheet four, not 4"),
("PageLayout", "TwoColumnRight", "Two pages side by side, odd ones on the right"),
("PageMode", "UseOutlines", "The bookmark panel is open when the file opens"),
("CenterWindow", "true", "The window opens in the middle of the screen"),
("FitWindow", "true", "The window is sized to the first page"),
("DisplayDocTitle", "true", "The title bar shows Info.Title, not the file name"),
("Language", "en-GB", "What a screen reader announces it in"),
("CustomValues[\"/Pipeline\"]", $"{pipeline.Length} bytes of JSON", "Private data; no reader shows it"),
("NamedDestinations", "4 names", "Links that survive pages being inserted")
];

double y = 325;
foreach (var row in rows)
{
gfx.DrawString(row.Setting, mono, XBrushes.Black, new XPoint(50, y));
gfx.DrawString(row.Value, body, XBrushes.Firebrick, new XPoint(210, y));
gfx.DrawString(row.Effect, body, XBrushes.DimGray, new XPoint(300, y));
y += 16;
}

gfx.DrawString("The six label styles", label, XBrushes.Black, new XPoint(50, y + 20));

// Every style, shown as what the first four sheets of a run would read. Built through
// the API rather than transcribed, so the table cannot drift from the implementation.
(PdfPageLabelStyle Style, string? Prefix)[] styles =
[
(PdfPageLabelStyle.Decimal, null),
(PdfPageLabelStyle.LowercaseRoman, null),
(PdfPageLabelStyle.UppercaseRoman, null),
(PdfPageLabelStyle.LowercaseLetters, null),
(PdfPageLabelStyle.UppercaseLetters, null),
(PdfPageLabelStyle.None, "Appendix ")
];

var styleY = y + 40;
foreach (var style in styles)
{
using var probe = new PdfDocument();
for (var index = 0; index < 4; index++)
_ = probe.AddPage();
probe.PageLabels.Add(0, style.Style, style.Prefix, 1);

var labels = new List<string>();
for (var index = 0; index < 4; index++)
labels.Add(probe.PageLabels.GetLabel(index));

gfx.DrawString(style.Style.ToString(), mono, XBrushes.Black, new XPoint(50, styleY));
gfx.DrawString(style.Prefix is null ? "" : $"prefix \"{style.Prefix}\"",
body, XBrushes.DimGray, new XPoint(210, styleY));
gfx.DrawString(string.Join(" ", labels), body, XBrushes.Firebrick,
new XPoint(320, styleY));
styleY += 15;
}

prose.DrawString(
"PdfPageLabelStyle.None with a prefix is how a run is labelled without being "
+ "numbered at all - every sheet of it reads the prefix and nothing else, which is "
+ "what a run of unnumbered plates wants.",
body, XBrushes.Black, new XRect(50, styleY + 10, 495, 40));

// Round-tripped rather than asserted: what survives a save and a reopen is the only
// version of this that matters.
using var buffer = new MemoryStream();
using var copy = new PdfDocument();
_ = copy.AddPage();
copy.Language = "en-GB";
copy.CustomValues["/Pipeline"] = new PdfCustomValue("kept"u8.ToArray());
copy.PageLabels.Add(0, PdfPageLabelStyle.LowercaseRoman);
copy.Save(buffer, false);
buffer.Position = 0;

using var reopened = PdfReader.Open(buffer, PdfDocumentOpenMode.Modify);

gfx.DrawString("After a save and a reopen", label, XBrushes.Black,
new XPoint(50, styleY + 60));
gfx.DrawString(
$"Language = \"{reopened.Language}\", "
+ $"first page label = \"{reopened.PageLabels.GetLabel(0)}\", "
+ $"CustomValues[\"/Pipeline\"] = "
+ $"\"{Encoding.UTF8.GetString(reopened.CustomValues["/Pipeline"].Value)}\"",
mono, XBrushes.Black, new XPoint(50, styleY + 78));
}