diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debabe1f..13d3076d 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 00000000..177f0d42 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfMergeStructure.cs @@ -0,0 +1,1091 @@ +// 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 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) + 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; + // 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, (name, 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 (item is PdfString textKey && ByteCount(textKey) > limits.StringBytes) + return null; + 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 + { + 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) ? _destinations[found] : null; + if (value.Length >= SharedLength) + _lookups.Add(value, resolved); + } + if (resolved is not { } hit) + return null; + return link ? hit.Name : hit.Destination; + } + + 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 = Imported(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 (item is not PdfReference || 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 (kids.Elements[i] is not PdfReference || 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 || Imported(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 || + Imported(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 (Imported(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) ?? PdfNull.Value; + 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, value) in _destinations) + { + ct.ThrowIfCancellationRequested(); + pairs.Elements.Add(Raw(key)); + pairs.Elements.Add(value.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 1c7395d4..dc8e5098 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -49,18 +49,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, cancellationToken); - foreach (var page in source.Pages) - output.AddPage(page); - } - output.Save(outputPath); - }, cancellationToken); + ct.ThrowIfCancellationRequested(); + using var source = Open(input, PdfDocumentOpenMode.Import, ct); + 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 00000000..0621cc4a --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfMergeStructureTests.cs @@ -0,0 +1,2304 @@ +// 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 so other tests do not inflate the limit cases' allocation measurements. +[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; + } + + [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); + 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"); + } + + [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); + 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(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(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"); + } + + [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)); + } +}