From 6c5827a5360f9e963cc4aa9c2d2b8b2bea5892af Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:59:16 +0900 Subject: [PATCH 1/2] feat(pdf): keep bookmarks, links and form fields when merging PDFs Merge PDF copied pages only, so outlines, internal links, named destinations and the AcroForm field tree were lost. PdfMergeStructure now carries them from each source after its pages are added: bookmarks point at the merged pages with the same view, /Dest and /GoTo links are re-pointed, names from the name tree and the old /Dests dictionary go to one /Names /Dests, and page-backed fields go into one AcroForm. Colliding field names get a file-position prefix, colliding destination names a -2 suffix, and colliding form resource names are renamed with the /DA that uses them. The new structure is untrusted input: it is walked iteratively with depth, count and byte limits, and broken or oversized parts are left out while every page is still merged. Part of #965 Co-authored-by: sisyphus-dev-ai --- docs/ARCHITECTURE.md | 3 +- src/Filee.Engines/Pdf/PdfMergeStructure.cs | 1071 +++++++++ src/Filee.Engines/Pdf/PdfSharpConverter.cs | 28 +- .../PdfMergeStructureTests.cs | 2065 +++++++++++++++++ 4 files changed, 3155 insertions(+), 12 deletions(-) create mode 100644 src/Filee.Engines/Pdf/PdfMergeStructure.cs create mode 100644 tests/Filee.Engines.Tests/PdfMergeStructureTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debabe1fd..13d3076d0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,8 @@ (no Recycle Bin on the drive, a file open elsewhere, a read-only file), both stay and the file's notice says why, with a translated reason (`trash.*`). A result "Only if smaller" copied from the source leaves the source alone. Watch folders and files from a browser never replace. - "Merge into one PDF" converts each file to PDF and merges with PDFsharp. + "Merge into one PDF" converts each file to PDF and merges with PDFsharp, preserving bookmarks, internal links, + named destinations and page-backed AcroForm fields. Fields with colliding names get a file-position prefix. "Compress into one archive" packs the dropped files, unconverted, with the archive engine (`IFileCombiner`). "Merge videos", "Merge audio" and "Video from images" join the files, in the order picked in the merge order window, into one file with FFmpeg (`IMediaJoiner`); pictures become PNG through the image engines first. Like the diff --git a/src/Filee.Engines/Pdf/PdfMergeStructure.cs b/src/Filee.Engines/Pdf/PdfMergeStructure.cs new file mode 100644 index 000000000..5f4e3cdee --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfMergeStructure.cs @@ -0,0 +1,1071 @@ +// Carries bookmarks, destinations, links and page-backed AcroForm fields when PDFsharp imports pages. +// The extra catalog graphs are untrusted: traversal is iterative, bounded and checked before linking. + +using System.Globalization; +using System.Text; +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; + +namespace Filee.Engines.Pdf; + +internal sealed class PdfMergeStructure(PdfDocument output, PdfMergeStructure.Limits limits) +{ + internal sealed record Limits + { + public int OutlineItems { get; init; } = 100_000; + public int OutlineDepth { get; init; } = 64; + public int StringBytes { get; init; } = 4 * 1024 * 1024; + public int NameTreeNodes { get; init; } = 100_000; + public int NameTreeDepth { get; init; } = 64; + public int NamedDestinations { get; init; } = 100_000; + public int FieldDepth { get; init; } = 64; + public int FieldItems { get; init; } = 100_000; + public int ResourceObjects { get; init; } = 10_000; + public int ResourceBytes { get; init; } = 4 * 1024 * 1024; + public int ResourceItems { get; init; } = 1_000_000; + public int AppearanceLength { get; init; } = 4096; + public int AppearanceBytes { get; init; } = 1024 * 1024; + public Action? BeforeCarry { get; init; } + } + + private sealed class Outline(PdfDictionary dictionary, bool opened) + { + public PdfDictionary Dictionary { get; } = dictionary; + public bool Opened { get; } = opened; + public List Children { get; } = []; + public int VisibleChildren { get; set; } + } + + private readonly List _outlines = []; + // Raw byte strings have exactly the same ordinal ordering as their bytes. + private readonly SortedDictionary _destinations = new(StringComparer.Ordinal); + private readonly PdfArray _fields = new(output); + private readonly PdfDictionary _resources = new(output); + private readonly HashSet _fieldNames = new(StringComparer.Ordinal); + private static readonly string[] ResourceKinds = ["/Font", "/ColorSpace", "/Pattern", "/ExtGState"]; + private int _filePosition; + private int _stringBytes; + private bool _needAppearances; + + internal void Carry(PdfDocument source, IReadOnlyDictionary pages, CancellationToken ct) + { + if (++_filePosition == 1) + limits.BeforeCarry?.Invoke(); + ct.ThrowIfCancellationRequested(); + _stringBytes = 0; + _lookups.Clear(); + var catalog = source.Internals.Catalog; + var names = ReadDestinations(catalog, pages, ct); + CarryOutlines(catalog, pages, names, ct); + CarryLinks(source, pages, names, ct); + CarryFields(catalog, ct); + } + + private static PdfItem? Value(PdfItem? item) => item is PdfReference reference ? ValueObject(reference.Value) : ValueObject(item); + + private static PdfItem? ValueObject(PdfItem? item) => item switch + { + PdfStringObject text => new PdfString(text.Value, text.Encoding), + PdfIntegerObject integer => new PdfInteger(integer.Value), + PdfRealObject real => new PdfReal(real.Value), + PdfNameObject name => new PdfName(name.Value), + PdfBooleanObject boolean => new PdfBoolean(boolean.Value), + PdfNullObject => PdfNull.Value, + _ => item, + }; + + private static PdfDictionary? Dictionary(PdfItem? item) => Value(item) as PdfDictionary; + private static PdfArray? Array(PdfItem? item) => Value(item) as PdfArray; + private static string? Name(PdfItem? item) => (Value(item) as PdfName)?.Value; + private static PdfString? Text(PdfItem? item) => Value(item) as PdfString; + + private static (PdfObjectID Id, PdfObject? Direct) Identity(PdfObject item) => + item.Reference is { } reference ? (reference.ObjectID, null) : (default, item); + + private bool SpendStrings(int count) + { + if (count > limits.StringBytes - _stringBytes) + return false; + _stringBytes += count; + return true; + } + + private static string Bytes(PdfString text) + { + if (text.Encoding != PdfStringEncoding.Unicode && !text.Value.Any(c => c > 255)) + return text.Value; + return "\xfe\xff" + Encoding.Latin1.GetString(Encoding.BigEndianUnicode.GetBytes(text.Value)); + } + + // One indirect string read at many places yields the same string instance, so a long one is measured + // and looked up once. Short strings are cheap and may be shared instances such as string.Empty. + private const int SharedLength = 256; + private readonly Dictionary _byteCounts = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _lookups = new(ReferenceEqualityComparer.Instance); + // The next suffix to try for a name already taken, so later sources do not walk the taken ones again. + private readonly Dictionary _destinationSuffixes = new(StringComparer.Ordinal); + private readonly Dictionary<(string Kind, string Name), int> _resourceSuffixes = new(); + private readonly Dictionary _actions = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _links = new(ReferenceEqualityComparer.Instance); + + private int ByteCount(PdfString text) + { + if (text.Value.Length < SharedLength) + return Count(text); + if (!_byteCounts.TryGetValue(text.Value, out var count)) + { + count = Count(text); + _byteCounts.Add(text.Value, count); + } + return count; + } + + private static int Count(PdfString text) => + text.Encoding == PdfStringEncoding.Unicode || text.Value.Any(c => c > 255) + ? 2 + Encoding.BigEndianUnicode.GetByteCount(text.Value) + : text.Value.Length; + + private static string DecodeName(string name) + { + var decoded = new StringBuilder(name.Length); + for (var i = name.StartsWith('/') ? 1 : 0; i < name.Length; i++) + { + if (name[i] == '#' && i + 2 < name.Length && + byte.TryParse(name.AsSpan(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + decoded.Append((char)b); + i += 2; + } + else + decoded.Append(name[i]); + } + return decoded.ToString(); + } + + private static PdfString Raw(string bytes) => new(bytes, PdfStringEncoding.RawEncoding); + + private PdfArray? MapDestination(PdfItem? item, IReadOnlyDictionary pages) + { + var source = Array(item); + if (source is null || source.Elements.Count < 2 || source.Elements[0] is not PdfReference reference || + !pages.TryGetValue(reference.ObjectID, out var page)) + return null; + var kind = Name(source.Elements[1]); + var length = kind switch + { + "/XYZ" => 5, + "/Fit" or "/FitB" => 2, + "/FitH" or "/FitBH" or "/FitV" or "/FitBV" => 3, + "/FitR" => 6, + _ => -1, + }; + if (source.Elements.Count != length) + return null; + var values = new List(length) { page.Reference!, new PdfName(kind!) }; + for (var i = 2; i < length; i++) + { + var value = Value(source.Elements[i]); + if (value is not (PdfInteger or PdfReal) && !(value is PdfNull && kind != "/FitR")) + return null; + values.Add(value switch + { + PdfInteger integer => new PdfInteger(integer.Value), + PdfReal real => new PdfReal(real.Value), + _ => PdfNull.Value, + }); + } + var result = new PdfArray(output); + foreach (var value in values) + result.Elements.Add(value); + return result; + } + + private Dictionary<(bool Legacy, string Bytes), string> ReadDestinations(PdfDictionary catalog, + IReadOnlyDictionary pages, CancellationToken ct) + { + var names = new Dictionary<(bool, string), string>(); + var count = 0; + // Returns false when the destination is not usable; the key is only built for a usable one. + bool Register(bool legacy, Func keyOf, PdfItem? item) + { + if (count >= limits.NamedDestinations) + return false; + if (Dictionary(item) is { } dictionary) + item = dictionary.Elements["/D"]; + if (MapDestination(item, pages) is not { } destination) + return false; + var key = keyOf(); + if (names.ContainsKey((legacy, key))) + return true; + var renamed = key; + var suffix = _destinationSuffixes.GetValueOrDefault(key, 2); + while (_destinations.ContainsKey(renamed)) + renamed = key + "-" + (suffix++).ToString(CultureInfo.InvariantCulture); + if (!SpendStrings(renamed.Length)) + return true; + names.Add((legacy, key), renamed); + _destinations.Add(renamed, destination); + if (!ReferenceEquals(renamed, key)) + _destinationSuffixes[key] = suffix; + count++; + return true; + } + + if (Dictionary(catalog.Elements["/Names"]) is { } catalogNames && + Dictionary(catalogNames.Elements["/Dests"]) is { } tree) + { + var stack = new Stack<(PdfDictionary Node, int Depth)>(); + var visited = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + // Nodes may share one /Names or /Kids array; reading it again would multiply the work. A /Kids + // array is read again only from a shallower depth, where children cut by the depth limit may fit. + var expandedNames = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + var expandedKids = new Dictionary<(PdfObjectID Id, PdfObject? Direct), int>(); + var seenKeys = new HashSet(ReferenceEqualityComparer.Instance); + stack.Push((tree, 1)); + while (stack.TryPop(out var entry)) + { + ct.ThrowIfCancellationRequested(); + if (entry.Depth > limits.NameTreeDepth || visited.Count >= limits.NameTreeNodes || + !visited.Add(Identity(entry.Node))) + continue; + if (Array(entry.Node.Elements["/Names"]) is { } pairs && expandedNames.Add(Identity(pairs))) + for (var i = 0; i + 1 < pairs.Elements.Count; i += 2) + { + ct.ThrowIfCancellationRequested(); + // Keys are unique in a name tree; a long key repeated by reference would be converted + // and hashed again for every pair. + if (Text(pairs.Elements[i]) is { } key && ByteCount(key) <= limits.StringBytes - _stringBytes && + (key.Value.Length < SharedLength || !seenKeys.Contains(key.Value)) && + Register(false, () => Bytes(key), pairs.Elements[i + 1]) && key.Value.Length >= SharedLength) + seenKeys.Add(key.Value); + } + if (Array(entry.Node.Elements["/Kids"]) is { } kids && + (!expandedKids.TryGetValue(Identity(kids), out var depth) || entry.Depth < depth)) + { + expandedKids[Identity(kids)] = entry.Depth; + for (var i = kids.Elements.Count - 1; i >= 0; i--) + { + ct.ThrowIfCancellationRequested(); + if (Dictionary(kids.Elements[i]) is { } child && !visited.Contains(Identity(child))) + stack.Push((child, entry.Depth + 1)); + } + } + } + } + if (Dictionary(catalog.Elements["/Dests"]) is { } old) + foreach (var key in old.Elements.Keys) + { + ct.ThrowIfCancellationRequested(); + // The reader has already decoded PDF name escapes in dictionary keys and PdfName.Value. + if (key.Length - 1 <= limits.StringBytes - _stringBytes) + Register(true, () => key[1..], old.Elements[key]); + } + return names; + } + + private PdfItem? Destination(PdfItem? item, IReadOnlyDictionary pages, + Dictionary<(bool Legacy, string Bytes), string> names, bool link) + { + item = Value(item); + if (item is PdfArray) + return MapDestination(item, pages); + if (link && item is PdfString textKey && ByteCount(textKey) > limits.StringBytes - _stringBytes) + return null; + if (link && item is PdfName nameKey && nameKey.Value.Length - 1 > limits.StringBytes - _stringBytes) + return null; + // A PdfName and a PdfString never share one string instance, so one cache serves both kinds. + var value = item switch + { + PdfName name => name.Value, + PdfString text => text.Value, + _ => null, + }; + if (value is null) + return null; + if (value.Length < SharedLength || !_lookups.TryGetValue(value, out var resolved)) + { + var key = item is PdfString text ? (false, Bytes(text)) : (true, value[1..]); + resolved = names.TryGetValue(key, out var found) ? (found, _destinations[found]) : null; + if (value.Length >= SharedLength) + _lookups.Add(value, resolved); + } + if (resolved is not { } hit) + return null; + var (renamed, destination) = hit; + if (!link) + return destination; + return SpendStrings(renamed.Length) ? Raw(renamed) : null; + } + + private void CarryOutlines(PdfDictionary catalog, IReadOnlyDictionary pages, + Dictionary<(bool Legacy, string Bytes), string> names, CancellationToken ct) + { + if (Dictionary(catalog.Elements["/Outlines"]) is not { } root) + return; + var stack = new Stack<(PdfDictionary Source, List Into, int Depth)>(); + if (Dictionary(root.Elements["/First"]) is { } first) + stack.Push((first, _outlines, 1)); + var visited = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + var count = 0; + while (stack.TryPop(out var entry)) + { + ct.ThrowIfCancellationRequested(); + if (count >= limits.OutlineItems) + break; + if (!visited.Add(Identity(entry.Source))) + continue; + if (Dictionary(entry.Source.Elements["/Next"]) is { } next) + stack.Push((next, entry.Into, entry.Depth)); + if (entry.Depth > limits.OutlineDepth) + continue; + var title = Text(entry.Source.Elements["/Title"]); + if (title is null || !SpendStrings(ByteCount(title))) + continue; + var dictionary = new PdfDictionary(output); + dictionary.Elements["/Title"] = new PdfString(title.Value, title.Encoding); + var destination = Destination(entry.Source.Elements["/Dest"], pages, names, false); + if (destination is null && Dictionary(entry.Source.Elements["/A"]) is { } action && Name(action.Elements["/S"]) == "/GoTo") + destination = Destination(action.Elements["/D"], pages, names, false); + if (destination is not null) + dictionary.Elements["/Dest"] = destination; + if (Array(entry.Source.Elements["/C"]) is { Elements.Count: 3 } color && + color.Elements.All(item => Value(item) is PdfInteger or PdfReal)) + { + var copy = new PdfArray(output); + foreach (var item in color.Elements) + copy.Elements.Add(Value(item)!); + dictionary.Elements["/C"] = copy; + } + if (Value(entry.Source.Elements["/F"]) is PdfInteger style) + dictionary.Elements["/F"] = new PdfInteger(style.Value); + var outline = new Outline(dictionary, Value(entry.Source.Elements["/Count"]) is PdfInteger { Value: > 0 }); + entry.Into.Add(outline); + count++; + if (Dictionary(entry.Source.Elements["/First"]) is { } child) + stack.Push((child, outline.Children, entry.Depth + 1)); + } + } + + private void CarryLinks(PdfDocument source, IReadOnlyDictionary pages, + Dictionary<(bool Legacy, string Bytes), string> names, CancellationToken ct) + { + foreach (var page in source.Pages) + { + ct.ThrowIfCancellationRequested(); + if (Array(page.Elements["/Annots"]) is not { } annotations || + Array(pages[page.Reference!.ObjectID].Elements["/Annots"]) is not { } imported) + continue; + for (var i = 0; i < annotations.Elements.Count; i++) + { + ct.ThrowIfCancellationRequested(); + if (Dictionary(annotations.Elements[i]) is not { } annotation || Name(annotation.Elements["/Subtype"]) != "/Link") + continue; + var mapped = annotation.Reference is null ? null : output.Internals.MapExternalObject(annotation) as PdfDictionary; + mapped ??= i < imported.Elements.Count ? Dictionary(imported.Elements[i]) : null; + // An /Annots array may list one annotation many times; rewriting it again would copy its action again. + if (mapped is null || !_links.Add(mapped)) + continue; + if (annotation.Elements.ContainsKey("/Dest")) + { + var destination = Destination(annotation.Elements["/Dest"], pages, names, true); + if (destination is not null) + mapped.Elements["/Dest"] = destination; + else + mapped.Elements.Remove("/Dest"); + } + if (Dictionary(annotation.Elements["/A"]) is { } action && Name(action.Elements["/S"]) == "/GoTo") + { + // Links sharing one action get one rewritten action; copying it per link would write a large + // direct /Next graph once per link. + var shared = Dictionary(mapped.Elements["/A"]); + if (shared is not null && _actions.TryGetValue(shared, out var done)) + { + if (done is null) + mapped.Elements.Remove("/A"); + else + mapped.Elements["/A"] = done.Reference; + continue; + } + var destination = Destination(action.Elements["/D"], pages, names, true); + if (destination is null) + { + mapped.Elements.Remove("/A"); + if (shared is not null) + _actions.Add(shared, null); + } + else + { + // A fresh dictionary, as the imported action may be shared; its other entries such as + // /Next are already output objects and stay. + var copy = new PdfDictionary(output); + if (shared is not null) + foreach (var entry in shared.Elements) + copy.Elements[entry.Key] = entry.Value; + copy.Elements["/S"] = new PdfName("/GoTo"); + copy.Elements["/D"] = destination; + output.Internals.AddObject(copy); + mapped.Elements["/A"] = copy.Reference; + if (shared is not null) + _actions.Add(shared, copy); + } + } + } + } + } + + private void CarryFields(PdfDictionary catalog, CancellationToken ct) + { + if (Dictionary(catalog.Elements["/AcroForm"]) is not { } form) + return; + _needAppearances |= Value(form.Elements["/NeedAppearances"]) is PdfBoolean { Value: true }; + if (Array(form.Elements["/Fields"]) is not { } fields) + return; + var visited = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + var expanded = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + var prepared = new List<(PdfDictionary Root, PdfDictionary Imported, List Nodes)>(); + // Names a field's own /DR uses (LibreOffice writes one) are kept free when form resources are renamed. + var localNames = new HashSet<(string Kind, string Name)>(); + var collected = new HashSet<(string Kind, (PdfObjectID Id, PdfObject? Direct) Dictionary)>(); + var fieldItems = 0; + foreach (var item in fields.Elements) + { + ct.ThrowIfCancellationRequested(); + if (Dictionary(item) is not { } root) + continue; + var stack = new Stack<(PdfDictionary Field, PdfDictionary? Parent, int Depth, string? Type)>(); + var nodes = new List(); + stack.Push((root, null, 1, null)); + var valid = true; + var signature = false; + while (stack.TryPop(out var entry)) + { + ct.ThrowIfCancellationRequested(); + if (++fieldItems > limits.FieldItems || entry.Depth > limits.FieldDepth || !visited.Add(Identity(entry.Field)) || + (entry.Parent is null ? Value(entry.Field.Elements["/Parent"]) is not (null or PdfNull) : + !ReferenceEquals(Dictionary(entry.Field.Elements["/Parent"]), entry.Parent)) || + (entry.Field.Elements.ContainsKey("/T") && Text(entry.Field.Elements["/T"]) is null)) + { + valid = false; + break; + } + var type = Name(entry.Field.Elements["/FT"]) ?? entry.Type; + signature |= type == "/Sig"; + nodes.Add(entry.Field); + if (entry.Field.Elements.ContainsKey("/Kids")) + { + // A /Kids array shared by two fields gives its children two parents. + if (Array(entry.Field.Elements["/Kids"]) is not { } kids || !expanded.Add(Identity(kids))) + { + valid = false; + break; + } + for (var i = kids.Elements.Count - 1; i >= 0; i--) + { + ct.ThrowIfCancellationRequested(); + if (Dictionary(kids.Elements[i]) is not { } child || visited.Contains(Identity(child))) + { + valid = false; + break; + } + stack.Push((child, entry.Field, entry.Depth + 1, type)); + } + if (!valid) + break; + } + } + if (!valid || signature || output.Internals.MapExternalObject(root) is not PdfDictionary imported) + continue; + foreach (var node in nodes) + if (Dictionary(node.Elements["/DR"]) is { } local) + foreach (var kind in ResourceKinds) + if (Dictionary(local.Elements[kind]) is { } own && collected.Add((kind, Identity(own)))) + foreach (var key in own.Elements.Keys) + { + ct.ThrowIfCancellationRequested(); + localNames.Add((kind, key)); + } + prepared.Add((root, imported, nodes)); + } + + var resourceNames = CarryResources(Dictionary(form.Elements["/DR"]), localNames, ct); + var cache = new Dictionary<(string Value, PdfStringEncoding Encoding), PdfString>(); + var candidates = new List<(string? Name, PdfDictionary Field)>(); + var appearanceBytes = 0; + var aliased = new HashSet<(string Kind, (PdfObjectID Id, PdfObject? Direct) Dictionary)>(); + foreach (var (root, imported, nodes) in prepared) + { + ct.ThrowIfCancellationRequested(); + var writes = new List<(PdfDictionary Field, PdfString Appearance)>(); + var bytes = 0; + foreach (var node in nodes) + { + ct.ThrowIfCancellationRequested(); + var appearance = Text(node.Elements["/DA"]); + if (ReferenceEquals(node, root) && !node.Elements.ContainsKey("/DA")) + appearance = Text(form.Elements["/DA"]); + if (appearance is null || ByteCount(appearance) > limits.AppearanceLength || + output.Internals.MapExternalObject(node) is not PdfDictionary mapped) + continue; + var key = (appearance.Value, appearance.Encoding); + if (!cache.TryGetValue(key, out var rewritten)) + { + rewritten = RewriteAppearance(appearance, resourceNames, ct); + cache.Add(key, rewritten); + } + bytes += ByteCount(rewritten); + if (bytes > limits.AppearanceBytes - appearanceBytes) + break; + writes.Add((mapped, rewritten)); + } + if (bytes > limits.AppearanceBytes - appearanceBytes) + continue; + appearanceBytes += bytes; + foreach (var (field, appearance) in writes) + { + ct.ThrowIfCancellationRequested(); + field.Elements["/DA"] = appearance; + } + // Some writers (LibreOffice) also put a /DR on the field itself, and viewers such as poppler look the + // /DA names up there first; give the renamed names the same resources in that dictionary too. A kid + // that inherits its /DA needs them as much as a field with its own. + foreach (var node in nodes) + { + ct.ThrowIfCancellationRequested(); + if (output.Internals.MapExternalObject(node) is not PdfDictionary field || + Dictionary(field.Elements["/DR"]) is not { } local) + continue; + foreach (var kind in ResourceKinds) + { + if (Dictionary(local.Elements[kind]) is not { } own || !aliased.Add((kind, Identity(own)))) + continue; + foreach (var name in own.Elements.Keys.ToArray()) + { + ct.ThrowIfCancellationRequested(); + if (resourceNames.TryGetValue((kind, name), out var renamed) && renamed != name && + !own.Elements.ContainsKey(renamed)) + own.Elements[renamed] = own.Elements[name]; + } + } + } + if (!root.Elements.ContainsKey("/Q") && Value(form.Elements["/Q"]) is PdfInteger alignment) + imported.Elements["/Q"] = new PdfInteger(alignment.Value); + candidates.Add((Text(root.Elements["/T"])?.Value, imported)); + } + + // Names this source adds; names of earlier sources stay in _fieldNames and are not copied. + var reserved = new HashSet(StringComparer.Ordinal); + foreach (var (name, _) in candidates) + { + ct.ThrowIfCancellationRequested(); + if (name is not null && !_fieldNames.Contains(name)) + reserved.Add(name); + } + PdfDictionary? prefix = null; + PdfArray? prefixedKids = null; + foreach (var (name, field) in candidates) + { + ct.ThrowIfCancellationRequested(); + if (field.Reference is null) + output.Internals.AddObject(field); + if (name is not null && _fieldNames.Contains(name)) + { + if (prefix is null) + { + var basis = _filePosition.ToString(CultureInfo.InvariantCulture); + var prefixName = basis; + for (var suffix = 2; reserved.Contains(prefixName) || _fieldNames.Contains(prefixName); suffix++) + prefixName = basis + "_" + suffix.ToString(CultureInfo.InvariantCulture); + prefix = new PdfDictionary(output); + prefixedKids = new PdfArray(output); + prefix.Elements["/T"] = new PdfString(prefixName); + prefix.Elements["/Kids"] = prefixedKids; + output.Internals.AddObject(prefix); + _fields.Elements.Add(prefix.Reference!); + reserved.Add(prefixName); + } + field.Elements["/Parent"] = prefix.Reference; + prefixedKids!.Elements.Add(field.Reference!); + } + else + _fields.Elements.Add(field.Reference!); + } + _fieldNames.UnionWith(reserved); + } + + private Dictionary<(string Kind, string Name), string> CarryResources(PdfDictionary? source, + HashSet<(string Kind, string Name)> localNames, CancellationToken ct) + { + // Also after a resource limit stops the copy: the resources already carried keep their defaults. + var names = CopyResources(source, localNames, ct); + KeepDefaultSpaces(names); + return names; + } + + private Dictionary<(string Kind, string Name), string> CopyResources(PdfDictionary? source, + HashSet<(string Kind, string Name)> localNames, CancellationToken ct) + { + var names = new Dictionary<(string, string), string>(); + if (source is null) + return names; + var copied = new Dictionary(ReferenceEqualityComparer.Instance); + var objectCount = 0; + var scalarBytes = 0; + // Entries read while checking resources, kept or left out, so a wide shared array is not read again per resource. + var work = 0; + foreach (var kind in ResourceKinds) + { + ct.ThrowIfCancellationRequested(); + if (Dictionary(source.Elements[kind]) is not { } resources) + continue; + var target = Dictionary(_resources.Elements[kind]) ?? new PdfDictionary(output); + var reserved = new HashSet(resources.Elements.Keys, StringComparer.Ordinal); + foreach (var key in resources.Elements.Keys) + { + ct.ThrowIfCancellationRequested(); + var value = Value(resources.Elements[key]); + if (kind == "/ColorSpace" ? value is not (PdfName or PdfArray) : value is not PdfDictionary) + continue; + // Preflight the whole resource before allocating or linking any output objects. + var pending = new Stack(); + var graph = new List(); + var visited = new HashSet<(PdfObjectID Id, PdfObject? Direct)>(); + // Strings and names are copied as direct values at every use, so their bytes are budgeted + // per use; a resource that leads to a page would copy the page graph outside the page tree. + var bytes = 0; + var page = false; + pending.Push(value); + while (pending.TryPop(out var item)) + { + ct.ThrowIfCancellationRequested(); + item = Value(item)!; + bytes += item switch + { + PdfString text => ByteCount(text), + PdfName name => name.Value.Length, + _ => 0, + }; + if (bytes > limits.ResourceBytes - scalarBytes) + return names; + if (item is not (PdfDictionary or PdfArray) || item is not PdfObject obj || + copied.ContainsKey(obj) || !visited.Add(Identity(obj))) + continue; + if (obj is PdfDictionary { } typed && Name(typed.Elements["/Type"]) is "/Page" or "/Pages" or "/Catalog") + { + page = true; + break; + } + graph.Add(obj); + if (graph.Count > limits.ResourceObjects - objectCount) + return names; + work += obj is PdfDictionary entries ? entries.Elements.Count : ((PdfArray)obj).Elements.Count; + if (work > limits.ResourceItems) + return names; + if (obj is PdfDictionary dictionary) + foreach (var entry in dictionary.Elements) + { + ct.ThrowIfCancellationRequested(); + if (dictionary.Stream is null || entry.Key != "/Length") + pending.Push(entry.Value!); + } + else if (obj is PdfArray array) + foreach (var entry in array.Elements) + { + ct.ThrowIfCancellationRequested(); + pending.Push(entry); + } + } + if (page) + { + // Count the work of a resource that is left out too, so a shared graph is not walked again + // for every resource that leads into it. + objectCount += graph.Count; + scalarBytes += bytes; + continue; + } + foreach (var obj in graph) + { + ct.ThrowIfCancellationRequested(); + PdfObject clone = obj is PdfDictionary ? new PdfDictionary(output) : new PdfArray(output); + if (obj.Reference is not null) + output.Internals.AddObject(clone); + copied.Add(obj, clone); + } + PdfItem Copy(PdfItem item) + { + var direct = Value(item)!; + if (direct is PdfObject obj && copied.TryGetValue(obj, out var clone)) + return item is PdfReference ? clone.Reference! : clone; + return direct; + } + foreach (var obj in graph) + { + ct.ThrowIfCancellationRequested(); + if (obj is PdfDictionary dictionary) + { + var clone = (PdfDictionary)copied[obj]; + foreach (var entry in dictionary.Elements) + { + ct.ThrowIfCancellationRequested(); + if (dictionary.Stream is null || entry.Key != "/Length") + clone.Elements[entry.Key] = Copy(entry.Value!); + } + if (dictionary.Stream is not null) + clone.CreateStream((byte[])dictionary.Stream.Value.Clone()); + } + else if (obj is PdfArray array) + { + var clone = (PdfArray)copied[obj]; + foreach (var entry in array.Elements) + { + ct.ThrowIfCancellationRequested(); + clone.Elements.Add(Copy(entry)); + } + } + } + objectCount += graph.Count; + scalarBytes += bytes; + var renamed = key; + // A default colour space silently replaces a device space for every field of the merged form, so it + // always gets a plain name, and this source's /DA selects it explicitly (RewriteAppearance). + if (target.Elements.ContainsKey(renamed) || (kind == "/ColorSpace" && DefaultSpaces.ContainsValue(key))) + { + // Only suffixes already in the merged dictionary stay taken for later sources; names this + // source reserves are free again for the next one. + var suffix = _resourceSuffixes.GetValueOrDefault((kind, key), 2); + while (target.Elements.ContainsKey(Suffixed(key, suffix))) + suffix++; + _resourceSuffixes[(kind, key)] = suffix; + do + renamed = Suffixed(key, suffix++); + while (reserved.Contains(renamed) || localNames.Contains((kind, renamed)) || target.Elements.ContainsKey(renamed)); + } + target.Elements[renamed] = Copy(resources.Elements[key]!); + _resources.Elements[kind] = target; + names.Add((kind, key), renamed); + } + } + return names; + } + + // A moved default space must still replace its device space wherever this source's colour space resources + // name that device space: a plain alias such as /CS1 /DeviceRGB, or the base or alternate of an + // /Indexed, /Pattern, /Separation or /DeviceN array, also as the /ColorSpace of a shading pattern's shading, + // which has no resources of its own. Tiling patterns resolve defaults in their own /Resources. + private void KeepDefaultSpaces(Dictionary<(string Kind, string Name), string> names) + { + if (Dictionary(_resources.Elements["/ColorSpace"]) is not { } spaces) + return; + var defaults = new Dictionary(StringComparer.Ordinal); + foreach (var (device, space) in DefaultSpaces) + if (names.TryGetValue(("/ColorSpace", space), out var moved) && spaces.Elements[moved] is { } value) + { + // Shared by reference, so a large default array is written once however many aliases use it. + if (value is PdfObject { Reference: null } direct) + { + output.Internals.AddObject(direct); + spaces.Elements[moved] = direct.Reference; + } + defaults[device] = spaces.Elements[moved]!; + } + if (defaults.Count == 0) + return; + // Carried objects may also be used by a tiling pattern's own resources, where a device space follows that + // pattern's defaults, so a default is applied to a copy made once per object, never to the object itself. + var kept = new Dictionary(ReferenceEqualityComparer.Instance); + PdfItem Indirect(PdfObject copy) + { + output.Internals.AddObject(copy); + return copy.Reference!; + } + // A plain device name, or an array whose base or alternate slot leads to one. ISO 32000 allows at most + // /Pattern, then /Indexed, then /Separation or /DeviceN above the device space, so three arrays are followed. + PdfItem? Kept(PdfItem? item, int depth = 0) + { + var value = Value(item); + if (value is PdfName device) + return defaults.TryGetValue(device.Value, out var alias) ? alias : null; + if (depth >= 3 || value is not PdfArray array) + return null; + if (kept.TryGetValue(array, out var done)) + return done; + kept[array] = null; + var slot = Name(array.Elements.Count > 0 ? array.Elements[0] : null) switch + { + "/Indexed" or "/Pattern" => 1, + "/Separation" or "/DeviceN" => 2, + _ => -1, + }; + if (slot < 0 || slot >= array.Elements.Count || Kept(array.Elements[slot], depth + 1) is not { } inner) + return null; + var copy = new PdfArray(output); + foreach (var entry in array.Elements) + copy.Elements.Add(entry); + copy.Elements[slot] = inner; + return kept[array] = Indirect(copy); + } + // One memo per replaced key: a dictionary used both as a pattern and as a shading needs both copies. + var keptDictionaries = new Dictionary>(StringComparer.Ordinal); + PdfItem? KeptDictionary(PdfDictionary dictionary, string key, Func keep) + { + if (!keptDictionaries.TryGetValue(key, out var memo)) + keptDictionaries[key] = memo = new Dictionary(ReferenceEqualityComparer.Instance); + if (memo.TryGetValue(dictionary, out var done)) + return done; + memo[dictionary] = null; + if (keep(dictionary.Elements[key]) is not { } replacement) + return null; + var copy = new PdfDictionary(output); + foreach (var entry in dictionary.Elements) + if (dictionary.Stream is null || entry.Key != "/Length") + copy.Elements[entry.Key] = entry.Value; + // Mesh shadings (types 4 to 7) keep their data in the stream. + if (dictionary.Stream is not null) + copy.CreateStream((byte[])dictionary.Stream.Value.Clone()); + copy.Elements[key] = replacement; + return memo[dictionary] = Indirect(copy); + } + var patterns = Dictionary(_resources.Elements["/Pattern"]); + foreach (var ((kind, name), renamed) in names) + { + if (kind == "/ColorSpace" && !DefaultSpaces.ContainsValue(name) && Kept(spaces.Elements[renamed]) is { } alias) + spaces.Elements[renamed] = alias; + else if (kind == "/Pattern" && Dictionary(patterns?.Elements[renamed]) is { } pattern && + Value(pattern.Elements["/PatternType"]) is PdfInteger { Value: 2 } && + KeptDictionary(pattern, "/Shading", shading => + Dictionary(shading) is { } dictionary ? KeptDictionary(dictionary, "/ColorSpace", space => Kept(space)) : null) is { } copy) + patterns!.Elements[renamed] = copy; + } + } + + private static readonly Dictionary DefaultSpaces = new(StringComparer.Ordinal) + { + ["/DeviceGray"] = "/DefaultGray", + ["/DeviceRGB"] = "/DefaultRGB", + ["/DeviceCMYK"] = "/DefaultCMYK", + }; + + // Device colour operators and their colour space, operand count and general replacement operator. + private static readonly Dictionary DeviceColours = new(StringComparer.Ordinal) + { + ["g"] = ("/DeviceGray", 1, "sc"), + ["G"] = ("/DeviceGray", 1, "SC"), + ["rg"] = ("/DeviceRGB", 3, "sc"), + ["RG"] = ("/DeviceRGB", 3, "SC"), + ["k"] = ("/DeviceCMYK", 4, "sc"), + ["K"] = ("/DeviceCMYK", 4, "SC"), + }; + + private static string Encoded(string name) + { + var result = new StringBuilder(name.Length + 1).Append('/'); + foreach (var c in name.AsSpan(1)) + if (c < 33 || c > 126 || Delimiter(c) || c == '#') + result.Append('#').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture)); + else + result.Append(c); + return result.ToString(); + } + + private static string Suffixed(string key, int suffix) => key + "_" + suffix.ToString(CultureInfo.InvariantCulture); + + private readonly record struct Token(int Start, int Length, string? Name, string? Word); + + private static bool White(char c) => c is '\0' or '\t' or '\n' or '\f' or '\r' or ' '; + private static bool Delimiter(char c) => White(c) || c is '(' or ')' or '<' or '>' or '[' or ']' or '{' or '}' or '/' or '%'; + + private static PdfString RewriteAppearance(PdfString source, Dictionary<(string Kind, string Name), string> names, CancellationToken ct) + { + var text = source.Value; + var operands = new List(); + var changes = new List<(int Start, int Length, string Text)>(); + string? Default(string device) => + names.TryGetValue(("/ColorSpace", DefaultSpaces[device]), out var renamed) ? renamed : null; + for (var i = 0; i < text.Length;) + { + ct.ThrowIfCancellationRequested(); + if (White(text[i])) + { + i++; + continue; + } + if (text[i] == '%') + { + while (i < text.Length && text[i] is not ('\r' or '\n')) + i++; + continue; + } + var start = i; + string? name = null; + string? word = null; + if (text[i] == '/') + { + while (++i < text.Length && !Delimiter(text[i])) { } + name = "/" + DecodeName(text[start..i]); + } + else if (text[i] == '(') + { + var depth = 1; + for (i++; i < text.Length && depth > 0; i++) + { + if (text[i] == '\\') + i++; + else if (text[i] == '(') + depth++; + else if (text[i] == ')') + depth--; + } + if (depth != 0) + return new PdfString(source.Value, source.Encoding); + } + else if (text[i] == '<' && (i + 1 >= text.Length || text[i + 1] != '<')) + { + for (i++; i < text.Length && text[i] != '>'; i++) + if (!White(text[i]) && !Uri.IsHexDigit(text[i])) + return new PdfString(source.Value, source.Encoding); + if (i == text.Length) + return new PdfString(source.Value, source.Encoding); + i++; + } + else if (Delimiter(text[i])) + { + // Composite operands are legal, but are not resource-name operands. + i++; + } + else + { + while (i < text.Length && !Delimiter(text[i])) + i++; + var value = text[start..i]; + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) && + value is not ("true" or "false" or "null")) + word = value; + } + var token = new Token(start, i - start, name, word); + if (word is null) + { + operands.Add(token); + continue; + } + var kind = word switch + { + "Tf" => "/Font", + "cs" or "CS" => "/ColorSpace", + "scn" or "SCN" => "/Pattern", + "gs" => "/ExtGState", + _ => null, + }; + var index = operands.Count - (word == "Tf" ? 2 : 1); + // Device colour spaces and /Pattern are built-in names for cs and CS, not resource names; a device space + // this source had a default for selects that default by its new name. + if (kind == "/ColorSpace" && index >= 0 && operands[index].Name is "/DeviceGray" or "/DeviceRGB" or "/DeviceCMYK" or "/Pattern") + { + if (operands[index].Name is { } device && DefaultSpaces.ContainsKey(device) && Default(device) is { } space) + changes.Add((operands[index].Start, operands[index].Length, Encoded(space))); + kind = null; + } + if (kind is not null && index >= 0 && operands[index] is { Name: { } resource } operand && + names.TryGetValue((kind, resource), out var renamed) && renamed != resource) + changes.Add((operand.Start, operand.Length, Encoded(renamed))); + if (DeviceColours.TryGetValue(word, out var colour) && operands.Count == colour.Operands && + Default(colour.Space) is { } replacement) + { + changes.Add((operands[0].Start, 0, Encoded(replacement) + (colour.Operator == "sc" ? " cs " : " CS "))); + changes.Add((token.Start, token.Length, colour.Operator)); + } + operands.Clear(); + } + if (changes.Count == 0) + return new PdfString(source.Value, source.Encoding); + var result = new StringBuilder(text.Length); + var position = 0; + foreach (var (start, length, replacement) in changes) + { + ct.ThrowIfCancellationRequested(); + result.Append(text, position, start - position); + result.Append(replacement); + position = start + length; + } + result.Append(text, position, text.Length - position); + return new PdfString(result.ToString(), source.Encoding); + } + + internal void Write(CancellationToken ct) + { + var catalog = output.Internals.Catalog; + if (_destinations.Count > 0) + { + var pairs = new PdfArray(output); + foreach (var (key, destination) in _destinations) + { + ct.ThrowIfCancellationRequested(); + pairs.Elements.Add(Raw(key)); + pairs.Elements.Add(destination); + } + var tree = new PdfDictionary(output); + tree.Elements["/Names"] = pairs; + var names = new PdfDictionary(output); + names.Elements["/Dests"] = tree; + catalog.Elements["/Names"] = names; + } + if (_outlines.Count > 0) + { + var root = new PdfDictionary(output); + root.Elements["/Type"] = new PdfName("/Outlines"); + output.Internals.AddObject(root); + var stack = new Stack<(PdfDictionary Parent, List Children, bool Finish)>(); + stack.Push((root, _outlines, false)); + while (stack.TryPop(out var entry)) + { + ct.ThrowIfCancellationRequested(); + if (entry.Finish) + { + var visible = 0; + foreach (var child in entry.Children) + { + ct.ThrowIfCancellationRequested(); + child.VisibleChildren = child.Children.Sum(c => 1 + (c.Opened ? c.VisibleChildren : 0)); + if (child.Children.Count > 0) + child.Dictionary.Elements["/Count"] = new PdfInteger(child.Opened ? child.VisibleChildren : -child.VisibleChildren); + visible += 1 + (child.Opened ? child.VisibleChildren : 0); + } + if (ReferenceEquals(entry.Parent, root)) + root.Elements["/Count"] = new PdfInteger(visible); + continue; + } + stack.Push((entry.Parent, entry.Children, true)); + for (var i = 0; i < entry.Children.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var child = entry.Children[i]; + output.Internals.AddObject(child.Dictionary); + } + for (var i = 0; i < entry.Children.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var child = entry.Children[i]; + child.Dictionary.Elements["/Parent"] = entry.Parent.Reference; + if (i > 0) + child.Dictionary.Elements["/Prev"] = entry.Children[i - 1].Dictionary.Reference; + if (i + 1 < entry.Children.Count) + child.Dictionary.Elements["/Next"] = entry.Children[i + 1].Dictionary.Reference; + if (child.Children.Count > 0) + stack.Push((child.Dictionary, child.Children, false)); + } + entry.Parent.Elements["/First"] = entry.Children[0].Dictionary.Reference; + entry.Parent.Elements["/Last"] = entry.Children[^1].Dictionary.Reference; + } + catalog.Elements["/Outlines"] = root.Reference; + } + if (_fields.Elements.Count > 0) + { + var form = new PdfDictionary(output); + form.Elements["/Fields"] = _fields; + if (_resources.Elements.Count > 0) + form.Elements["/DR"] = _resources; + if (_needAppearances) + form.Elements["/NeedAppearances"] = new PdfBoolean(true); + output.Internals.AddObject(form); + catalog.Elements["/AcroForm"] = form.Reference; + } + } +} diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index 3ea39b560..0b94aa5af 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -48,18 +48,24 @@ public Task FinishAsync(string path, PdfOptions options, CancellationToken cance Task.Run(() => PdfFinishing.Finish(path, options, cancellationToken), cancellationToken); public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, CancellationToken cancellationToken) => - Task.Run(() => + Task.Run(() => Merge(inputPaths, outputPath, cancellationToken), cancellationToken); + + internal static void Merge(IReadOnlyList inputs, string outputPath, CancellationToken ct, PdfMergeStructure.Limits? limits = null) + { + using var output = new PdfDocument(); + var structure = new PdfMergeStructure(output, limits ?? new()); + foreach (var input in inputs) { - using var output = new PdfDocument(); - foreach (var input in inputPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - using var source = Open(input, PdfDocumentOpenMode.Import); - foreach (var page in source.Pages) - output.AddPage(page); - } - output.Save(outputPath); - }, cancellationToken); + ct.ThrowIfCancellationRequested(); + using var source = Open(input, PdfDocumentOpenMode.Import); + var pages = new Dictionary(); + foreach (var page in source.Pages) + pages[page.Reference!.ObjectID] = output.AddPage(page); + structure.Carry(source, pages, ct); + } + structure.Write(ct); + output.Save(outputPath); + } /// Places every frame of an image (TIFF pages, ...) on its own PDF page. private static IReadOnlyList ImageToPdf(ConversionStep step, IProgress? progress, CancellationToken ct) diff --git a/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs b/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs new file mode 100644 index 000000000..6e2c14f78 --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs @@ -0,0 +1,2065 @@ +// Generated low-level PDFs exercise catalog structure independently of PDFsharp's convenience writers. + +using System.Diagnostics; +using System.IO.Compression; +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Pdf; +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Tests; + +/// Runs alone: the limit cases check merge time, which other tests running at once would stretch. +[CollectionDefinition(nameof(PdfMergeStructureCollection), DisableParallelization = true)] +public sealed class PdfMergeStructureCollection; + +[Collection(nameof(PdfMergeStructureCollection))] +public class PdfMergeStructureTests(EngineFixture fx) : IClassFixture +{ + private static PdfItem? V(PdfItem? item) => item is PdfReference reference ? reference.Value : item; + private static PdfDictionary D(PdfItem? item) => Assert.IsAssignableFrom(V(item)); + private static PdfArray A(PdfItem? item) => Assert.IsType(V(item)); + private static string S(PdfItem? item) => V(item) switch + { + PdfString text => text.Value, + PdfStringObject text => text.Value, + PdfName name => name.Value, + _ => throw new InvalidOperationException("Not a PDF string or name"), + }; + + private static PdfDictionary Object(PdfDocument document) + { + var dictionary = new PdfDictionary(document); + document.Internals.AddObject(dictionary); + return dictionary; + } + + private static PdfArray Items(PdfDocument document, params PdfItem[] items) + { + var array = new PdfArray(document); + foreach (var item in items) + array.Elements.Add(item); + return array; + } + + private static PdfArray Dest(PdfDocument document, int page, string kind = "/Fit", params PdfItem[] values) => + Items(document, [document.Pages[page].Reference!, new PdfName(kind), .. values]); + + private static PdfDictionary Link(PdfDocument document, int page, PdfItem? destination = null, PdfDictionary? action = null) + { + var link = Object(document); + link.Elements["/Type"] = new PdfName("/Annot"); + link.Elements["/Subtype"] = new PdfName("/Link"); + link.Elements["/Rect"] = Items(document, new PdfInteger(10), new PdfInteger(10), new PdfInteger(30), new PdfInteger(30)); + if (destination is not null) + link.Elements["/Dest"] = destination; + if (action is not null) + link.Elements["/A"] = action; + if (V(document.Pages[page].Elements["/Annots"]) is not PdfArray annotations) + { + annotations = new PdfArray(document); + document.Pages[page].Elements["/Annots"] = annotations; + } + annotations.Elements.Add(link.Reference!); + return link; + } + + private static PdfDictionary Action(PdfDocument document, string kind, PdfItem destination) + { + var action = new PdfDictionary(document); + action.Elements["/S"] = new PdfName(kind); + action.Elements[kind == "/URI" ? "/URI" : "/D"] = destination; + return action; + } + + private static PdfDictionary Bookmark(PdfDocument document, string title, PdfItem? destination = null) + { + var item = Object(document); + item.Elements["/Title"] = new PdfString(title); + if (destination is not null) + item.Elements["/Dest"] = destination; + return item; + } + + private static void Children(PdfDictionary parent, params PdfDictionary[] children) + { + if (children.Length == 0) + return; + parent.Elements["/First"] = children[0].Reference; + parent.Elements["/Last"] = children[^1].Reference; + for (var i = 0; i < children.Length; i++) + { + children[i].Elements["/Parent"] = parent.Reference; + if (i > 0) + children[i].Elements["/Prev"] = children[i - 1].Reference; + if (i + 1 < children.Length) + children[i].Elements["/Next"] = children[i + 1].Reference; + } + } + + private static PdfDictionary Widget(PdfDocument document, int page, PdfDictionary? parent = null) + { + var widget = Object(document); + widget.Elements["/Type"] = new PdfName("/Annot"); + widget.Elements["/Subtype"] = new PdfName("/Widget"); + widget.Elements["/Rect"] = Items(document, new PdfInteger(40), new PdfInteger(40), new PdfInteger(100), new PdfInteger(60)); + widget.Elements["/P"] = document.Pages[page].Reference; + if (parent is not null) + widget.Elements["/Parent"] = parent.Reference; + if (V(document.Pages[page].Elements["/Annots"]) is not PdfArray annotations) + { + annotations = new PdfArray(document); + document.Pages[page].Elements["/Annots"] = annotations; + } + annotations.Elements.Add(widget.Reference!); + var appearance = Object(document); + appearance.Elements["/Type"] = new PdfName("/XObject"); + appearance.Elements["/Subtype"] = new PdfName("/Form"); + appearance.Elements["/BBox"] = Items(document, new PdfInteger(0), new PdfInteger(0), new PdfInteger(60), new PdfInteger(20)); + appearance.CreateStream("0 0 1 rg 0 0 60 20 re f"u8.ToArray()); + var ap = new PdfDictionary(document); + ap.Elements["/N"] = appearance.Reference; + widget.Elements["/AP"] = ap; + return widget; + } + + private static PdfDictionary Form(PdfDocument document) + { + var form = Object(document); + form.Elements["/Fields"] = new PdfArray(document); + document.Internals.Catalog.Elements["/AcroForm"] = form.Reference; + return form; + } + + private static PdfDictionary Field(PdfDocument document, PdfDictionary form, string name, string value, bool multiple = false) + { + var field = multiple ? Object(document) : Widget(document, 0); + field.Elements["/T"] = new PdfString(name); + field.Elements["/FT"] = new PdfName("/Tx"); + field.Elements["/V"] = new PdfString(value); + if (multiple) + field.Elements["/Kids"] = Items(document, Widget(document, 0, field).Reference!, Widget(document, 1, field).Reference!); + A(form.Elements["/Fields"]).Elements.Add(field.Reference!); + return field; + } + + private static PdfDictionary Resource(PdfDocument document, string? font = null) + { + var dictionary = Object(document); + if (font is not null) + { + dictionary.Elements["/Type"] = new PdfName("/Font"); + dictionary.Elements["/Subtype"] = new PdfName("/Type1"); + dictionary.Elements["/BaseFont"] = new PdfName("/" + font); + } + return dictionary; + } + + private static string Make(string folder, string name, int index = 0, bool? need = null, bool collision = false) + { + var path = Path.Combine(folder, name + ".pdf"); + using var document = new PdfDocument(); + document.AddPage(); + document.AddPage().Rotate = 90; + for (var p = 0; p < 2; p++) + { + var content = Object(document); + content.CreateStream(Encoding.ASCII.GetBytes($"0 0 {index + p + 1} 10 re f")); + document.Pages[p].Elements["/Contents"] = content.Reference; + } + var pairs = Items(document, new PdfString("intro"), Dest(document, 1, "/FitH", new PdfInteger(123))); + var tree = new PdfDictionary(document); + tree.Elements["/Names"] = pairs; + var names = new PdfDictionary(document); + names.Elements["/Dests"] = tree; + document.Internals.Catalog.Elements["/Names"] = names; + var old = new PdfDictionary(document); + old.Elements["/old"] = Dest(document, 0, "/FitR", new PdfInteger(1), new PdfInteger(2), new PdfInteger(30), new PdfInteger(40)); + document.Internals.Catalog.Elements["/Dests"] = old; + var xyz = Dest(document, 1, "/XYZ", new PdfInteger(10), PdfNull.Value, new PdfReal(1.5)); + var root = Object(document); + root.Elements["/Type"] = new PdfName("/Outlines"); + var top = Bookmark(document, $"source-{index}", xyz); + top.Elements["/C"] = Items(document, new PdfReal(0.2), new PdfInteger(0), new PdfInteger(1)); + top.Elements["/F"] = new PdfInteger(3); + top.Elements["/Count"] = new PdfInteger(99); + var child = Bookmark(document, $"child-{index}"); + child.Elements["/A"] = Action(document, "/GoTo", new PdfString("intro")); + Children(top, child); + Children(root, top); + document.Internals.Catalog.Elements["/Outlines"] = root.Reference; + Link(document, 0, xyz); + Link(document, 0, action: Action(document, "/GoTo", old.Elements["/old"]!)); + Link(document, 0, new PdfString("intro")); + Link(document, 0, new PdfName("/old")); + Link(document, 0, action: Action(document, "/URI", new PdfString("https://example.org/"))); + Link(document, 0, Dest(document, 0)); + var form = Form(document); + form.Elements["/DA"] = new PdfString($"/F#31 {10 + index * 4} Tf % /F1 stays in a comment\n/CS1 cs /CS1 CS /P1 scn /P1 SCN /GS1 gs /DeviceRGB cs {(index == 0 ? "1 0 0" : "0 0 1")} rg (/F1 /CS1) Tj"); + form.Elements["/Q"] = new PdfInteger(index == 0 ? 0 : 2); + if (need is { } needValue) + form.Elements["/NeedAppearances"] = new PdfBoolean(needValue); + Field(document, form, "name", index == 0 ? "first" : "second", multiple: true); + var unique = Field(document, form, $"unique-{index}", "unique"); + unique.Elements["/FT"] = new PdfName("/Btn"); + unique.Elements["/V"] = new PdfName("/Yes"); + unique.Elements["/AS"] = new PdfName("/Yes"); + var appearances = D(unique.Elements["/AP"]); + var off = Object(document); + off.Elements["/Type"] = new PdfName("/XObject"); + off.Elements["/Subtype"] = new PdfName("/Form"); + off.Elements["/BBox"] = Items(document, new PdfInteger(0), new PdfInteger(0), new PdfInteger(60), new PdfInteger(20)); + off.CreateStream([]); + var checks = new PdfDictionary(document); + checks.Elements["/Yes"] = appearances.Elements["/N"]; + checks.Elements["/Off"] = off.Reference; + appearances.Elements["/N"] = checks; + var alternate = Field(document, form, $"alternate-{index}", "alt"); + alternate.Elements["/DA"] = new PdfString("/F1_2 9 Tf 0 g"); + if (collision) + Field(document, form, "2", "reserved"); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/F1"] = Resource(document, index == 0 ? "Helvetica" : "Courier").Reference; + fonts.Elements["/F1_2"] = Resource(document, index == 0 ? "Times-Roman" : "Times-Bold").Reference; + resources.Elements["/Font"] = fonts; + var spaces = new PdfDictionary(document); + spaces.Elements["/CS1"] = new PdfName(index == 0 ? "/DeviceRGB" : "/DeviceGray"); + resources.Elements["/ColorSpace"] = spaces; + var patterns = new PdfDictionary(document); + var pattern = Resource(document); + pattern.Elements["/Marker"] = new PdfInteger(index); + patterns.Elements["/P1"] = pattern.Reference; + resources.Elements["/Pattern"] = patterns; + var states = new PdfDictionary(document); + var state = Resource(document); + state.Elements["/ca"] = new PdfReal(index == 0 ? 0.5 : 0.8); + states.Elements["/GS1"] = state.Reference; + resources.Elements["/ExtGState"] = states; + form.Elements["/DR"] = resources; + if (index == 1) + { + var direct = new PdfDictionary(document); + foreach (var entry in form.Elements) + direct.Elements[entry.Key] = entry.Value; + document.Internals.Catalog.Elements["/AcroForm"] = direct; + } + document.Save(path); + return path; + } + + private string Merge(PdfMergeStructure.Limits? limits = null, bool collision = false) + { + var folder = fx.NewFolder(); + var inputs = new[] { Make(folder, "a", 0), Make(folder, "b", 1, true, collision) }; + var path = Path.Combine(folder, "merged.pdf"); + PdfSharpConverter.Merge(inputs, path, TestContext.Current.CancellationToken, limits); + return path; + } + + private static List OutlineItems(PdfDocument document) + { + var result = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var stack = new Stack(); + if (V(document.Internals.Catalog.Elements["/Outlines"]) is PdfDictionary root && root.Elements["/First"] is { } first) + stack.Push(first); + while (stack.TryPop(out var item)) + { + var dictionary = D(item); + Assert.True(visited.Add(dictionary), "outline items must be reachable exactly once"); + result.Add(dictionary); + if (dictionary.Elements["/Next"] is { } next) + stack.Push(next); + if (dictionary.Elements["/First"] is { } child) + stack.Push(child); + } + return result; + } + + private static Dictionary Fields(PdfDocument document) + { + var result = new Dictionary(StringComparer.Ordinal); + if (V(document.Internals.Catalog.Elements["/AcroForm"]) is not PdfDictionary form) + return result; + var stack = new Stack<(PdfItem Item, string Prefix)>(); + foreach (var item in A(form.Elements["/Fields"]).Elements.Reverse()) + stack.Push((item, "")); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + while (stack.TryPop(out var entry)) + { + var field = D(entry.Item); + Assert.True(visited.Add(field), "fields and widgets must be reachable exactly once"); + var name = field.Elements["/T"] is { } t ? entry.Prefix + S(t) : entry.Prefix.TrimEnd('.'); + if (field.Elements.ContainsKey("/T")) + result.Add(name, field); + if (V(field.Elements["/Kids"]) is PdfArray kids) + foreach (var kid in kids.Elements.Reverse()) + { + Assert.Same(field, D(D(kid).Elements["/Parent"])); + stack.Push((kid, name + ".")); + } + } + return result; + } + + private static List Links(PdfDocument document, int page) => + A(document.Pages[page].Elements["/Annots"]).Elements.Select(D) + .Where(d => d.Elements["/Subtype"] is { } item && S(item) == "/Link").ToList(); + + private static Dictionary Destinations(PdfDocument document) + { + var names = A(D(D(document.Internals.Catalog.Elements["/Names"]).Elements["/Dests"]).Elements["/Names"]); + var result = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < names.Elements.Count; i += 2) + result.Add(Key(names.Elements[i]), A(names.Elements[i + 1])); + return result; + } + + private static string Key(PdfItem item) + { + var text = Assert.IsType(V(item)); + return text.Encoding == PdfStringEncoding.Unicode + ? "\xfe\xff" + Encoding.Latin1.GetString(Encoding.BigEndianUnicode.GetBytes(text.Value)) + : text.Value; + } + + private static PdfArray Resolved(PdfDocument document, PdfItem? item) => + V(item) is PdfString ? Destinations(document)[Key(item!)] : A(item); + + private static void View(PdfDocument document, PdfArray destination, int page, string kind, params string[] values) + { + Assert.Equal(document.Pages[page].Reference!.ObjectID, Assert.IsType(destination.Elements[0]).ObjectID); + Assert.Equal(kind, S(destination.Elements[1])); + Assert.Equal(values, destination.Elements.Skip(2).Select(item => item.ToString())); + } + + private static void AssertBookmarks(PdfDocument document) + { + var items = OutlineItems(document); + Assert.Equal(new[] { "source-0", "child-0", "source-1", "child-1" }, items.Select(d => S(d.Elements["/Title"]))); + Assert.Equal(4, Assert.IsType(D(document.Internals.Catalog.Elements["/Outlines"]).Elements["/Count"]).Value); + for (var source = 0; source < 2; source++) + { + var parent = items[source * 2]; + var child = items[source * 2 + 1]; + Assert.Same(parent, D(child.Elements["/Parent"])); + Assert.Same(child, D(parent.Elements["/First"])); + Assert.Equal(1, Assert.IsType(parent.Elements["/Count"]).Value); + Assert.False(child.Elements.ContainsKey("/Count")); + Assert.Equal(new[] { "0.2", "0", "1" }, A(parent.Elements["/C"]).Elements.Select(v => v.ToString())); + Assert.Equal(3, Assert.IsType(parent.Elements["/F"]).Value); + View(document, A(parent.Elements["/Dest"]), source * 2 + 1, "/XYZ", "10", "null", "1.5"); + View(document, A(child.Elements["/Dest"]), source * 2 + 1, "/FitH", "123"); + } + } + + private static void AssertForms(PdfDocument document, string prefix = "2") + { + var fields = Fields(document); + Assert.Equal("first", S(fields["name"].Elements["/V"])); + Assert.Equal("second", S(fields[prefix + ".name"].Elements["/V"])); + for (var source = 0; source < 2; source++) + { + var parent = fields[source == 0 ? "name" : prefix + ".name"]; + var widgets = A(parent.Elements["/Kids"]); + Assert.Equal(2, widgets.Elements.Count); + for (var p = 0; p < 2; p++) + { + var widget = D(widgets.Elements[p]); + Assert.Same(parent, D(widget.Elements["/Parent"])); + Assert.Contains(A(document.Pages[source * 2 + p].Elements["/Annots"]).Elements, + item => ReferenceEquals(V(item), widget)); + Assert.NotNull(D(widget.Elements["/AP"]).Elements["/N"]); + } + Assert.Equal("/Yes", S(fields[$"unique-{source}"].Elements["/V"])); + } + var reachable = new HashSet(ReferenceEqualityComparer.Instance); + var stack = new Stack(A(D(document.Internals.Catalog.Elements["/AcroForm"]).Elements["/Fields"]).Elements); + while (stack.TryPop(out var item)) + { + var field = D(item); + Assert.True(reachable.Add(field)); + if (V(field.Elements["/Kids"]) is PdfArray kids) + foreach (var kid in kids.Elements) + stack.Push(kid); + } + foreach (var page in document.Pages) + foreach (var widget in A(page.Elements["/Annots"]).Elements.Select(D).Where(d => S(d.Elements["/Subtype"]) == "/Widget")) + Assert.Contains(widget, reachable); + } + + private static void AssertLinks(PdfDocument document) + { + var names = Destinations(document); + Assert.Contains("intro", names.Keys); + Assert.Contains("intro-2", names.Keys); + for (var source = 0; source < 2; source++) + { + var links = Links(document, source * 2); + View(document, A(links[0].Elements["/Dest"]), source * 2 + 1, "/XYZ", "10", "null", "1.5"); + View(document, A(D(links[1].Elements["/A"]).Elements["/D"]), source * 2, "/FitR", "1", "2", "30", "40"); + Assert.Equal(source == 0 ? "intro" : "intro-2", S(links[2].Elements["/Dest"])); + View(document, Resolved(document, links[2].Elements["/Dest"]), source * 2 + 1, "/FitH", "123"); + View(document, Resolved(document, links[3].Elements["/Dest"]), source * 2, "/FitR", "1", "2", "30", "40"); + Assert.Equal("/URI", S(D(links[4].Elements["/A"]).Elements["/S"])); + Assert.Equal("https://example.org/", S(D(links[4].Elements["/A"]).Elements["/URI"])); + View(document, A(links[5].Elements["/Dest"]), source * 2, "/Fit"); + } + } + + private static PdfItem? Inherited(PdfDictionary field, string key) + { + while (true) + { + if (field.Elements.ContainsKey(key)) + return field.Elements[key]; + if (V(field.Elements["/Parent"]) is not PdfDictionary parent) + return null; + field = parent; + } + } + + private static void AssertDefaults(PdfDocument document) + { + var fields = Fields(document); + var resources = D(D(document.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]); + var fonts = D(resources.Elements["/Font"]); + Assert.Equal(4, fonts.Elements.Count); + for (var source = 0; source < 2; source++) + { + var field = fields[source == 0 ? "name" : "2.name"]; + var da = S(Inherited(field, "/DA")); + var fontName = da.Split(' ')[0].Replace("#31", "1", StringComparison.Ordinal); + Assert.Equal(source == 0 ? "/Helvetica" : "/Courier", S(D(fonts.Elements[fontName]).Elements["/BaseFont"])); + Assert.Contains(source == 0 ? "10 Tf" : "14 Tf", da, StringComparison.Ordinal); + Assert.Contains(source == 0 ? "1 0 0 rg" : "0 0 1 rg", da, StringComparison.Ordinal); + Assert.Contains("% /F1 stays in a comment", da, StringComparison.Ordinal); + Assert.Contains("(/F1 /CS1) Tj", da, StringComparison.Ordinal); + Assert.Contains("/DeviceRGB cs", da, StringComparison.Ordinal); + Assert.Equal(source == 0 ? 0 : 2, Assert.IsType(Inherited(field, "/Q")).Value); + var tokens = da.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + string Before(string operation) => tokens[System.Array.LastIndexOf(tokens, operation) - 1]; + var spaces = D(resources.Elements["/ColorSpace"]); + Assert.Equal(source == 0 ? "/DeviceRGB" : "/DeviceGray", S(spaces.Elements[tokens[System.Array.IndexOf(tokens, "cs") - 1]])); + Assert.Equal(source == 0 ? "/DeviceRGB" : "/DeviceGray", S(spaces.Elements[Before("CS")])); + var pattern = D(D(resources.Elements["/Pattern"]).Elements[Before("scn")]); + Assert.Equal(source, Assert.IsType(pattern.Elements["/Marker"]).Value); + Assert.Same(pattern, D(D(resources.Elements["/Pattern"]).Elements[Before("SCN")])); + var state = D(D(resources.Elements["/ExtGState"]).Elements[Before("gs")]); + Assert.Equal(source == 0 ? 0.5 : 0.8, Assert.IsType(state.Elements["/ca"]).Value); + var alternate = S(fields[$"alternate-{source}"].Elements["/DA"]).Split(' ')[0]; + Assert.Equal(source == 0 ? "/Times-Roman" : "/Times-Bold", S(D(fonts.Elements[alternate]).Elements["/BaseFont"])); + } + } + + private static void ChangeIndependently(string path, string? password = null) + { + var edited = Path.Combine(Path.GetDirectoryName(path)!, "edited.pdf"); + using (var document = password is null ? PdfReader.Open(path, PdfDocumentOpenMode.Modify) : PdfReader.Open(path, password, PdfDocumentOpenMode.Modify)) + { + var fields = Fields(document); + fields["2.name"].Elements["/V"] = new PdfString("changed"); + document.Save(edited); + } + using var opened = password is null ? PdfReader.Open(edited, PdfDocumentOpenMode.Import) : PdfReader.Open(edited, password, PdfDocumentOpenMode.Import); + var result = Fields(opened); + Assert.Equal("first", S(result["name"].Elements["/V"])); + Assert.Equal("changed", S(result["2.name"].Elements["/V"])); + foreach (var name in new[] { "unique-0", "unique-1" }) + { + Assert.Equal("/Yes", S(result[name].Elements["/V"])); + Assert.Equal("/Yes", S(result[name].Elements["/AS"])); + } + foreach (var widget in A(result["2.name"].Elements["/Kids"]).Elements) + Assert.Equal("changed", S(D(D(widget).Elements["/Parent"]).Elements["/V"])); + } + + [Fact] + public void Merge_keeps_each_sources_bookmarks_pointing_at_its_pages() + { + using var document = PdfReader.Open(Merge(), PdfDocumentOpenMode.Import); + AssertBookmarks(document); + } + + [Fact] + public void Merge_keeps_form_fields_and_their_values() + { + var folder = fx.NewFolder(); + var inputs = new[] { Make(folder, "a"), Make(folder, "b", 1) }; + using (var second = PdfReader.Open(inputs[1], PdfDocumentOpenMode.Import)) + Assert.IsType(second.Internals.Catalog.Elements["/AcroForm"]); + var output = Path.Combine(folder, "merged.pdf"); + PdfSharpConverter.Merge(inputs, output, TestContext.Current.CancellationToken); + using var document = PdfReader.Open(output, PdfDocumentOpenMode.Import); + AssertForms(document); + } + + [Fact] + public void Fields_with_the_same_name_get_the_file_position_as_a_prefix() + { + using (var document = PdfReader.Open(Merge(), PdfDocumentOpenMode.Import)) + AssertForms(document); + using var collision = PdfReader.Open(Merge(collision: true), PdfDocumentOpenMode.Import); + AssertForms(collision, "2_2"); + Assert.Equal("reserved", S(Fields(collision)["2"].Elements["/V"])); + } + + [Fact] + public void Links_and_named_destinations_point_into_the_merged_file() + { + using var document = PdfReader.Open(Merge(), PdfDocumentOpenMode.Import); + AssertLinks(document); + } + + [Fact] + public void Merged_pages_are_the_pages_of_the_sources() + { + var folder = fx.NewFolder(); + var inputs = new[] { Make(folder, "a"), Make(folder, "b", 1) }; + var path = Path.Combine(folder, "merged.pdf"); + var baseline = Path.Combine(folder, "baseline.pdf"); + using (var old = new PdfDocument()) + { + foreach (var input in inputs) + { + using var source = PdfReader.Open(input, PdfDocumentOpenMode.Import); + foreach (var page in source.Pages) + old.AddPage(page); + } + old.Save(baseline); + } + PdfSharpConverter.Merge(inputs, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + using var original = PdfReader.Open(baseline, PdfDocumentOpenMode.Import); + Assert.Equal(original.PageCount, result.PageCount); + for (var i = 0; i < result.PageCount; i++) + { + Assert.Equal(original.Pages[i].MediaBox, result.Pages[i].MediaBox); + Assert.Equal(original.Pages[i].Rotate, result.Pages[i].Rotate); + Assert.Equal(original.Pages[i].Contents.CreateSingleContent().Stream.Value, result.Pages[i].Contents.CreateSingleContent().Stream.Value); + } + Assert.Equal(result.PageCount, result.Internals.GetAllObjects().OfType().Count(d => d.Elements["/Type"] is { } type && S(type) == "/Page")); + } + + [Fact] + public async Task Merge_job_keeps_structure_through_watermark_page_numbers_and_password() + { + var folder = fx.NewFolder(); + var job = await fx.ConvertAsync([Make(folder, "a"), Make(folder, "b", 1, true)], new Preset + { + TargetFormat = "pdf", + Pdf = { MergeIntoSingle = true, Watermark = "DRAFT", PageNumbers = true, Password = "merge-test" }, + }); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var path = Assert.Single(job.Outputs); + using (var document = PdfReader.Open(path, "merge-test", PdfDocumentOpenMode.Import)) + { + AssertBookmarks(document); + AssertForms(document); + AssertLinks(document); + AssertDefaults(document); + Assert.True(Assert.IsType(D(document.Internals.Catalog.Elements["/AcroForm"]).Elements["/NeedAppearances"]).Value); + } + ChangeIndependently(path, "merge-test"); + } + + [Fact] + public void Sources_without_structure_merge_as_before() + { + var folder = fx.NewFolder(); + var source = Path.Combine(folder, "plain.pdf"); + using (var document = new PdfDocument()) + { + document.AddPage(); + document.Save(source); + } + var output = Path.Combine(folder, "out.pdf"); + var second = Source(document => document.Pages[0].Rotate = 90); + PdfSharpConverter.Merge([source, second], output, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(3, result.PageCount); + Assert.Equal(90, result.Pages[1].Rotate); + foreach (var key in new[] { "/Outlines", "/Names", "/AcroForm" }) + Assert.False(result.Internals.Catalog.Elements.ContainsKey(key)); + } + + private string Source(Action build) + { + var path = Path.Combine(fx.NewFolder(), "source.pdf"); + using var document = new PdfDocument(); + document.AddPage(); + document.AddPage(); + build(document); + document.Save(path); + return path; + } + + private static string MergeOne(string source, PdfMergeStructure.Limits? limits = null) + { + var path = Path.Combine(Path.GetDirectoryName(source)!, "out.pdf"); + PdfSharpConverter.Merge([source], path, TestContext.Current.CancellationToken, limits); + return path; + } + + private static PdfDictionary OutlineRoot(PdfDocument document) + { + var root = Object(document); + root.Elements["/Type"] = new PdfName("/Outlines"); + document.Internals.Catalog.Elements["/Outlines"] = root.Reference; + return root; + } + + private static PdfDictionary NameTree(PdfDocument document) + { + var names = new PdfDictionary(document); + var root = Object(document); + names.Elements["/Dests"] = root.Reference; + document.Internals.Catalog.Elements["/Names"] = names; + return root; + } + + [Theory] + [InlineData("outline-next")] + [InlineData("outline-first")] + [InlineData("destination-count")] + [InlineData("destination-value")] + [InlineData("destination-object")] + [InlineData("destination-integer")] + [InlineData("name-cycle")] + [InlineData("name-value")] + [InlineData("field-cycle")] + [InlineData("field-parent-self")] + [InlineData("field-parent-other")] + [InlineData("field-parent-null")] + [InlineData("field-shared")] + [InlineData("field-title")] + [InlineData("null-outline")] + [InlineData("null-field")] + [InlineData("null-resource")] + [InlineData("resource-value")] + [InlineData("resource-cycle")] + [InlineData("resource-shared")] + [InlineData("signature")] + public void Broken_items_are_dropped_and_their_neighbours_kept(string kind) + { + var source = Source(document => + { + if (kind.StartsWith("outline", StringComparison.Ordinal) || kind == "null-outline" || + kind.StartsWith("destination", StringComparison.Ordinal)) + { + var root = OutlineRoot(document); + var before = Bookmark(document, "before", Dest(document, 0)); + var broken = Bookmark(document, "broken", Dest(document, 1)); + var after = Bookmark(document, "after", Dest(document, 1)); + Children(root, before, broken, after); + switch (kind) + { + case "outline-next": after.Elements["/Next"] = before.Reference; break; + case "outline-first": broken.Elements["/First"] = before.Reference; break; + case "null-outline": broken.Elements["/First"] = PdfNull.Value; break; + case "destination-count": broken.Elements["/Dest"] = Dest(document, 1, "/XYZ", new PdfInteger(1)); break; + case "destination-value": broken.Elements["/Dest"] = Dest(document, 1, "/FitH", new PdfString("bad")); break; + case "destination-object": A(broken.Elements["/Dest"]).Elements[0] = Resource(document).Reference!; break; + case "destination-integer": A(broken.Elements["/Dest"]).Elements[0] = new PdfInteger(0); break; + } + Link(document, 0, broken.Elements["/Dest"]); + } + else if (kind.StartsWith("name", StringComparison.Ordinal)) + { + var root = NameTree(document); + var child = Object(document); + root.Elements["/Names"] = Items(document, new PdfString("before"), Dest(document, 0)); + child.Elements["/Names"] = Items(document, new PdfString("after"), Dest(document, 1), + new PdfString("broken"), kind == "name-value" ? new PdfInteger(42) : Dest(document, 0)); + root.Elements["/Kids"] = Items(document, child.Reference!); + if (kind == "name-cycle") + child.Elements["/Kids"] = Items(document, root.Reference!); + } + else if (kind.StartsWith("resource", StringComparison.Ordinal) || kind == "null-resource") + { + var form = Form(document); + Field(document, form, "before", "ok"); + Field(document, form, "after", "ok"); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/Before"] = Resource(document, "Helvetica").Reference; + var shared = Resource(document); + if (kind is "null-resource" or "resource-value") + fonts.Elements["/Broken"] = kind == "null-resource" ? PdfNull.Value : new PdfInteger(42); + else + { + using var bytes = new MemoryStream(); + using (var compressed = new ZLibStream(bytes, CompressionLevel.SmallestSize, leaveOpen: true)) + compressed.Write("resource"u8); + shared.CreateStream(bytes.ToArray()); + shared.Elements["/Filter"] = new PdfName("/FlateDecode"); + fonts.Elements["/Broken"] = shared.Reference; + shared.Elements["/A"] = shared.Reference; + shared.Elements["/B"] = shared.Reference; + if (kind == "resource-shared") + fonts.Elements["/Again"] = shared.Reference; + } + fonts.Elements["/After"] = Resource(document, "Courier").Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + } + else + { + var form = Form(document); + Field(document, form, "before", "ok"); + var broken = Field(document, form, "broken", "bad", multiple: true); + var widget = D(A(broken.Elements["/Kids"]).Elements[0]); + var after = Field(document, form, "after", "ok"); + switch (kind) + { + case "field-cycle": A(broken.Elements["/Kids"]).Elements.Add(broken.Reference!); break; + case "field-parent-self": broken.Elements["/Parent"] = broken.Reference; break; + case "field-parent-other": widget.Elements["/Parent"] = after.Reference; break; + case "field-parent-null": widget.Elements["/Parent"] = PdfNull.Value; break; + case "field-shared": + var branch = Object(document); + branch.Elements["/Parent"] = broken.Reference; + branch.Elements["/Kids"] = Items(document, widget.Reference!); + A(broken.Elements["/Kids"]).Elements.Add(branch.Reference!); + break; + case "field-title": broken.Elements["/T"] = new PdfInteger(7); break; + case "null-field": A(broken.Elements["/Kids"]).Elements.Add(PdfNull.Value); break; + case "signature": broken.Elements["/FT"] = new PdfName("/Sig"); break; + } + } + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + if (kind.StartsWith("outline", StringComparison.Ordinal) || kind == "null-outline" || + kind.StartsWith("destination", StringComparison.Ordinal)) + { + var items = OutlineItems(result); + Assert.Equal(new[] { "before", "broken", "after" }, items.Select(d => S(d.Elements["/Title"]))); + if (kind.StartsWith("destination", StringComparison.Ordinal)) + { + Assert.False(items[1].Elements.ContainsKey("/Dest")); + Assert.False(Links(result, 0)[0].Elements.ContainsKey("/Dest")); + } + } + else if (kind.StartsWith("name", StringComparison.Ordinal)) + { + var names = Destinations(result); + Assert.Contains("before", names.Keys); + Assert.Contains("after", names.Keys); + Assert.Equal(kind == "name-value" ? 2 : 3, names.Count); + } + else + { + Assert.Equal(new[] { "before", "after" }, Fields(result).Keys); + if (kind.StartsWith("resource", StringComparison.Ordinal) || kind == "null-resource") + { + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Contains("/Before", fonts.Elements.Keys); + Assert.Contains("/After", fonts.Elements.Keys); + if (kind is "null-resource" or "resource-value") + Assert.False(fonts.Elements.ContainsKey("/Broken")); + else + { + var shared = D(fonts.Elements["/Broken"]); + Assert.Equal("/FlateDecode", S(shared.Elements["/Filter"])); + Assert.Equal("resource"u8.ToArray(), shared.Stream.UnfilteredValue); + Assert.Same(shared, D(shared.Elements["/A"])); + Assert.Same(shared, D(shared.Elements["/B"])); + if (kind == "resource-shared") + Assert.Same(shared, D(fonts.Elements["/Again"])); + } + } + } + Assert.Equal(result.PageCount, result.Internals.GetAllObjects().OfType().Count(d => d.Elements["/Type"] is { } type && S(type) == "/Page")); + } + + [Fact] + public void Named_destinations_keep_their_namespace_and_bytes() + { + var source = Source(document => + { + var tree = NameTree(document); + tree.Elements["/Names"] = Items(document, + new PdfString("intro"), Dest(document, 0), + new PdfString("A", PdfStringEncoding.Unicode), Dest(document, 0), + new PdfString("B", PdfStringEncoding.RawEncoding), Dest(document, 1), + new PdfString("caf\xe9", PdfStringEncoding.RawEncoding), Dest(document, 1), + new PdfString("한글", PdfStringEncoding.Unicode), Dest(document, 1)); + var old = new PdfDictionary(document); + old.Elements["/intro"] = Dest(document, 1); + old.Elements["/hash#23name"] = Dest(document, 0); + document.Internals.Catalog.Elements["/Dests"] = old; + Link(document, 0, new PdfString("intro")); + Link(document, 0, new PdfName("/intro")); + Link(document, 0, new PdfString("A", PdfStringEncoding.Unicode)); + Link(document, 0, new PdfString("caf\xe9", PdfStringEncoding.RawEncoding)); + Link(document, 0, new PdfName("/hash#23name")); + var root = OutlineRoot(document); + Children(root, Bookmark(document, "string", new PdfString("intro")), + Bookmark(document, "name", new PdfName("/intro"))); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var names = Destinations(result); + Assert.Contains("caf\xe9", names.Keys); + Assert.Contains("hash#23name", names.Keys); + Assert.Contains("\xfe\xff\0A", names.Keys); + Assert.Contains("\xfe\xff" + Encoding.Latin1.GetString(Encoding.BigEndianUnicode.GetBytes("한글")), names.Keys); + Assert.Equal(names.Keys.Order(StringComparer.Ordinal), names.Keys); + Assert.True(names.Keys.ToList().IndexOf("B") < names.Keys.ToList().IndexOf("\xfe\xff\0A")); + var links = Links(result, 0); + View(result, Resolved(result, links[0].Elements["/Dest"]), 0, "/Fit"); + View(result, Resolved(result, links[1].Elements["/Dest"]), 1, "/Fit"); + View(result, Resolved(result, links[2].Elements["/Dest"]), 0, "/Fit"); + View(result, Resolved(result, links[3].Elements["/Dest"]), 1, "/Fit"); + View(result, Resolved(result, links[4].Elements["/Dest"]), 0, "/Fit"); + var outlines = OutlineItems(result); + View(result, A(outlines[0].Elements["/Dest"]), 0, "/Fit"); + View(result, A(outlines[1].Elements["/Dest"]), 1, "/Fit"); + } + + private static void OutlineList(PdfDocument document, int count, bool chain = false, PdfItem? title = null, PdfItem? dest = null) + { + var root = OutlineRoot(document); + var parent = root; + PdfDictionary? previous = null; + for (var i = 0; i < count; i++) + { + var item = Bookmark(document, i.ToString(System.Globalization.CultureInfo.InvariantCulture), Dest(document, i % 2)); + if (title is not null) + item.Elements["/Title"] = title; + if (dest is not null) + item.Elements["/Dest"] = dest; + item.Elements["/Count"] = new PdfInteger(i % 2 == 0 ? 999 : -999); + if (chain || previous is null) + parent.Elements["/First"] = item.Reference; + else + previous.Elements["/Next"] = item.Reference; + item.Elements["/Parent"] = parent.Reference; + previous = item; + if (chain) + parent = item; + } + } + + private static PdfDictionary ResourceChain(PdfDocument document, int count) + { + var first = Resource(document); + var current = first; + for (var i = 1; i < count; i++) + { + var next = Resource(document); + current.Elements["/Next"] = next.Reference; + current = next; + } + return first; + } + + [Theory] + [InlineData("outline-items")] + [InlineData("outline-depth")] + [InlineData("name-nodes")] + [InlineData("resources")] + [InlineData("appearance")] + [InlineData("shared-title")] + [InlineData("long-name")] + [InlineData("shared-kids")] + [InlineData("shared-page-resource")] + [InlineData("shared-field-resources")] + [InlineData("shared-dest")] + [InlineData("repeated-key")] + [InlineData("two-long-dests")] + [InlineData("shared-action")] + [InlineData("wide-page-resource")] + public void Structure_beyond_the_limits_is_cut_in_bounded_time_and_memory(string kind) + { + var source = Source(document => + { + switch (kind) + { + case "outline-items": OutlineList(document, 150_000); break; + case "outline-depth": OutlineList(document, 1000, chain: true); break; + case "shared-title": + var shared = new PdfStringObject(document, new string('x', 64 * 1024)); + document.Internals.AddObject(shared); + OutlineList(document, 150_000, title: shared.Reference); + break; + case "name-nodes": + var tree = NameTree(document); + var kids = new PdfArray(document); + tree.Elements["/Kids"] = kids; + tree.Elements["/Names"] = Items(document, new PdfString("root"), Dest(document, 0)); + for (var i = 1; i < 150_000; i++) + { + var child = Object(document); + child.Elements["/Names"] = Items(document, new PdfString($"n{i}"), Dest(document, 0)); + kids.Elements.Add(child.Reference!); + } + break; + case "resources": + var form = Form(document); + Field(document, form, "name", "value"); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/Small"] = Resource(document, "Helvetica").Reference; + fonts.Elements["/Long"] = ResourceChain(document, 20_000).Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + break; + case "appearance": + var appearanceForm = Form(document); + appearanceForm.Elements["/DA"] = new PdfString("/F1 10 Tf " + new string(' ', 4096 - 10)); + for (var i = 0; i < 1000; i++) + Field(document, appearanceForm, $"f{i}", "v"); + break; + case "shared-kids": + // 50,000 nodes share one /Kids array of 50,000 entries: small to store, 2.5 billion + // children if the array were read again for every node. + var leaf = Object(document); + leaf.Elements["/Names"] = Items(document, new PdfString("leaf"), Dest(document, 0)); + var sharedKids = new PdfArray(document); + document.Internals.AddObject(sharedKids); + for (var i = 0; i < 50_000; i++) + sharedKids.Elements.Add(leaf.Reference!); + var top = new PdfArray(document); + for (var i = 0; i < 50_000; i++) + { + var node = Object(document); + node.Elements["/Kids"] = sharedKids.Reference; + top.Elements.Add(node.Reference!); + } + NameTree(document).Elements["/Kids"] = top; + break; + case "shared-page-resource": + // 50,000 fonts lead into one chain of 9,000 dictionaries that ends at a page. + var pageForm = Form(document); + Field(document, pageForm, "name", "v"); + var chain = Object(document); + chain.Elements["/ExamplePage"] = document.Pages[0].Reference; + for (var i = 1; i < 9000; i++) + { + var link = Object(document); + link.Elements["/Next"] = chain.Reference; + chain = link; + } + var chainFonts = new PdfDictionary(document); + for (var i = 0; i < 50_000; i++) + { + var font = Resource(document, "Helvetica"); + font.Elements["/Example"] = chain.Reference; + chainFonts.Elements[$"/F{i}"] = font.Reference; + } + var chainResources = new PdfDictionary(document); + chainResources.Elements["/Font"] = chainFonts; + pageForm.Elements["/DR"] = chainResources; + break; + case "shared-field-resources": + // 50,000 fields share one /DR whose /Font has 50,000 names. + var sharedForm = Form(document); + var helvetica = Resource(document, "Helvetica").Reference!; + var ownFonts = new PdfDictionary(document); + for (var i = 0; i < 50_000; i++) + ownFonts.Elements[$"/F{i}"] = helvetica; + var own = Object(document); + own.Elements["/Font"] = ownFonts; + for (var i = 0; i < 50_000; i++) + Field(document, sharedForm, $"f{i}", "v").Elements["/DR"] = own.Reference; + break; + case "wide-page-resource": + // 5,000 fonts lead into one array of a page and 200,000 integers. + var wideForm = Form(document); + Field(document, wideForm, "name", "v"); + var wide = new PdfArray(document); + document.Internals.AddObject(wide); + wide.Elements.Add(document.Pages[0].Reference!); + for (var i = 0; i < 200_000; i++) + wide.Elements.Add(new PdfInteger(0)); + var wideFonts = new PdfDictionary(document); + for (var i = 0; i < 5000; i++) + { + var font = Resource(document, "Helvetica"); + font.Elements["/Example"] = wide.Reference; + wideFonts.Elements[$"/F{i}"] = font.Reference; + } + var wideResources = new PdfDictionary(document); + wideResources.Elements["/Font"] = wideFonts; + wideForm.Elements["/DR"] = wideResources; + break; + case "shared-dest": + // 10,000 bookmarks name one destination through the same 1 MiB Unicode string. + var unicodeKey = new PdfStringObject(document, new string('\uD55C', 512 * 1024)); + document.Internals.AddObject(unicodeKey); + NameTree(document).Elements["/Names"] = Items(document, unicodeKey.Reference!, Dest(document, 0)); + OutlineList(document, 10_000, dest: unicodeKey.Reference); + break; + case "repeated-key": + // One /Names array repeats the same 64 KiB key 150,000 times. + var repeatedKey = new PdfStringObject(document, new string('k', 64 * 1024)); + document.Internals.AddObject(repeatedKey); + var repeated = new PdfArray(document); + for (var i = 0; i < 150_000; i++) + { + repeated.Elements.Add(repeatedKey.Reference!); + repeated.Elements.Add(Dest(document, 0)); + } + NameTree(document).Elements["/Names"] = repeated; + break; + case "two-long-dests": + // Two long keys that differ only in the last character, used by alternating bookmarks. + var prefix = new string('\uD55C', 256 * 1024); + var first = new PdfStringObject(document, prefix + "A"); + var second = new PdfStringObject(document, prefix + "B"); + document.Internals.AddObject(first); + document.Internals.AddObject(second); + NameTree(document).Elements["/Names"] = Items(document, first.Reference!, Dest(document, 0), second.Reference!, Dest(document, 1)); + OutlineList(document, 5_000, dest: first.Reference); + var alternate = OutlineItems(document); + for (var i = 1; i < alternate.Count; i += 2) + alternate[i].Elements["/Dest"] = second.Reference; + break; + case "shared-action": + // 20,000 links share one action whose direct /Next holds 5,000 URI actions. + var nextActions = new PdfArray(document); + for (var i = 0; i < 5000; i++) + { + var uri = new PdfDictionary(document); + uri.Elements["/S"] = new PdfName("/URI"); + uri.Elements["/URI"] = new PdfString($"https://example.org/{i}"); + nextActions.Elements.Add(uri); + } + var sharedAction = Object(document); + sharedAction.Elements["/S"] = new PdfName("/GoTo"); + sharedAction.Elements["/D"] = Dest(document, 1); + sharedAction.Elements["/Next"] = nextActions; + for (var i = 0; i < 20_000; i++) + Link(document, 0, action: sharedAction); + break; + case "long-name": + var longKey = new string('k', 64 * 1024); + var key = new PdfStringObject(document, longKey); + document.Internals.AddObject(key); + NameTree(document).Elements["/Names"] = Items(document, key.Reference!, Dest(document, 0)); + for (var i = 0; i < 16_384; i++) + Link(document, 0, key.Reference!); + break; + } + }); + var output = Path.Combine(Path.GetDirectoryName(source)!, "out.pdf"); + // Opening and saving a large source already allocates hundreds of MB before any structure is + // carried, so the bound is relative: the same merge with pages only, as before this change. + var pagesOnly = GC.GetAllocatedBytesForCurrentThread(); + using (var baseline = new PdfDocument()) + { + using (var opened = PdfReader.Open(source, PdfDocumentOpenMode.Import)) + foreach (var page in opened.Pages) + baseline.AddPage(page); + baseline.Save(Path.Combine(Path.GetDirectoryName(source)!, "baseline.pdf")); + } + pagesOnly = GC.GetAllocatedBytesForCurrentThread() - pagesOnly; + var allocated = GC.GetAllocatedBytesForCurrentThread(); + var timer = Stopwatch.StartNew(); + PdfSharpConverter.Merge([source], output, TestContext.Current.CancellationToken); + timer.Stop(); + allocated = GC.GetAllocatedBytesForCurrentThread() - allocated; + TestContext.Current.TestOutputHelper?.WriteLine($"{kind}: {timer.Elapsed.TotalSeconds:F3} s, {allocated:N0} bytes, pages only {pagesOnly:N0} bytes"); + using var result = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + switch (kind) + { + case "outline-items": Assert.Equal(100_000, OutlineItems(result).Count); break; + case "outline-depth": Assert.Equal(64, OutlineItems(result).Count); break; + case "name-nodes": Assert.Equal(100_000, Destinations(result).Count); break; + case "shared-kids": Assert.Equal(new[] { "leaf" }, Destinations(result).Keys); break; + case "shared-page-resource": + Assert.False(D(D(result.Internals.Catalog.Elements["/AcroForm"])).Elements.ContainsKey("/DR")); + Assert.Single(Fields(result)); + break; + case "shared-field-resources": Assert.Equal(50_000, Fields(result).Count); break; + case "shared-dest": + var destOutlines = OutlineItems(result); + Assert.Equal(10_000, destOutlines.Count); + Assert.All(destOutlines, d => Assert.Equal(result.Pages[0].Reference, A(d.Elements["/Dest"]).Elements[0])); + break; + case "repeated-key": Assert.Single(Destinations(result)); break; + case "shared-action": + var sharedLinks = Links(result, 0); + Assert.Equal(20_000, sharedLinks.Count); + Assert.Single(sharedLinks.Select(l => Assert.IsType(l.Elements["/A"]).Value).Distinct()); + break; + case "two-long-dests": + var alternating = OutlineItems(result); + Assert.Equal(5_000, alternating.Count); + for (var i = 0; i < alternating.Count; i++) + Assert.Equal(result.Pages[i % 2].Reference, A(alternating[i].Elements["/Dest"]).Elements[0]); + break; + case "wide-page-resource": + Assert.False(D(D(result.Internals.Catalog.Elements["/AcroForm"])).Elements.ContainsKey("/DR")); + Assert.Single(Fields(result)); + break; + case "resources": + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal(new[] { "/Small" }, fonts.Elements.Keys); + Assert.DoesNotContain(result.Internals.GetAllObjects().OfType(), d => d.Elements.ContainsKey("/Next")); + break; + case "appearance": + var fields = Fields(result); + Assert.Equal(256, fields.Count); + Assert.Equal(1024 * 1024, fields.Values.Sum(f => S(f.Elements["/DA"]).Length)); + break; + case "shared-title": + var outlines = OutlineItems(result); + Assert.Equal(64, outlines.Count); + Assert.All(outlines, d => Assert.Equal(new string('x', 64 * 1024), S(d.Elements["/Title"]))); + Assert.Equal(4 * 1024 * 1024, outlines.Sum(d => S(d.Elements["/Title"]).Length)); + break; + case "long-name": + var links = Links(result, 0); + Assert.Equal(63, links.Count(d => d.Elements.ContainsKey("/Dest"))); + Assert.All(links.Take(63), d => Assert.Equal(new string('k', 64 * 1024), S(d.Elements["/Dest"]))); + Assert.All(links.Skip(63), d => Assert.False(d.Elements.ContainsKey("/Dest"))); + Assert.Equal(4 * 1024 * 1024, Destinations(result).Keys.Sum(k => k.Length) + links.Where(d => d.Elements.ContainsKey("/Dest")).Sum(d => Key(d.Elements["/Dest"]!).Length)); + break; + } + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(10), $"{kind}: {timer.Elapsed}"); + Assert.True(allocated < 3 * pagesOnly + 64 * 1024 * 1024, $"{kind}: {allocated} bytes, pages only {pagesOnly} bytes"); + } + + [Theory] + [InlineData("outline-items")] + [InlineData("outline-depth")] + [InlineData("name-nodes")] + [InlineData("name-depth")] + [InlineData("names")] + [InlineData("field-depth")] + [InlineData("field-items")] + [InlineData("resources")] + [InlineData("strings")] + [InlineData("appearance-length")] + public void Each_limit_cuts_exactly_at_its_value(string kind) + { + if (kind == "appearance-length") + { + var seed = Source(document => + { + var form = Form(document); + Field(document, form, "seed", "v"); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/F1"] = Resource(document, "Helvetica").Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + var boundary = Source(document => + { + var form = Form(document); + form.Elements["/DA"] = new PdfString("/F1 10 Tf " + new string(' ', 4096 - 10)); + Field(document, form, "before", "ok"); + var own = Field(document, form, "long", "ok"); + own.Elements["/DA"] = new PdfString("/F1 10 Tf " + new string(' ', 4097 - 10)); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/F1"] = Resource(document, "Courier").Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + var beyond = Source(document => + { + var form = Form(document); + form.Elements["/DA"] = new PdfString("/F1 10 Tf " + new string(' ', 4097 - 10)); + Field(document, form, "not-inherited", "ok"); + }); + var path = Path.Combine(Path.GetDirectoryName(seed)!, "length.pdf"); + PdfSharpConverter.Merge([seed, boundary, beyond], path, TestContext.Current.CancellationToken); + using var opened = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var fields = Fields(opened); + Assert.StartsWith("/F1_2 10 Tf ", S(fields["before"].Elements["/DA"]), StringComparison.Ordinal); + Assert.Equal(4098, S(fields["before"].Elements["/DA"]).Length); + Assert.StartsWith("/F1 10 Tf ", S(fields["long"].Elements["/DA"]), StringComparison.Ordinal); + Assert.Equal(4097, S(fields["long"].Elements["/DA"]).Length); + Assert.False(fields["not-inherited"].Elements.ContainsKey("/DA")); + return; + } + var limits = new PdfMergeStructure.Limits(); + limits = kind switch + { + "outline-items" => limits with { OutlineItems = 3 }, + "outline-depth" => limits with { OutlineDepth = 3 }, + "name-nodes" => limits with { NameTreeNodes = 3 }, + "name-depth" => limits with { NameTreeDepth = 3 }, + "names" => limits with { NamedDestinations = 3 }, + "field-depth" => limits with { FieldDepth = 3 }, + "field-items" => limits with { FieldItems = 3 }, + "resources" => limits with { ResourceObjects = 5 }, + "strings" => limits with { StringBytes = 3 }, + _ => limits, + }; + var source = Source(document => + { + switch (kind) + { + case "outline-items": OutlineList(document, 5, chain: true); break; + case "outline-depth": OutlineList(document, 5, chain: true); break; + case "strings": + var root = OutlineRoot(document); + Children(root, Bookmark(document, "a"), Bookmark(document, "bb"), Bookmark(document, "c")); + break; + case "name-nodes": + case "name-depth": + var tree = NameTree(document); + for (var i = 0; i < 5; i++) + { + tree.Elements["/Names"] = Items(document, new PdfString($"n{i}"), Dest(document, 0)); + var child = Object(document); + tree.Elements["/Kids"] = Items(document, child.Reference!); + tree = child; + } + break; + case "names": + var pairs = new PdfArray(document); + NameTree(document).Elements["/Names"] = pairs; + for (var i = 0; i < 5; i++) + { + pairs.Elements.Add(new PdfString($"n{i}")); + pairs.Elements.Add(Dest(document, 0)); + } + break; + case "field-depth": + case "field-items": + var form = Form(document); + Field(document, form, "before", "ok"); + var parent = Object(document); + parent.Elements["/T"] = new PdfString("broken"); + A(form.Elements["/Fields"]).Elements.Add(parent.Reference!); + for (var i = 0; i < 3; i++) + { + var child = i == 2 ? Widget(document, 0, parent) : Object(document); + child.Elements["/Parent"] = parent.Reference; + parent.Elements["/Kids"] = Items(document, child.Reference!); + parent = child; + } + break; + case "resources": + var resourceForm = Form(document); + Field(document, resourceForm, "name", "v"); + var resources = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/Before"] = ResourceChain(document, 3).Reference; + fonts.Elements["/Broken"] = ResourceChain(document, 3).Reference; + fonts.Elements["/After"] = Resource(document, "Courier").Reference; + resources.Elements["/Font"] = fonts; + resourceForm.Elements["/DR"] = resources; + break; + } + }); + using var result = PdfReader.Open(MergeOne(source, limits), PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + switch (kind) + { + case "outline-items": + case "outline-depth": + var outlines = OutlineItems(result); + Assert.Equal(3, outlines.Count); + Assert.Equal(2, Assert.IsType(D(result.Internals.Catalog.Elements["/Outlines"]).Elements["/Count"]).Value); + Assert.Equal(1, Assert.IsType(outlines[0].Elements["/Count"]).Value); + Assert.Equal(-1, Assert.IsType(outlines[1].Elements["/Count"]).Value); + Assert.False(outlines[2].Elements.ContainsKey("/Count")); + break; + case "name-nodes": + case "name-depth": + case "names": Assert.Equal(3, Destinations(result).Count); break; + case "strings": Assert.Equal(new[] { "a", "bb" }, OutlineItems(result).Select(d => S(d.Elements["/Title"]))); break; + case "field-depth": + case "field-items": Assert.Equal(new[] { "before" }, Fields(result).Keys); break; + case "resources": + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal(new[] { "/Before" }, fonts.Elements.Keys); + Assert.Equal(2, result.Internals.GetAllObjects().OfType().Count(d => d.Elements.ContainsKey("/Next"))); + break; + } + } + + [Fact] + public void Fields_keep_the_default_appearance_and_resources_of_their_source() + { + using var document = PdfReader.Open(Merge(), PdfDocumentOpenMode.Import); + AssertDefaults(document); + var seed = Source(pdf => + { + var form = Form(pdf); + Field(pdf, form, "first", "v").Elements["/DA"] = new PdfString("/F1 10 Tf"); + var resources = new PdfDictionary(pdf); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + var second = Source(pdf => + { + var form = Form(pdf); + Field(pdf, form, "second", "v").Elements["/DA"] = new PdfString("/F1 12 Tf"); + Field(pdf, form, "own", "v").Elements["/DA"] = new PdfString("/F1_2 9 Tf"); + var resources = new PdfDictionary(pdf); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Courier").Reference; + fonts.Elements["/F1_2"] = Resource(pdf, "Times-Roman").Reference; + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + var path = Path.Combine(Path.GetDirectoryName(seed)!, "reserved.pdf"); + PdfSharpConverter.Merge([seed, second], path, TestContext.Current.CancellationToken); + using var reserved = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var fields = Fields(reserved); + var fonts = D(D(D(reserved.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + foreach (var (field, font) in new[] { ("first", "/Helvetica"), ("second", "/Courier"), ("own", "/Times-Roman") }) + { + var name = S(fields[field].Elements["/DA"]).Split(' ')[0]; + Assert.Equal(font, S(D(fonts.Elements[name]).Elements["/BaseFont"])); + } + Assert.StartsWith("/F1_2 ", S(fields["own"].Elements["/DA"]), StringComparison.Ordinal); + } + + [Fact] + public void Renamed_fonts_also_resolve_in_a_fields_own_resources() + { + // LibreOffice writes a /DR on each field as well as on the form. + string Make(string font, string? ownOther = null) => Source(pdf => + { + var form = Form(pdf); + var helv = Resource(pdf, font).Reference; + var field = Field(pdf, form, "name", "v"); + field.Elements["/DA"] = new PdfString("0 g /He 8 Tf"); + foreach (var owner in new[] { form, field }) + { + var resources = new PdfDictionary(pdf); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/He"] = helv; + // A name only the field's own /DR has must not be taken by the renamed form font. + if (owner == field && ownOther is not null) + fonts.Elements["/He_2"] = Resource(pdf, ownOther).Reference; + resources.Elements["/Font"] = fonts; + owner.Elements["/DR"] = resources; + } + }); + var first = Make("Helvetica"); + var path = Path.Combine(Path.GetDirectoryName(first)!, "own-resources.pdf"); + PdfSharpConverter.Merge([first, Make("Courier", ownOther: "Times-Roman")], path, TestContext.Current.CancellationToken); + using var merged = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var fields = Fields(merged); + var formFonts = D(D(D(merged.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + foreach (var (field, font) in new[] { ("name", "/Helvetica"), ("2.name", "/Courier") }) + { + var name = S(fields[field].Elements["/DA"]).Split(' ')[2]; + var ownFonts = D(D(fields[field].Elements["/DR"]).Elements["/Font"]); + Assert.Equal(font, S(D(formFonts.Elements[name]).Elements["/BaseFont"])); + Assert.Equal(font, S(D(ownFonts.Elements[name]).Elements["/BaseFont"])); + } + } + + [Fact] + public void Resource_strings_are_counted_at_every_use() + { + // One indirect string shared by three fonts is written once per font in the merged /DR. + var source = Source(pdf => + { + var form = Form(pdf); + Field(pdf, form, "name", "v"); + var shared = new PdfStringObject(pdf, new string('c', 1000)); + pdf.Internals.AddObject(shared); + var fonts = new PdfDictionary(pdf); + foreach (var key in new[] { "/F1", "/F2", "/F3" }) + { + var font = Resource(pdf, "Helvetica"); + font.Elements["/CharSet"] = shared.Reference; + fonts.Elements[key] = font.Reference; + } + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + // Each font writes /Font, /Type1, /Helvetica and the 1,000-byte string: 1,021 bytes. + using var result = PdfReader.Open(MergeOne(source, new PdfMergeStructure.Limits { ResourceBytes = 2500 }), PdfDocumentOpenMode.Import); + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal(new[] { "/F1", "/F2" }, fonts.Elements.Keys); + } + + [Fact] + public void Resources_that_lead_to_a_page_are_left_out() + { + var source = Source(pdf => + { + var form = Form(pdf); + Field(pdf, form, "name", "v"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var odd = Resource(pdf, "Courier"); + odd.Elements["/ExamplePage"] = pdf.Pages[0].Reference; + fonts.Elements["/F2"] = odd.Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal(new[] { "/F1" }, fonts.Elements.Keys); + Assert.Equal(result.PageCount, result.Internals.GetAllObjects().OfType() + .Count(d => V(d.Elements["/Type"]) is PdfName { Value: "/Page" })); + } + + [Fact] + public void Names_repeated_across_sources_take_the_next_free_suffix() + { + string Make(string destination, string field) => Source(pdf => + { + NameTree(pdf).Elements["/Names"] = Items(pdf, new PdfString(destination), Dest(pdf, 0)); + var form = Form(pdf); + Field(pdf, form, field, "v"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + }); + var sources = new[] { Make("intro", "f0"), Make("intro", "f1"), Make("intro-3", "f2"), Make("intro", "f0") }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "names.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + Assert.Equal(new[] { "intro", "intro-2", "intro-3", "intro-4" }, Destinations(result).Keys.OrderBy(k => k, StringComparer.Ordinal)); + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal(new[] { "/F1", "/F1_2", "/F1_3", "/F1_4" }, fonts.Elements.Keys.OrderBy(k => k, StringComparer.Ordinal)); + Assert.Equal(new[] { "4.f0", "f0", "f1", "f2" }, Fields(result).Keys.Where(k => k != "4").OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void A_name_one_source_only_reserves_stays_free_for_the_next() + { + string Make(string font, int fields, int ownNames = 0, string? appearance = null) => Source(pdf => + { + var form = Form(pdf); + if (appearance is not null) + form.Elements["/DA"] = new PdfString(appearance); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, font).Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + for (var i = 0; i < fields; i++) + { + var field = Field(pdf, form, $"{font}{i}", "v"); + if (ownNames == 0) + continue; + field.Elements["/DA"] = new PdfString("/F1 10 Tf"); + var own = new PdfDictionary(pdf); + own.Elements["/F1"] = fonts.Elements["/F1"]; + for (var n = 2; n < 2 + ownNames; n++) + own.Elements[$"/F1_{n}"] = Resource(pdf, "Times-Roman").Reference; + var ownResources = new PdfDictionary(pdf); + ownResources.Elements["/Font"] = own; + field.Elements["/DR"] = ownResources; + } + }); + // The third source's 256 fields fit the 1 MiB appearance budget exactly when its font becomes /F1_2. + var sources = new[] { Make("Helvetica", 1), Make("Courier", 1, ownNames: 8), Make("Symbol", 256, appearance: "/F1 10 Tf " + new string(' ', 4094 - 10)) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "reserved.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var fonts = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]); + Assert.Equal("/Courier", S(D(fonts.Elements["/F1_10"]).Elements["/BaseFont"])); + Assert.Equal("/Symbol", S(D(fonts.Elements["/F1_2"]).Elements["/BaseFont"])); + var fields = Fields(result); + Assert.Equal(256, fields.Keys.Count(k => k.StartsWith("Symbol", StringComparison.Ordinal))); + Assert.All(fields.Where(f => f.Key.StartsWith("Symbol", StringComparison.Ordinal)), f => Assert.StartsWith("/F1_2 10 Tf ", S(f.Value.Elements["/DA"]), StringComparison.Ordinal)); + } + + [Fact] + public void A_long_key_whose_first_destination_is_broken_takes_the_next_one() + { + var source = Source(pdf => + { + var key = new PdfStringObject(pdf, new string('k', 256)); + pdf.Internals.AddObject(key); + NameTree(pdf).Elements["/Names"] = Items(pdf, key.Reference!, new PdfInteger(42), key.Reference!, Dest(pdf, 0)); + Link(pdf, 1, key.Reference); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + Assert.Equal(new[] { new string('k', 256) }, Destinations(result).Keys); + Assert.True(Assert.Single(Links(result, 1)).Elements.ContainsKey("/Dest")); + } + + [Fact] + public void A_shared_name_tree_array_is_read_again_from_a_shallower_node_and_per_role() + { + var source = Source(pdf => + { + var leaf = Object(pdf); + leaf.Elements["/Names"] = Items(pdf, new PdfString("ok"), Dest(pdf, 0)); + var shared = Items(pdf, leaf.Reference!); + pdf.Internals.AddObject(shared); + var deep = Object(pdf); + deep.Elements["/Kids"] = shared.Reference; + var a1 = Object(pdf); + a1.Elements["/Kids"] = Items(pdf, deep.Reference!); + var b = Object(pdf); + b.Elements["/Kids"] = shared.Reference; + // One array as an odd-length /Names and as /Kids: the broken role must not hide the other. + var other = Object(pdf); + other.Elements["/Names"] = Items(pdf, new PdfString("ok2"), Dest(pdf, 1)); + var both = Items(pdf, other.Reference!); + pdf.Internals.AddObject(both); + var c = Object(pdf); + c.Elements["/Names"] = both.Reference; + c.Elements["/Kids"] = both.Reference; + NameTree(pdf).Elements["/Kids"] = Items(pdf, a1.Reference!, b.Reference!, c.Reference!); + }); + // Depth 3: the leaf is cut below a1 (depth 4) but fits below b (depth 3). + using var result = PdfReader.Open(MergeOne(source, new PdfMergeStructure.Limits { NameTreeDepth = 3 }), PdfDocumentOpenMode.Import); + Assert.Equal(new[] { "ok", "ok2" }, Destinations(result).Keys.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void A_fields_own_dictionary_shared_by_two_kinds_reserves_names_for_both() + { + PdfDictionary State(PdfDocument pdf, double alpha) + { + var state = Object(pdf); + state.Elements["/ca"] = new PdfReal(alpha); + return state; + } + string Make(string field, double alpha, bool own) => Source(pdf => + { + var form = Form(pdf); + var states = new PdfDictionary(pdf); + states.Elements["/R1"] = State(pdf, alpha).Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/ExtGState"] = states; + form.Elements["/DR"] = resources; + var widget = Field(pdf, form, field, "v"); + if (!own) + return; + widget.Elements["/DA"] = new PdfString("0 g /R1 gs"); + var shared = Object(pdf); + shared.Elements["/R1"] = states.Elements["/R1"]; + shared.Elements["/R1_2"] = State(pdf, 0.9).Reference; + var ownResources = new PdfDictionary(pdf); + ownResources.Elements["/Font"] = shared.Reference; + ownResources.Elements["/ExtGState"] = shared.Reference; + widget.Elements["/DR"] = ownResources; + }); + var sources = new[] { Make("a", 0.1, false), Make("b", 0.4, true) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "kinds.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var field = Fields(result)["b"]; + Assert.Equal("0 g /R1_3 gs", S(field.Elements["/DA"])); + double Alpha(PdfItem? state) => Assert.IsType(D(state).Elements["/ca"]).Value; + Assert.Equal(0.4, Alpha(D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/ExtGState"]).Elements["/R1_3"])); + Assert.Equal(0.4, Alpha(D(D(field.Elements["/DR"]).Elements["/ExtGState"]).Elements["/R1_3"])); + } + + [Fact] + public void Built_in_colour_space_names_are_not_renamed() + { + string Make(string field, string appearance) => Source(pdf => + { + var form = Form(pdf); + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DeviceRGB"] = new PdfName("/DeviceGray"); + var resources = new PdfDictionary(pdf); + resources.Elements["/ColorSpace"] = spaces; + form.Elements["/DR"] = resources; + Field(pdf, form, field, "v").Elements["/DA"] = new PdfString(appearance); + }); + var sources = new[] { Make("a", "/DeviceRGB cs 0 1 0 sc"), Make("b", "/DeviceRGB cs 1 0 0 sc /DeviceRGB CS") }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "colour.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + Assert.Equal("/DeviceRGB cs 1 0 0 sc /DeviceRGB CS", S(Fields(result)["b"].Elements["/DA"])); + } + + [Fact] + public void A_goto_link_keeps_its_next_action() + { + var source = Source(pdf => + { + var next = new PdfDictionary(pdf); + next.Elements["/S"] = new PdfName("/URI"); + next.Elements["/URI"] = new PdfString("https://example.org/receipt"); + var action = Object(pdf); + action.Elements["/S"] = new PdfName("/GoTo"); + action.Elements["/D"] = Dest(pdf, 1); + action.Elements["/Next"] = next; + Link(pdf, 0, action: action); + Link(pdf, 0, action: action); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var links = Links(result, 0); + Assert.Equal(2, links.Count); + // Both links still share one action object. + var reference = Assert.IsType(links[0].Elements["/A"]); + Assert.Same(reference.Value, Assert.IsType(links[1].Elements["/A"]).Value); + var merged = D(reference); + Assert.Equal(result.Pages[1].Reference, A(merged.Elements["/D"]).Elements[0]); + Assert.Equal("https://example.org/receipt", S(D(merged.Elements["/Next"]).Elements["/URI"])); + } + + [Fact] + public void A_kid_that_inherits_its_appearance_gets_the_renamed_font_in_its_own_resources() + { + string Make(string font, bool group) => Source(pdf => + { + var form = Form(pdf); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/He"] = Resource(pdf, font).Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + if (!group) + { + Field(pdf, form, "a", "v"); + return; + } + var parent = Object(pdf); + parent.Elements["/T"] = new PdfString("group"); + parent.Elements["/DA"] = new PdfString("/He 8 Tf 0 g"); + var kid = Widget(pdf, 0, parent); + kid.Elements["/T"] = new PdfString("name"); + kid.Elements["/FT"] = new PdfName("/Tx"); + kid.Elements["/V"] = new PdfString("B value"); + var own = new PdfDictionary(pdf); + own.Elements["/He"] = fonts.Elements["/He"]; + var ownResources = new PdfDictionary(pdf); + ownResources.Elements["/Font"] = own; + kid.Elements["/DR"] = ownResources; + parent.Elements["/Kids"] = Items(pdf, kid.Reference!); + A(form.Elements["/Fields"]).Elements.Add(parent.Reference!); + }); + var sources = new[] { Make("Helvetica", false), Make("Courier", true) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "inherit.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var fields = Fields(result); + Assert.Equal("/He_2 8 Tf 0 g", S(fields["group"].Elements["/DA"])); + var own = D(D(fields["group.name"].Elements["/DR"]).Elements["/Font"]); + Assert.Equal("/Courier", S(D(own.Elements["/He_2"]).Elements["/BaseFont"])); + Assert.Equal("/Courier", S(D(own.Elements["/He"]).Elements["/BaseFont"])); + } + + [Fact] + public void An_annotation_listed_many_times_is_rewritten_once() + { + var source = Source(pdf => + { + var action = Object(pdf); + action.Elements["/S"] = new PdfName("/GoTo"); + action.Elements["/D"] = Dest(pdf, 1); + var next = new PdfDictionary(pdf); + next.Elements["/S"] = new PdfName("/URI"); + next.Elements["/URI"] = new PdfString("https://example.org/receipt"); + action.Elements["/Next"] = next; + var link = Link(pdf, 0, action: action); + var annotations = A(pdf.Pages[0].Elements["/Annots"]); + annotations.Elements.Add(link.Reference!); + annotations.Elements.Add(link.Reference!); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var links = Links(result, 0); + Assert.Equal(3, links.Count); + var action = Assert.Single(links.Select(l => Assert.IsType(l.Elements["/A"]).Value).Distinct()); + Assert.Equal(result.Pages[1].Reference, A(D(action).Elements["/D"]).Elements[0]); + Assert.Single(result.Internals.GetAllObjects().OfType(), d => d.Elements.ContainsKey("/Next")); + } + + [Fact] + public void A_repeated_annotation_spends_its_destination_name_once() + { + var source = Source(pdf => + { + NameTree(pdf).Elements["/Names"] = Items(pdf, new PdfString("abc"), Dest(pdf, 1)); + var repeated = Link(pdf, 0, new PdfString("abc")); + var annotations = A(pdf.Pages[0].Elements["/Annots"]); + annotations.Elements.Add(repeated.Reference!); + annotations.Elements.Add(repeated.Reference!); + Link(pdf, 0, new PdfString("abc")); + }); + // 9 bytes: the registered name and one use for each of the two distinct links. + using var result = PdfReader.Open(MergeOne(source, new PdfMergeStructure.Limits { StringBytes = 9 }), PdfDocumentOpenMode.Import); + Assert.All(Links(result, 0), link => Assert.Equal("abc", Key(link.Elements["/Dest"]!))); + } + + [Fact] + public void Each_sources_default_colour_space_stays_with_its_fields() + { + string Make(string field, double? gamma) => Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /DeviceRGB cs 0.5 0.5 0.5 sc 0.1 0.2 0.3 rg"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + if (gamma is { } value) + { + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(value), new PdfReal(value), new PdfReal(value)); + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DefaultRGB"] = Items(pdf, new PdfName("/CalRGB"), parameters); + resources.Elements["/ColorSpace"] = spaces; + } + form.Elements["/DR"] = resources; + Field(pdf, form, field, "v"); + }); + var sources = new[] { Make("a", 1.8), Make("b", 2.2), Make("c", null) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "defaults.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var spaces = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/ColorSpace"]); + // No default space is left to recolour the device colours of another source's fields. + Assert.Equal(new[] { "/DefaultRGB_2", "/DefaultRGB_3" }, spaces.Elements.Keys.OrderBy(k => k, StringComparer.Ordinal)); + double Gamma(string name) => Assert.IsType(A(D(A(spaces.Elements[name]).Elements[1]).Elements["/Gamma"]).Elements[0]).Value; + Assert.Equal(1.8, Gamma("/DefaultRGB_2")); + Assert.Equal(2.2, Gamma("/DefaultRGB_3")); + var fields = Fields(result); + Assert.Equal("/F1 10 Tf /DefaultRGB_2 cs 0.5 0.5 0.5 sc /DefaultRGB_2 cs 0.1 0.2 0.3 sc", S(fields["a"].Elements["/DA"])); + Assert.Equal("/F1_2 10 Tf /DefaultRGB_3 cs 0.5 0.5 0.5 sc /DefaultRGB_3 cs 0.1 0.2 0.3 sc", S(fields["b"].Elements["/DA"])); + Assert.Equal("/F1_3 10 Tf /DeviceRGB cs 0.5 0.5 0.5 sc 0.1 0.2 0.3 rg", S(fields["c"].Elements["/DA"])); + } + + [Fact] + public void A_colour_space_naming_a_device_space_keeps_its_sources_default() + { + string Make(string field, bool withDefault) => Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /CS1 cs 0.5 0.5 0.5 sc"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var spaces = new PdfDictionary(pdf); + spaces.Elements["/CS1"] = new PdfName("/DeviceRGB"); + spaces.Elements["/CS2"] = Items(pdf, new PdfName("/Indexed"), new PdfName("/DeviceRGB"), new PdfInteger(1), new PdfString("abcdef")); + if (withDefault) + { + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(2.2), new PdfReal(2.2), new PdfReal(2.2)); + spaces.Elements["/DefaultRGB"] = Items(pdf, new PdfName("/CalRGB"), parameters); + } + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + form.Elements["/DR"] = resources; + Field(pdf, form, field, "v"); + }); + var sources = new[] { Make("a", true), Make("b", false) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "aliases.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var spaces = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/ColorSpace"]); + bool IsDefault(PdfItem? item) => V(item) is PdfArray array && S(array.Elements[0]) == "/CalRGB"; + Assert.True(IsDefault(spaces.Elements["/CS1"])); + Assert.True(IsDefault(A(spaces.Elements["/CS2"]).Elements[1])); + Assert.Equal("/DeviceRGB", S(spaces.Elements["/CS1_2"])); + Assert.Equal("/DeviceRGB", S(A(spaces.Elements["/CS2_2"]).Elements[1])); + Assert.Equal("/F1_2 10 Tf /CS1_2 cs 0.5 0.5 0.5 sc", S(Fields(result)["b"].Elements["/DA"])); + } + + private static PdfArray CalRgb(PdfDocument pdf) + { + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(2.2), new PdfReal(2.2), new PdfReal(2.2)); + return Items(pdf, new PdfName("/CalRGB"), parameters); + } + + private static bool IsCalRgb(PdfItem? item) => V(item) is PdfArray array && S(array.Elements[0]) == "/CalRGB"; + + [Fact] + public void A_pattern_over_an_indexed_space_keeps_its_sources_default() + { + string Make(string field, bool withDefault) => Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /CS1 cs 0 /P1 scn"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var spaces = new PdfDictionary(pdf); + spaces.Elements["/CS1"] = Items(pdf, new PdfName("/Pattern"), + Items(pdf, new PdfName("/Indexed"), new PdfName("/DeviceRGB"), new PdfInteger(0), new PdfString("abc"))); + if (withDefault) + { + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(2.2), new PdfReal(2.2), new PdfReal(2.2)); + spaces.Elements["/DefaultRGB"] = Items(pdf, new PdfName("/CalRGB"), parameters); + } + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + form.Elements["/DR"] = resources; + Field(pdf, form, field, "v"); + }); + var sources = new[] { Make("a", true), Make("b", false) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "indexed-pattern.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var spaces = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/ColorSpace"]); + PdfItem? Base(string name) => A(A(spaces.Elements[name]).Elements[1]).Elements[1]; + Assert.True(V(Base("/CS1")) is PdfArray array && S(array.Elements[0]) == "/CalRGB"); + Assert.Equal("/DeviceRGB", S(Base("/CS1_2"))); + } + + [Fact] + public void A_shading_shared_with_a_tiling_pattern_keeps_the_tiling_patterns_default() + { + PdfArray CalRgb(PdfDocument pdf, double gamma) + { + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(gamma), new PdfReal(gamma), new PdfReal(gamma)); + return Items(pdf, new PdfName("/CalRGB"), parameters); + } + var source = Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /Pattern cs /P1 scn"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var shading = Object(pdf); + shading.Elements["/ShadingType"] = new PdfInteger(2); + shading.Elements["/ColorSpace"] = new PdfName("/DeviceRGB"); + var tileSpaces = new PdfDictionary(pdf); + tileSpaces.Elements["/DefaultRGB"] = CalRgb(pdf, 1.8); + var shadings = new PdfDictionary(pdf); + shadings.Elements["/S1"] = shading.Reference; + var tileResources = new PdfDictionary(pdf); + tileResources.Elements["/ColorSpace"] = tileSpaces; + tileResources.Elements["/Shading"] = shadings; + var tile = Object(pdf); + tile.Elements["/PatternType"] = new PdfInteger(1); + tile.Elements["/Resources"] = tileResources; + var gradient = Object(pdf); + gradient.Elements["/PatternType"] = new PdfInteger(2); + gradient.Elements["/Shading"] = shading.Reference; + var patterns = new PdfDictionary(pdf); + patterns.Elements["/P1"] = tile.Reference; + patterns.Elements["/P2"] = gradient.Reference; + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DefaultRGB"] = CalRgb(pdf, 2.2); + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + resources.Elements["/Pattern"] = patterns; + form.Elements["/DR"] = resources; + Field(pdf, form, "f", "v"); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var patterns = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Pattern"]); + var formSpace = V(D(D(patterns.Elements["/P2"]).Elements["/Shading"]).Elements["/ColorSpace"]); + Assert.True(formSpace is PdfArray array && S(array.Elements[0]) == "/CalRGB"); + var tileShading = D(D(D(D(patterns.Elements["/P1"]).Elements["/Resources"]).Elements["/Shading"]).Elements["/S1"]); + Assert.Equal("/DeviceRGB", S(tileShading.Elements["/ColorSpace"])); + } + + private string DefaultPatternSource(Action addPatterns) => Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /Pattern cs /P1 scn"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(2.2), new PdfReal(2.2), new PdfReal(2.2)); + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DefaultRGB"] = Items(pdf, new PdfName("/CalRGB"), parameters); + var patterns = new PdfDictionary(pdf); + addPatterns(pdf, patterns); + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + resources.Elements["/Pattern"] = patterns; + form.Elements["/DR"] = resources; + Field(pdf, form, "f", "v"); + }); + + [Fact] + public void A_mesh_shading_keeps_its_data_when_its_default_is_kept() + { + var data = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }; + var source = DefaultPatternSource((pdf, patterns) => + { + var shading = Object(pdf); + shading.Elements["/ShadingType"] = new PdfInteger(4); + shading.Elements["/ColorSpace"] = new PdfName("/DeviceRGB"); + shading.CreateStream(data); + var pattern = Object(pdf); + pattern.Elements["/PatternType"] = new PdfInteger(2); + pattern.Elements["/Shading"] = shading.Reference; + patterns.Elements["/P1"] = pattern.Reference; + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var patterns = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Pattern"]); + var shading = D(D(patterns.Elements["/P1"]).Elements["/Shading"]); + Assert.True(IsCalRgb(shading.Elements["/ColorSpace"])); + Assert.Equal(data, shading.Stream!.UnfilteredValue); + } + + [Fact] + public void A_dictionary_used_as_pattern_and_shading_keeps_both_defaults() + { + var source = DefaultPatternSource((pdf, patterns) => + { + var inner = Object(pdf); + inner.Elements["/ShadingType"] = new PdfInteger(2); + inner.Elements["/ColorSpace"] = new PdfName("/DeviceRGB"); + var both = Object(pdf); + both.Elements["/PatternType"] = new PdfInteger(2); + both.Elements["/ShadingType"] = new PdfInteger(2); + both.Elements["/ColorSpace"] = new PdfName("/DeviceRGB"); + both.Elements["/Shading"] = inner.Reference; + var outer = Object(pdf); + outer.Elements["/PatternType"] = new PdfInteger(2); + outer.Elements["/Shading"] = both.Reference; + patterns.Elements["/P1"] = both.Reference; + patterns.Elements["/P2"] = outer.Reference; + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var patterns = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Pattern"]); + Assert.True(IsCalRgb(D(D(patterns.Elements["/P1"]).Elements["/Shading"]).Elements["/ColorSpace"])); + Assert.True(IsCalRgb(D(D(patterns.Elements["/P2"]).Elements["/Shading"]).Elements["/ColorSpace"])); + } + + [Fact] + public void A_pattern_over_an_indexed_separation_keeps_its_sources_default() + { + var source = Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /CS1 cs 0 /P1 scn"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var parameters = new PdfDictionary(pdf); + parameters.Elements["/WhitePoint"] = Items(pdf, new PdfReal(0.9505), new PdfInteger(1), new PdfReal(1.089)); + parameters.Elements["/Gamma"] = Items(pdf, new PdfReal(2.2), new PdfReal(2.2), new PdfReal(2.2)); + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DefaultRGB"] = Items(pdf, new PdfName("/CalRGB"), parameters); + var separation = Items(pdf, new PdfName("/Separation"), new PdfName("/Spot"), new PdfName("/DeviceRGB"), Object(pdf).Reference!); + spaces.Elements["/CS1"] = Items(pdf, new PdfName("/Pattern"), + Items(pdf, new PdfName("/Indexed"), separation, new PdfInteger(0), new PdfString("a"))); + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + form.Elements["/DR"] = resources; + Field(pdf, form, "f", "v"); + }); + using var result = PdfReader.Open(MergeOne(source), PdfDocumentOpenMode.Import); + var spaces = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/ColorSpace"]); + var indexed = A(A(spaces.Elements["/CS1"]).Elements[1]); + Assert.True(IsCalRgb(A(indexed.Elements[1]).Elements[2])); + } + + [Fact] + public void A_shading_pattern_keeps_its_sources_default_colour_space() + { + string Make(string field, bool withDefault) => Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /Pattern cs /P1 scn"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var shading = new PdfDictionary(pdf); + shading.Elements["/ShadingType"] = new PdfInteger(2); + shading.Elements["/ColorSpace"] = new PdfName("/DeviceRGB"); + var pattern = new PdfDictionary(pdf); + pattern.Elements["/Type"] = new PdfName("/Pattern"); + pattern.Elements["/PatternType"] = new PdfInteger(2); + pattern.Elements["/Shading"] = shading; + var patterns = new PdfDictionary(pdf); + patterns.Elements["/P1"] = pattern; + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/Pattern"] = patterns; + if (withDefault) + { + var spaces = new PdfDictionary(pdf); + spaces.Elements["/DefaultRGB"] = CalRgb(pdf); + resources.Elements["/ColorSpace"] = spaces; + } + form.Elements["/DR"] = resources; + Field(pdf, form, field, "v"); + }); + var sources = new[] { Make("a", true), Make("b", false) }; + var path = Path.Combine(Path.GetDirectoryName(sources[0])!, "shading.pdf"); + PdfSharpConverter.Merge(sources, path, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var patterns = D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Pattern"]); + Assert.True(IsCalRgb(D(D(patterns.Elements["/P1"]).Elements["/Shading"]).Elements["/ColorSpace"])); + Assert.Equal("/DeviceRGB", S(D(D(patterns.Elements["/P1_2"]).Elements["/Shading"]).Elements["/ColorSpace"])); + } + + [Fact] + public void Carried_colour_spaces_keep_their_default_when_a_later_resource_is_over_the_limit() + { + var source = Source(pdf => + { + var form = Form(pdf); + form.Elements["/DA"] = new PdfString("/F1 10 Tf /CS1 cs 0.5 0.5 0.5 sc"); + var fonts = new PdfDictionary(pdf); + fonts.Elements["/F1"] = Resource(pdf, "Helvetica").Reference; + var spaces = new PdfDictionary(pdf); + spaces.Elements["/CS1"] = new PdfName("/DeviceRGB"); + spaces.Elements["/DefaultRGB"] = CalRgb(pdf); + var patterns = new PdfDictionary(pdf); + patterns.Elements["/Unused"] = new PdfDictionary(pdf); + var resources = new PdfDictionary(pdf); + resources.Elements["/Font"] = fonts; + resources.Elements["/ColorSpace"] = spaces; + resources.Elements["/Pattern"] = patterns; + form.Elements["/DR"] = resources; + Field(pdf, form, "f", "v"); + }); + // 5 objects: the font and the four of the CalRGB space; the unused pattern is the sixth. + using var result = PdfReader.Open(MergeOne(source, new PdfMergeStructure.Limits { ResourceObjects = 5 }), PdfDocumentOpenMode.Import); + var resources = D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]); + Assert.False(resources.Elements.ContainsKey("/Pattern")); + Assert.True(IsCalRgb(D(resources.Elements["/ColorSpace"]).Elements["/CS1"])); + } + + [Fact] + public void Outline_and_link_destinations_keep_their_view() + { + using var document = PdfReader.Open(Merge(), PdfDocumentOpenMode.Import); + AssertBookmarks(document); + AssertLinks(document); + } + + [Fact] + public void Fields_from_two_sources_change_independently() => ChangeIndependently(Merge()); + + [Theory] + [InlineData(null, null, false)] + [InlineData(false, false, false)] + [InlineData(null, true, true)] + [InlineData(false, true, true)] + [InlineData(true, null, true)] + public void Need_appearances_is_on_when_any_source_asks_for_it(bool? first, bool? second, bool expected) + { + var folder = fx.NewFolder(); + var output = Path.Combine(folder, "merged.pdf"); + PdfSharpConverter.Merge([Make(folder, "a", 0, first), Make(folder, "b", 1, second)], output, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(expected, V(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/NeedAppearances"]) is PdfBoolean { Value: true }); + } + + [Fact] + public void Cancelling_while_carrying_structure_stops_the_merge() + { + var folder = fx.NewFolder(); + var source = Make(folder, "a"); + var output = Path.Combine(folder, "cancelled.pdf"); + using var cts = new CancellationTokenSource(); + Assert.Throws(() => PdfSharpConverter.Merge([source], output, cts.Token, new() { BeforeCarry = cts.Cancel })); + Assert.False(File.Exists(output)); + } +} From 4093ec32e2897f4eec549f08e6ef984d41ecf1db Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:39:29 +0900 Subject: [PATCH 2/2] fix(pdf): keep merging when form objects weren't imported Review of #1227: a field whose widgets are not on a merged page, a direct object in /Fields or /Kids, fields in a file with no pages, or a reference to a missing object in /AcroForm /DR made the whole merge fail, where main still merged. MapExternalObject throws for an object the page import didn't copy, so every lookup now goes through Imported(), which returns null and skips only that field or node. A field tree with a direct /Fields or /Kids entry is treated as broken, and a missing reference in the copied resources is written as null. A destination name now spends the string budget once, when it is first registered, instead of again at every link or bookmark that uses it, so large files no longer lose link targets. Long names are shared as one indirect string, so many links to one name don't grow the output. The limit cases allow 60 s instead of 10 s; the allocation bound stays the real check. Part of #965 Co-authored-by: sisyphus-dev-ai --- src/Filee.Engines/Pdf/PdfMergeStructure.cs | 58 ++-- .../PdfMergeStructureTests.cs | 251 +++++++++++++++++- 2 files changed, 284 insertions(+), 25 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfMergeStructure.cs b/src/Filee.Engines/Pdf/PdfMergeStructure.cs index 5f4e3cdee..177f0d425 100644 --- a/src/Filee.Engines/Pdf/PdfMergeStructure.cs +++ b/src/Filee.Engines/Pdf/PdfMergeStructure.cs @@ -38,7 +38,7 @@ private sealed class Outline(PdfDictionary dictionary, bool opened) private readonly List _outlines = []; // Raw byte strings have exactly the same ordinal ordering as their bytes. - private readonly SortedDictionary _destinations = new(StringComparer.Ordinal); + private readonly SortedDictionary _destinations = new(StringComparer.Ordinal); private readonly PdfArray _fields = new(output); private readonly PdfDictionary _resources = new(output); private readonly HashSet _fieldNames = new(StringComparer.Ordinal); @@ -82,6 +82,20 @@ internal void Carry(PdfDocument source, IReadOnlyDictionary item.Reference is { } reference ? (reference.ObjectID, null) : (default, item); + private PdfObject? Imported(PdfObject external) + { + if (external.Reference is null) + return null; + try + { + return output.Internals.MapExternalObject(external); + } + catch (KeyNotFoundException) + { + return null; + } + } + private bool SpendStrings(int count) { if (count > limits.StringBytes - _stringBytes) @@ -101,7 +115,7 @@ private static string Bytes(PdfString text) // and looked up once. Short strings are cheap and may be shared instances such as string.Empty. private const int SharedLength = 256; private readonly Dictionary _byteCounts = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _lookups = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _lookups = new(ReferenceEqualityComparer.Instance); // The next suffix to try for a name already taken, so later sources do not walk the taken ones again. private readonly Dictionary _destinationSuffixes = new(StringComparer.Ordinal); private readonly Dictionary<(string Kind, string Name), int> _resourceSuffixes = new(); @@ -203,8 +217,16 @@ bool Register(bool legacy, Func keyOf, PdfItem? item) renamed = key + "-" + (suffix++).ToString(CultureInfo.InvariantCulture); if (!SpendStrings(renamed.Length)) return true; + // Direct short names have bounded size; long names must not be written once per link. + PdfItem name = Raw(renamed); + if (renamed.Length >= SharedLength) + { + var shared = new PdfStringObject(output, renamed) { Encoding = PdfStringEncoding.RawEncoding }; + output.Internals.AddObject(shared); + name = shared.Reference!; + } names.Add((legacy, key), renamed); - _destinations.Add(renamed, destination); + _destinations.Add(renamed, (name, destination)); if (!ReferenceEquals(renamed, key)) _destinationSuffixes[key] = suffix; count++; @@ -269,9 +291,9 @@ bool Register(bool legacy, Func keyOf, PdfItem? item) item = Value(item); if (item is PdfArray) return MapDestination(item, pages); - if (link && item is PdfString textKey && ByteCount(textKey) > limits.StringBytes - _stringBytes) + if (item is PdfString textKey && ByteCount(textKey) > limits.StringBytes) return null; - if (link && item is PdfName nameKey && nameKey.Value.Length - 1 > limits.StringBytes - _stringBytes) + if (item is PdfName nameKey && nameKey.Value.Length - 1 > limits.StringBytes) return null; // A PdfName and a PdfString never share one string instance, so one cache serves both kinds. var value = item switch @@ -285,16 +307,13 @@ bool Register(bool legacy, Func keyOf, PdfItem? item) if (value.Length < SharedLength || !_lookups.TryGetValue(value, out var resolved)) { var key = item is PdfString text ? (false, Bytes(text)) : (true, value[1..]); - resolved = names.TryGetValue(key, out var found) ? (found, _destinations[found]) : null; + resolved = names.TryGetValue(key, out var found) ? _destinations[found] : null; if (value.Length >= SharedLength) _lookups.Add(value, resolved); } if (resolved is not { } hit) return null; - var (renamed, destination) = hit; - if (!link) - return destination; - return SpendStrings(renamed.Length) ? Raw(renamed) : null; + return link ? hit.Name : hit.Destination; } private void CarryOutlines(PdfDictionary catalog, IReadOnlyDictionary pages, @@ -360,7 +379,7 @@ private void CarryLinks(PdfDocument source, IReadOnlyDictionary(); var nodes = new List(); @@ -462,7 +481,8 @@ private void CarryFields(PdfDictionary catalog, CancellationToken ct) for (var i = kids.Elements.Count - 1; i >= 0; i--) { ct.ThrowIfCancellationRequested(); - if (Dictionary(kids.Elements[i]) is not { } child || visited.Contains(Identity(child))) + if (kids.Elements[i] is not PdfReference || Dictionary(kids.Elements[i]) is not { } child || + visited.Contains(Identity(child))) { valid = false; break; @@ -473,7 +493,7 @@ private void CarryFields(PdfDictionary catalog, CancellationToken ct) break; } } - if (!valid || signature || output.Internals.MapExternalObject(root) is not PdfDictionary imported) + if (!valid || signature || Imported(root) is not PdfDictionary imported) continue; foreach (var node in nodes) if (Dictionary(node.Elements["/DR"]) is { } local) @@ -504,7 +524,7 @@ private void CarryFields(PdfDictionary catalog, CancellationToken ct) if (ReferenceEquals(node, root) && !node.Elements.ContainsKey("/DA")) appearance = Text(form.Elements["/DA"]); if (appearance is null || ByteCount(appearance) > limits.AppearanceLength || - output.Internals.MapExternalObject(node) is not PdfDictionary mapped) + Imported(node) is not PdfDictionary mapped) continue; var key = (appearance.Value, appearance.Encoding); if (!cache.TryGetValue(key, out var rewritten)) @@ -531,7 +551,7 @@ private void CarryFields(PdfDictionary catalog, CancellationToken ct) foreach (var node in nodes) { ct.ThrowIfCancellationRequested(); - if (output.Internals.MapExternalObject(node) is not PdfDictionary field || + if (Imported(node) is not PdfDictionary field || Dictionary(field.Elements["/DR"]) is not { } local) continue; foreach (var kind in ResourceKinds) @@ -692,7 +712,7 @@ private void CarryFields(PdfDictionary catalog, CancellationToken ct) } PdfItem Copy(PdfItem item) { - var direct = Value(item)!; + var direct = Value(item) ?? PdfNull.Value; if (direct is PdfObject obj && copied.TryGetValue(obj, out var clone)) return item is PdfReference ? clone.Reference! : clone; return direct; @@ -995,11 +1015,11 @@ internal void Write(CancellationToken ct) if (_destinations.Count > 0) { var pairs = new PdfArray(output); - foreach (var (key, destination) in _destinations) + foreach (var (key, value) in _destinations) { ct.ThrowIfCancellationRequested(); pairs.Elements.Add(Raw(key)); - pairs.Elements.Add(destination); + pairs.Elements.Add(value.Destination); } var tree = new PdfDictionary(output); tree.Elements["/Names"] = pairs; diff --git a/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs b/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs index 6e2c14f78..0621cc4a6 100644 --- a/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs +++ b/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs @@ -12,7 +12,7 @@ namespace Filee.Engines.Tests; -/// Runs alone: the limit cases check merge time, which other tests running at once would stretch. +/// Runs alone so other tests do not inflate the limit cases' allocation measurements. [CollectionDefinition(nameof(PdfMergeStructureCollection), DisableParallelization = true)] public sealed class PdfMergeStructureCollection; @@ -605,6 +605,208 @@ private static string MergeOne(string source, PdfMergeStructure.Limits? limits = return path; } + [Theory] + [InlineData("no-widget")] + [InlineData("outside-page-tree")] + [InlineData("kid-without-page")] + [InlineData("direct-field")] + [InlineData("direct-kid")] + [InlineData("no-pages")] + [InlineData("missing-resource-reference")] + [InlineData("unpaged-kid-with-own-resources")] + public void Unimported_fields_and_missing_resources_do_not_break_healthy_fields(string kind) + { + var source = Source(document => + { + var form = Form(document); + Field(document, form, "healthy", "kept"); + if (kind == "unpaged-kid-with-own-resources") + { + var root = Object(document); + root.Elements["/T"] = new PdfString("parent"); + root.Elements["/FT"] = new PdfName("/Tx"); + root.Elements["/V"] = new PdfString("kept"); + var paged = Widget(document, 0, root); + var unpaged = Object(document); + unpaged.Elements["/Type"] = new PdfName("/Annot"); + unpaged.Elements["/Subtype"] = new PdfName("/Widget"); + unpaged.Elements["/Parent"] = root.Reference; + unpaged.Elements["/DA"] = new PdfString("/KidFont 10 Tf"); + var local = new PdfDictionary(document); + var fonts = new PdfDictionary(document); + fonts.Elements["/KidFont"] = Resource(document, "Helvetica").Reference; + local.Elements["/Font"] = fonts; + unpaged.Elements["/DR"] = local; + root.Elements["/Kids"] = Items(document, paged.Reference!, unpaged.Reference!); + A(form.Elements["/Fields"]).Elements.Add(root.Reference!); + return; + } + if (kind is "no-pages") + return; + if (kind is "missing-resource-reference") + { + var font = Resource(document, "Helvetica"); + font.Elements["/ToUnicode"] = new PdfLiteral("777777 0 R"); + // PDFsharp normalizes a missing dictionary value, but leaves a missing array entry as null. + font.Elements["/MissingArray"] = Items(document, new PdfLiteral("777777 0 R")); + var fonts = new PdfDictionary(document); + fonts.Elements["/F1"] = font.Reference; + var resources = new PdfDictionary(document); + resources.Elements["/Font"] = fonts; + form.Elements["/DR"] = resources; + form.Elements["/DA"] = new PdfString("/F1 10 Tf"); + return; + } + var broken = kind == "direct-field" ? new PdfDictionary(document) : Object(document); + broken.Elements["/T"] = new PdfString("broken"); + broken.Elements["/FT"] = new PdfName("/Tx"); + broken.Elements["/V"] = new PdfString("bad"); + A(form.Elements["/Fields"]).Elements.Add(broken.Reference is { } reference ? reference : broken); + if (kind == "no-widget") + return; + if (kind == "outside-page-tree") + { + var page = document.AddPage(); + var widget = Widget(document, 2, broken); + broken.Elements["/Kids"] = Items(document, widget.Reference!); + document.Pages.Remove(page); + return; + } + if (kind == "direct-field") + { + broken.Elements["/Type"] = new PdfName("/Annot"); + broken.Elements["/Subtype"] = new PdfName("/Widget"); + broken.Elements["/P"] = document.Pages[0].Reference; + broken.Elements["/Rect"] = Items(document, new PdfInteger(0), new PdfInteger(0), new PdfInteger(20), new PdfInteger(20)); + A(document.Pages[0].Elements["/Annots"]).Elements.Add(broken); + return; + } + var kid = kind == "direct-kid" ? new PdfDictionary(document) : Object(document); + kid.Elements["/Type"] = new PdfName("/Annot"); + kid.Elements["/Subtype"] = new PdfName("/Widget"); + kid.Elements["/Parent"] = broken.Reference; + kid.Elements["/Rect"] = Items(document, new PdfInteger(0), new PdfInteger(0), new PdfInteger(20), new PdfInteger(20)); + kid.Elements["/DA"] = new PdfString("/F1 10 Tf"); + kid.Elements["/DR"] = new PdfDictionary(document); + broken.Elements["/Kids"] = kind == "direct-kid" + ? Items(document, Widget(document, 0, broken).Reference!, kid) + : Items(document, kid.Reference!); + }); + var output = Path.Combine(Path.GetDirectoryName(source)!, "out.pdf"); + if (kind == "no-pages") + { + // PDFsharp cannot save a document without pages; write the empty page tree explicitly. + var empty = Path.Combine(Path.GetDirectoryName(source)!, "empty.pdf"); + var objects = new[] + { + "<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Fields [4 0 R] >>", + "<< /FT /Tx /T (broken) /V (bad) >>", + }; + var pdf = new StringBuilder("%PDF-1.7\n"); + var offsets = new List(); + for (var i = 0; i < objects.Length; i++) + { + offsets.Add(pdf.Length); + pdf.Append(System.Globalization.CultureInfo.InvariantCulture, $"{i + 1} 0 obj\n{objects[i]}\nendobj\n"); + } + var xref = pdf.Length; + pdf.Append(System.Globalization.CultureInfo.InvariantCulture, $"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets) + pdf.Append(System.Globalization.CultureInfo.InvariantCulture, $"{offset:D10} 00000 n \n"); + pdf.Append(System.Globalization.CultureInfo.InvariantCulture, $"trailer\n<< /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); + File.WriteAllText(empty, pdf.ToString(), Encoding.ASCII); + PdfSharpConverter.Merge([empty, source], output, TestContext.Current.CancellationToken); + } + else + PdfSharpConverter.Merge([source], output, TestContext.Current.CancellationToken); + using var result = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + var fields = Fields(result); + Assert.Equal("kept", S(fields["healthy"].Elements["/V"])); + if (kind == "unpaged-kid-with-own-resources") + Assert.Equal("kept", S(fields["parent"].Elements["/V"])); + if (kind is "no-widget" or "outside-page-tree" or "direct-field" or "direct-kid" or "no-pages") + Assert.DoesNotContain("broken", fields.Keys); + if (kind == "missing-resource-reference") + { + var font = D(D(D(D(result.Internals.Catalog.Elements["/AcroForm"]).Elements["/DR"]).Elements["/Font"]).Elements["/F1"]); + Assert.IsType(V(font.Elements["/ToUnicode"])); + Assert.IsType(V(A(font.Elements["/MissingArray"]).Elements[0])); + } + using var pig = UglyToad.PdfPig.PdfDocument.Open(output); + Assert.Equal(2, pig.NumberOfPages); + Assert.True(pig.TryGetForm(out var parsed)); + Assert.Contains(parsed.Fields, field => + field is UglyToad.PdfPig.AcroForms.Fields.AcroTextField { Value: "kept" }); + } + + [Fact] + public void Page_backed_field_survives_an_unimported_kid_with_local_resources() + { + var sourcePath = Source(document => + { + var form = Form(document); + Field(document, form, "healthy", "kept"); + var root = Object(document); + root.Elements["/T"] = new PdfString("parent"); + root.Elements["/FT"] = new PdfName("/Tx"); + root.Elements["/V"] = new PdfString("root value"); + var paged = Widget(document, 0, root); + var unpaged = Object(document); + unpaged.Elements["/Type"] = new PdfName("/Annot"); + unpaged.Elements["/Subtype"] = new PdfName("/Widget"); + unpaged.Elements["/Parent"] = root.Reference; + unpaged.Elements["/DA"] = new PdfString("/KidFont 10 Tf"); + var fonts = new PdfDictionary(document); + fonts.Elements["/KidFont"] = Resource(document, "Helvetica").Reference; + var local = new PdfDictionary(document); + local.Elements["/Font"] = fonts; + unpaged.Elements["/DR"] = local; + root.Elements["/Kids"] = Items(document, paged.Reference!, unpaged.Reference!); + A(form.Elements["/Fields"]).Elements.Add(root.Reference!); + }); + var outputPath = Path.Combine(Path.GetDirectoryName(sourcePath)!, "out.pdf"); + using (var source = PdfReader.Open(sourcePath, PdfDocumentOpenMode.Import)) + using (var output = new PdfDocument()) + { + var form = D(source.Internals.Catalog.Elements["/AcroForm"]); + var root = D(A(form.Elements["/Fields"]).Elements[1]); + var kids = A(root.Elements["/Kids"]); + var unpaged = D(kids.Elements[1]); + var unpagedReference = kids.Elements[1]; + // Exclude the unpaged kid from PDFsharp's page-import closure, then restore the source tree. + kids.Elements.RemoveAt(1); + var pages = new Dictionary(); + foreach (var page in source.Pages) + pages[page.Reference!.ObjectID] = output.AddPage(page); + kids.Elements.Add(unpagedReference); + Assert.IsAssignableFrom(output.Internals.MapExternalObject(root)); + Assert.Throws(() => output.Internals.MapExternalObject(unpaged)); + + var structure = new PdfMergeStructure(output, new()); + structure.Carry(source, pages, TestContext.Current.CancellationToken); + structure.Write(TestContext.Current.CancellationToken); + output.Save(outputPath); + } + using (var result = PdfReader.Open(outputPath, PdfDocumentOpenMode.Import)) + { + Assert.Equal(2, result.PageCount); + var fields = Fields(result); + Assert.Equal("kept", S(fields["healthy"].Elements["/V"])); + var root = fields["parent"]; + Assert.Equal("root value", S(root.Elements["/V"])); + var paged = D(Assert.Single(A(root.Elements["/Kids"]).Elements)); + Assert.Contains(A(result.Pages[0].Elements["/Annots"]).Elements, item => ReferenceEquals(V(item), paged)); + } + using var pig = UglyToad.PdfPig.PdfDocument.Open(outputPath); + Assert.Equal(2, pig.NumberOfPages); + Assert.True(pig.TryGetForm(out var parsed)); + Assert.Contains(parsed.Fields, field => field is UglyToad.PdfPig.AcroForms.Fields.AcroTextField { Value: "kept" }); + Assert.Contains(parsed.Fields, field => field is UglyToad.PdfPig.AcroForms.Fields.AcroTextField { Value: "root value" }); + } + private static PdfDictionary OutlineRoot(PdfDocument document) { var root = Object(document); @@ -821,6 +1023,41 @@ public void Named_destinations_keep_their_namespace_and_bytes() View(result, A(outlines[1].Elements["/Dest"]), 1, "/Fit"); } + [Fact] + public void Looking_up_registered_names_does_not_spend_the_string_budget_again() + { + const int count = 60_000; + var timer = Stopwatch.StartNew(); + var source = Source(document => + { + var pairs = new PdfArray(document); + NameTree(document).Elements["/Names"] = pairs; + var destination = Dest(document, 1); + for (var i = 0; i < count; i++) + { + var name = i.ToString("D42", System.Globalization.CultureInfo.InvariantCulture); + pairs.Elements.Add(new PdfString(name)); + pairs.Elements.Add(destination); + Link(document, 0, new PdfString(name)); + } + }); + var output = MergeOne(source); + using var result = PdfReader.Open(output, PdfDocumentOpenMode.Import); + var names = Destinations(result); + var links = Links(result, 0); + var kept = links.Count(link => link.Elements.ContainsKey("/Dest")); + TestContext.Current.TestOutputHelper?.WriteLine($"{names.Count:N0} names, {kept:N0} link destinations, {timer.Elapsed.TotalSeconds:F3} s"); + Assert.Equal(count, names.Count); + Assert.Equal(count, links.Count); + Assert.Equal(count, kept); + for (var i = 0; i < count; i++) + { + var name = i.ToString("D42", System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(name, Key(links[i].Elements["/Dest"]!)); + View(result, names[name], 1, "/Fit"); + } + } + private static void OutlineList(PdfDocument document, int count, bool chain = false, PdfItem? title = null, PdfItem? dest = null) { var root = OutlineRoot(document); @@ -1117,13 +1354,15 @@ public void Structure_beyond_the_limits_is_cut_in_bounded_time_and_memory(string break; case "long-name": var links = Links(result, 0); - Assert.Equal(63, links.Count(d => d.Elements.ContainsKey("/Dest"))); - Assert.All(links.Take(63), d => Assert.Equal(new string('k', 64 * 1024), S(d.Elements["/Dest"]))); - Assert.All(links.Skip(63), d => Assert.False(d.Elements.ContainsKey("/Dest"))); - Assert.Equal(4 * 1024 * 1024, Destinations(result).Keys.Sum(k => k.Length) + links.Where(d => d.Elements.ContainsKey("/Dest")).Sum(d => Key(d.Elements["/Dest"]!).Length)); + Assert.Equal(16_384, links.Count); + var sharedName = Assert.Single(links.Select(d => Assert.IsType(d.Elements["/Dest"]).Value).Distinct()); + Assert.Equal(new string('k', 64 * 1024), S(sharedName)); + Assert.Equal(S(sharedName), Assert.Single(Destinations(result)).Key); + Assert.True(new FileInfo(output).Length < 2 * new FileInfo(Path.Combine(Path.GetDirectoryName(source)!, "baseline.pdf")).Length + 1024 * 1024); break; } - Assert.True(timer.Elapsed < TimeSpan.FromSeconds(10), $"{kind}: {timer.Elapsed}"); + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(60), $"{kind}: {timer.Elapsed}"); + // The allocation bound is the real check; this time limit only catches runaway work. Assert.True(allocated < 3 * pagesOnly + 64 * 1024 * 1024, $"{kind}: {allocated} bytes, pages only {pagesOnly} bytes"); }