diff --git a/CHANGELOG.md b/CHANGELOG.md
index a15f365b..d48f9b0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
document reports on re-opening now include `Extract`. Documents written with `Extract`
(the default, since `All` includes it) carry the same `/P` value as before. See Fixed, below,
for why. (#397)
+- **A preflight finding's message text changes for a named value longer than 32 characters, and for
+ any message longer than 1024 characters.** Ten rule sites that quote a producer-supplied name (a
+ stream `/Filter`, an action `/S` or named action `/N`, an annotation `/Subtype` or an extra `/AP`
+ key, a composite font's `/Encoding` CMap name in two rules, a `/RoleMap` key, a `/Perms` key, a
+ blend mode) now keep the first 32 characters followed by `... (N bytes)`, and every
+ `PreflightAssertion.Message` past 1024 characters ends in `... (N chars)`. `vellum-preflight`'s
+ text, JSON and SARIF output carries the same text. Verdicts, rule ids, clauses and assertion
+ counts are unaffected, and at these ten sites a message whose named value is 32 characters or
+ shorter is byte-identical to before. See Fixed, below, for why. (#403)
### Fixed
@@ -131,6 +140,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
writes `-4` instead of `-516`, the same value as `All`; at `/R` 6 the `/Perms` seal
(Algorithm 10) changes with it, since it seals the same `/P` value. A document written without
`Extract` therefore reads back with `Extract` included in `PdfEncryptionInfo.Permissions`. (#397)
+- **A preflight finding could retain a producer-sized string for the result's lifetime.** A rule
+ that names the offending value in its message interpolated it whole, so one oversized `/Filter`
+ name shared by 400 pages kept 705.7 MiB (GC delta) of message text alive from a 990 KB file
+ (measured in #403). Every `PreflightAssertion.Message` is now cut at 1024 characters with its
+ length appended, and the ten sites that quoted a name whole keep the first 32 characters plus the
+ byte count; the remaining sites that interpolate a producer value rely on the 1024-character cut
+ and are listed in #405. What this bounds is the retained result; the transient allocation the
+ Reader makes while building its own exception message for an unknown filter is unchanged and
+ tracked in #406. (#403)
## [2.3.0] - 2026-09-01
diff --git a/src/VellumPdf.Conformance/PdfPreflight.cs b/src/VellumPdf.Conformance/PdfPreflight.cs
index 302d8370..cd3a603a 100644
--- a/src/VellumPdf.Conformance/PdfPreflight.cs
+++ b/src/VellumPdf.Conformance/PdfPreflight.cs
@@ -342,6 +342,10 @@ internal static PreflightResult Validate(PdfDocumentReader reader, PdfConformanc
// starts), but should a future lazily-decoded reader feature let a rule raise either,
// "cannot evaluate" and "wrong password" are both a distinct signal that should
// propagate to the caller rather than be reported as a conformance violation.
+ //
+ // ex.Message can itself quote a whole oversized token (a filter name, a raw
+ // parser token); PreflightContext.Report bounds the retained message at
+ // MaxMessageChars, so that whole is not retained here either (#403).
context.Report(rule.RuleId, rule.Clause, PreflightSeverity.Error,
$"Rule evaluation failed: {ex.Message}");
}
diff --git a/src/VellumPdf.Conformance/PreflightAssertion.cs b/src/VellumPdf.Conformance/PreflightAssertion.cs
index d8254b5b..5fa2cfed 100644
--- a/src/VellumPdf.Conformance/PreflightAssertion.cs
+++ b/src/VellumPdf.Conformance/PreflightAssertion.cs
@@ -26,7 +26,15 @@ public sealed class PreflightAssertion
/// The severity of the finding.
public PreflightSeverity Severity { get; }
- /// A human-readable description of the finding.
+ ///
+ /// A human-readable description of the finding. Not a compatibility contract: the wording may
+ /// change across releases, so a caller that needs to branch on the condition should switch on
+ /// instead of matching text here. The text is bounded: past 1024
+ /// characters it is replaced by ... (N chars) with N the full length. Ten sites
+ /// additionally excerpt the producer name they quote, keeping an oversized one to its first
+ /// 32 characters followed by ... (N bytes); every other producer value is bounded by
+ /// the 1024-character cut alone.
+ ///
public string Message { get; }
///
diff --git a/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs b/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs
index e489bb68..4045ce3c 100644
--- a/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Actions;
@@ -137,7 +138,8 @@ private void CheckAction(PreflightContext context, PdfObject? actionObj, HashSet
s is null
? "An action dictionary has no /S action-type key (only GoTo, GoToR, GoToE, Thread, "
+ "URI, Named, and SubmitForm are permitted in PDF/A)."
- : $"The action type /{s.Value} is not permitted in PDF/A.");
+ : $"The action type /{DiagnosticExcerpt.Quote(s.Value)} is not permitted "
+ + "in PDF/A.");
}
else if (s.Value == "Named"
&& context.Resolve(action.Get(_n)) is PdfName named
@@ -148,8 +150,8 @@ s is null
"ISO19005-2:6.5.1-named-action",
"ISO 19005-2:2011, 6.5.1",
PreflightSeverity.Error,
- $"The named action /{named.Value} is not permitted in PDF/A "
- + "(only NextPage, PrevPage, FirstPage, and LastPage are allowed).");
+ $"The named action /{DiagnosticExcerpt.Quote(named.Value)} is not permitted "
+ + "in PDF/A (only NextPage, PrevPage, FirstPage, and LastPage are allowed).");
}
// /Next is either a single action or an array of actions executed afterwards.
diff --git a/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs b/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs
index 17525740..d857f99f 100644
--- a/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Annotations;
@@ -63,7 +64,12 @@ public void Evaluate(PreflightContext context)
continue;
}
- var label = subtype is null ? "An annotation" : $"A /{subtype} annotation";
+ // Quoted here, once, because label is reused by the nine messages below; an unquoted
+ // oversized /Subtype would otherwise be cut by the sink in each of them instead of
+ // excerpted once (#403).
+ var label = subtype is null
+ ? "An annotation"
+ : $"A /{DiagnosticExcerpt.Quote(subtype)} annotation";
// §6.3.2: a /Popup annotation is driven by its parent and is exempt from the annotation
// flag requirements (it need not even carry an /F). Every other annotation's flags are
@@ -95,7 +101,8 @@ public void Evaluate(PreflightContext context)
{
apHasOnlyN = false;
context.Report(RuleId, Clause, PreflightSeverity.Error,
- $"{label}'s appearance dictionary (/AP) shall contain only the /N entry (found /{entry.Key.Value}).");
+ $"{label}'s appearance dictionary (/AP) shall contain only the /N "
+ + $"entry (found /{DiagnosticExcerpt.Quote(entry.Key.Value)}).");
break;
}
diff --git a/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs b/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs
index 511d70d8..3730fd65 100644
--- a/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Fonts;
@@ -399,8 +400,9 @@ private void CheckCMapEncoding(PreflightContext context, PdfDictionary font)
return;
if (context.Resolve(font.Get(_encoding)) is PdfName name && !_predefinedCMaps.Contains(name.Value))
Report(context, "6.2.11.3.3-cmap-name", "ISO 19005-2:2011, 6.2.11.3.3",
- $"A composite font's /Encoding names the CMap /{name.Value}, which is neither one of the "
- + "predefined CMaps nor an embedded CMap stream.");
+ $"A composite font's /Encoding names the CMap "
+ + $"/{DiagnosticExcerpt.Quote(name.Value)}, which is neither one of the predefined "
+ + "CMaps nor an embedded CMap stream.");
}
// §6.2.11.3.1-1: the CIDSystemInfo of a composite font's descendant CIDFont and its CMap must be
diff --git a/src/VellumPdf.Conformance/Rules/PreflightContext.cs b/src/VellumPdf.Conformance/Rules/PreflightContext.cs
index 070802ee..e61cfd9e 100644
--- a/src/VellumPdf.Conformance/Rules/PreflightContext.cs
+++ b/src/VellumPdf.Conformance/Rules/PreflightContext.cs
@@ -515,11 +515,40 @@ public IEnumerable EnumerateStreams()
///
public ReadOnlyMemory DecryptedRawBody(ParsedStream stream) => Reader.DecryptedStreamView(stream).RawBody;
+ ///
+ /// The longest message a retains. A message identifies a
+ /// finding; it carries at most an excerpt of an oversized producer value, never the whole of
+ /// one. Many rules interpolate a name, a string or a keyword the document controls, and
+ /// ISO 32000-2 Annex C.1 sets no bound on any of those ("In general, this PDF standard does
+ /// not restrict the size or quantity of things described in the PDF file format"), so
+ /// without this cut one
+ /// 900,000-byte /Filter name shared by 400 pages retained 705.7 MiB (GC delta) of message text
+ /// from a 990 KB file (measured in #403). 1024 characters is roughly twice the longest sentence
+ /// any rule composes on its own (522 characters, A2aContentItemTaggingRule) and short enough
+ /// that a result list of thousands of findings stays a few megabytes.
+ ///
+ /// This cut is the only bound most messages have. Ten sites whose message names a producer
+ /// value (a /Filter, an action type, a named action, an annotation /Subtype or /AP key, a
+ /// composite font's /Encoding CMap name in two rules, a /RoleMap or /Perms key, a blend mode)
+ /// additionally excerpt it through the Reader's before
+ /// interpolating, so the sentence keeps its shape; every other producer-controlled
+ /// interpolation is cut mid-value here when the value is oversized (#405 lists them). The two
+ /// differ in what they can assume: a parsed from a document is Latin-1
+ /// (one character per byte, never a surrogate pair), so
+ /// slices freely and counts bytes, while this cut
+ /// sees text decoded from UTF-16BE too and has to step around a surrogate pair.
+ ///
+ ///
+ internal const int MaxMessageChars = 1024;
+
/// Records a finding for the current validation pass.
/// Stable rule identifier (typically the rule's ).
/// Specification clause citation.
/// The finding's severity.
- /// Human-readable description.
+ /// Human-readable description. Text past
+ /// characters is replaced by ... (N chars), N being the full length in characters
+ /// (UTF-16 code units). One character less is kept when the cut would split a surrogate
+ /// pair.
/// Optional "N 0 R" object location.
public void Report(
string ruleId,
@@ -527,5 +556,20 @@ public void Report(
PreflightSeverity severity,
string message,
string? objectRef = null)
- => _assertions.Add(new PreflightAssertion(ruleId, clause, severity, message, objectRef));
+ {
+ if (message is { Length: > MaxMessageChars })
+ {
+ // A message can end mid-surrogate-pair when the producer value came from UTF-16BE
+ // text (A2aLangSyntaxRule, UaLangSyntaxRule, XmpPacket): cutting inside the pair would
+ // leave a lone high surrogate in the retained string, which the CLI's SARIF writer
+ // (Formatter.WriteSarif) would then have to serialize (#403).
+ var cut = MaxMessageChars;
+ if (char.IsHighSurrogate(message[cut - 1]))
+ cut--;
+ message = string.Concat(message.AsSpan(0, cut), "... (", message.Length.ToString(),
+ " chars)");
+ }
+
+ _assertions.Add(new PreflightAssertion(ruleId, clause, severity, message, objectRef));
+ }
}
diff --git a/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs b/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs
index 78930086..73c03e62 100644
--- a/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Structure;
@@ -69,7 +70,8 @@ private void CheckRoleMapAcyclic(PreflightContext context, PdfDictionary roleMap
RuleId,
Clause,
PreflightSeverity.Error,
- $"The structure tree /RoleMap entry /{entry.Key.Value} shall map to a name.");
+ $"The structure tree /RoleMap entry "
+ + $"/{DiagnosticExcerpt.Quote(entry.Key.Value)} shall map to a name.");
}
}
diff --git a/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs b/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs
index 25a730d1..31a1a1f0 100644
--- a/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Structure;
@@ -39,7 +40,8 @@ public void Evaluate(PreflightContext context)
continue;
context.Report(
RuleId, Clause, PreflightSeverity.Error,
- $"The permissions dictionary contains the key /{entry.Key.Value}; only /UR3 and /DocMDP "
+ $"The permissions dictionary contains the key "
+ + $"/{DiagnosticExcerpt.Quote(entry.Key.Value)}; only /UR3 and /DocMDP "
+ "are permitted in PDF/A-2.");
return; // One report suffices; the verdict is unaffected by the count.
}
diff --git a/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs b/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs
index 3d455fc2..484250e8 100644
--- a/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs
@@ -150,11 +150,15 @@ private static bool CheckOneFilter(PreflightContext context, PdfName filterName,
}
// Any other filter name (including LZWDecode) is forbidden.
+ // The name is producer-controlled and unbounded (Annex C.1); the excerpt is what
+ // the retained message keeps of it (#403). NumericLimitsRule reports the length
+ // violation itself.
context.Report(
"ISO19005-2:6.1.7.2-1-filter",
"ISO 19005-2:2011, 6.1.7.2",
PreflightSeverity.Error,
- $"A stream uses the /{filterName.Value} filter, which is not permitted in PDF/A-2.");
+ $"A stream uses the /{DiagnosticExcerpt.Quote(filterName.Value)} filter, "
+ + "which is not permitted in PDF/A-2.");
return true;
}
diff --git a/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs b/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs
index 603df571..46c3bb34 100644
--- a/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using VellumPdf.Core;
+using VellumPdf.Reader;
namespace VellumPdf.Conformance.Rules.Transparency;
@@ -98,7 +99,8 @@ private void ReportIfInvalid(PreflightContext context, PdfName blendMode)
RuleId,
Clause,
PreflightSeverity.Error,
- $"The blend mode /{blendMode.Value} is not one of the standard blend modes permitted in PDF/A-2.");
+ $"The blend mode /{DiagnosticExcerpt.Quote(blendMode.Value)} is not one of the "
+ + "standard blend modes permitted in PDF/A-2.");
}
}
}
diff --git a/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs b/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs
index acd5407f..23cc6b08 100644
--- a/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs
+++ b/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs
@@ -81,8 +81,9 @@ public void Evaluate(PreflightContext context)
RuleId1,
Clause,
PreflightSeverity.Error,
- $"A composite font's /Encoding names the CMap /{nameVal.Value}, which is neither "
- + "one of the predefined CMaps nor an embedded CMap stream (§7.21.3.3).");
+ $"A composite font's /Encoding names the CMap "
+ + $"/{DiagnosticExcerpt.Quote(nameVal.Value)}, which is neither one of the "
+ + "predefined CMaps nor an embedded CMap stream (§7.21.3.3).");
}
// Predefined name (including Identity-H/V): §7.21.3.3-2/-3 have no embedded program
// to check — they do not apply to predefined-name encodings.
diff --git a/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs b/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs
new file mode 100644
index 00000000..ba914fe4
--- /dev/null
+++ b/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs
@@ -0,0 +1,555 @@
+// Copyright © Timothy van der Ham (@Tim81)
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Text;
+using VellumPdf.Canvas;
+using VellumPdf.Conformance.Rules;
+using VellumPdf.Core;
+using VellumPdf.Document;
+using VellumPdf.Fonts;
+using VellumPdf.Reader;
+
+namespace VellumPdf.Conformance.Tests;
+
+///
+/// Value-level tests for the #403 message bound: cuts a
+/// message at , and the ten sites that quoted a
+/// producer whole (nine .Value interpolations and the annotation
+/// label AnnotationRule builds from one) excerpt it through
+/// first, so the sentence shape survives instead of
+/// being cut mid-word by the sink. Every other producer-controlled interpolation relies on the
+/// sink cut alone; #405 lists them.
+///
+public sealed class PreflightMessageBoundTests
+{
+ // ── Fixture helpers (copied from PdfPreflightTests.AssemblePdf and kept independent; that ────
+ // file is over 12,000 lines already and does not need a #403-only dependency added to it) ────
+
+ ///
+ /// A single indirect object for . For a non-stream object,
+ /// is the complete object text. For a stream object, is
+ /// the dictionary's inner entries only; the assembler wraps it and appends the /Length.
+ ///
+ private sealed record PdfObj(string Dict, byte[]? Stream = null);
+
+ private static readonly PdfObj _pagesObj = new("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
+ private static readonly PdfObj _pageObj = new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>");
+
+ private static byte[] AssemblePdf(
+ IReadOnlyList objects,
+ string xmpConformance = "B")
+ {
+ var all = new List(objects);
+
+ var metaObjNum = all.Count + 1;
+ var xmp = XmpBytes("2", xmpConformance);
+ all.Add(new PdfObj("/Type /Metadata /Subtype /XML", xmp));
+ all[0] = all[0] with { Dict = InjectIntoDict(all[0].Dict, $"/Metadata {metaObjNum} 0 R") };
+
+ var ms = new MemoryStream();
+ void W(string s) => ms.Write(Encoding.ASCII.GetBytes(s));
+
+ W("%PDF-1.7\n");
+ ms.Write([(byte)'%', 0xE2, 0xE3, 0xCF, 0xD3, (byte)'\n']);
+
+ var offsets = new int[all.Count + 1];
+ for (var i = 0; i < all.Count; i++)
+ {
+ offsets[i + 1] = (int)ms.Position;
+ var n = i + 1;
+ if (all[i].Stream is { } body)
+ {
+ W($"{n} 0 obj\n<< {all[i].Dict} /Length {body.Length} >>\nstream\n");
+ ms.Write(body);
+ W("\nendstream\nendobj\n");
+ }
+ else
+ {
+ W($"{n} 0 obj\n{all[i].Dict}\nendobj\n");
+ }
+ }
+
+ var xrefOffset = (int)ms.Position;
+ var size = all.Count + 1;
+ W($"xref\n0 {size}\n");
+ W($"{0:D10} 65535 f \n");
+ for (var i = 1; i <= all.Count; i++)
+ W($"{offsets[i]:D10} 00000 n \n");
+ W($"trailer\n<< /Size {size} /Root 1 0 R "
+ + "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>] >>\n");
+ W($"startxref\n{xrefOffset}\n%%EOF\n");
+
+ return ms.ToArray();
+ }
+
+ private static string InjectIntoDict(string dict, string entry)
+ {
+ var i = dict.LastIndexOf(">>", StringComparison.Ordinal);
+ return i < 0 ? dict : string.Concat(dict[..i], entry, " ", dict[i..]);
+ }
+
+ private static byte[] XmpBytes(string part, string conformance)
+ {
+ var xmp =
+ ""
+ + ""
+ + ""
+ + $"{part}"
+ + $"{conformance}"
+ + "";
+ return Encoding.UTF8.GetBytes(xmp);
+ }
+
+ ///
+ /// Builds a doc whose page's /Contents stream carries the given /Filter name.
+ ///
+ private static byte[] BuildOversizedFilterPdf(string filterName)
+ => AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ _pagesObj,
+ new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>"),
+ new($"/Filter /{filterName}", []),
+ ]);
+
+ ///
+ /// Same shape as PdfPreflightTests.BuildFontPdf: a Type0 font selected via Tf.
+ ///
+ private static byte[] BuildFontPdf(params PdfObj[] fontObjects)
+ {
+ var contentObjNum = 6 + fontObjects.Length;
+ var objects = new List
+ {
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ _pagesObj,
+ new($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources 4 0 R "
+ + $"/Contents {contentObjNum} 0 R >>"),
+ new("<< /Font 5 0 R >>"),
+ new("<< /F0 6 0 R >>"),
+ };
+ objects.AddRange(fontObjects);
+ objects.Add(new PdfObj(string.Empty, Encoding.ASCII.GetBytes("BT /F0 12 Tf ET")));
+ return AssemblePdf(objects);
+ }
+
+ ///
+ /// Builds a UA-1 tagged document with one embedded (Type0) font selected via Tf, mirroring
+ /// OracleCorpus.WriterPdfTagged. Kept independent because that method is private to
+ /// OracleCorpus.
+ ///
+ private static byte[] BuildUa1TaggedPdf()
+ {
+ using var doc = new PdfDocument
+ {
+ Conformance = VellumPdf.Document.PdfConformance.PdfUA1,
+ Language = "en-US",
+ };
+ doc.Info.Title = "PreflightMessageBoundTests fixture";
+ var page = doc.AddPage(PageSize.A4);
+ var handle = doc.EmbedStandard14Font(Standard14.Helvetica);
+ doc.RegisterEmbeddedFontUsage(page, handle);
+
+ var canvas = new PdfCanvas(page);
+ var mcid = canvas.BeginMarkedContent("P");
+ canvas.BeginText().SetFontByName(handle.ResourceName, 12).SetTextMatrix(1, 0, 0, 1, 72, 720);
+ var gids = new ushort[7];
+ var count = handle.GetGlyphIds("Tagged.", gids);
+ canvas.ShowGlyphs(gids.AsSpan(0, count));
+ canvas.EndText();
+ canvas.EndMarkedContent();
+ canvas.Finish();
+
+ var p = new PdfStructElem("P") { Page = page, Mcid = mcid };
+ var root = new PdfStructElem("Document");
+ root.AddChild(p);
+ doc.RegisterStructElem(root);
+
+ using var ms = new MemoryStream();
+ doc.Save(ms);
+ return ms.ToArray();
+ }
+
+ ///
+ /// Rewrites the fixture's Type0 font's /Encoding to an oversized name via an incremental
+ /// update, mirroring OracleCorpus.Ua1BadCMapName's clone-and-AppendRevision step (that
+ /// method hardcodes a ten-character name, so it cannot produce an oversized one).
+ ///
+ private static byte[] BuildUa1OversizedCMapNamePdf(int nameBytes)
+ {
+ var baseline = BuildUa1TaggedPdf();
+ using var reader = PdfReader.Open(baseline);
+ var pagesRef = (PdfIndirectReference)reader.Catalog.Get(PdfName.Pages)!;
+ var pages = (PdfDictionary)reader.Resolve(pagesRef.ObjectNumber)!;
+ var kidsObj = pages.Get(new PdfName("Kids"));
+ var kids = kidsObj is PdfIndirectReference kr
+ ? (PdfArray)reader.Resolve(kr.ObjectNumber)!
+ : (PdfArray)kidsObj!;
+ var pageRef = (PdfIndirectReference)kids[0];
+ var page = (PdfDictionary)reader.Resolve(pageRef.ObjectNumber)!;
+ var resources = (PdfDictionary)reader.ResolveValue(page.Get(new PdfName("Resources"))!)!;
+ var fontDict = (PdfDictionary)reader.ResolveValue(resources.Get(PdfName.Font)!)!;
+ var type0Ref = (PdfIndirectReference)fontDict.Entries.First().Value;
+ var type0 = (PdfDictionary)reader.Resolve(type0Ref.ObjectNumber)!;
+
+ var clone = new PdfDictionary();
+ foreach (var kv in type0.Entries)
+ clone.Set(kv.Key, kv.Value);
+ clone.Set(new PdfName("Encoding"), new PdfName(new string('A', nameBytes)));
+
+ return reader.AppendRevision([(type0Ref.ObjectNumber, 0, clone)]);
+ }
+
+ // ── The shared oversized-/Filter fixture: the StreamRule site, and the sink ─────────────────
+
+ [Fact]
+ public void StreamFilter_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var bytes = BuildOversizedFilterPdf(new string('A', 1_048_576));
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.1.7.2-1-filter");
+ Assert.Equal(
+ "A stream uses the /" + new string('A', 32) + "... (1048576 bytes) filter, "
+ + "which is not permitted in PDF/A-2.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void RuleEvaluationFailure_withAnOversizedToken_isBoundedByTheSink()
+ {
+ var bytes = BuildOversizedFilterPdf(new string('A', 1_048_576));
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var full = "Rule evaluation failed: Unknown PDF filter: /" + new string('A', 1_048_576);
+ var expected = full[..1024] + $"... ({full.Length} chars)";
+
+ // Several rules try to decode the same oversized-filter /Contents stream and each wraps
+ // the same InvalidDataException as its own "Rule evaluation failed" finding (#403); assert
+ // every one of them, not just the first, since they all quote the identical thrown message.
+ var matching = result.Assertions
+ .Where(a => a.Message.StartsWith(
+ "Rule evaluation failed: Unknown PDF filter: /", StringComparison.Ordinal))
+ .ToList();
+ Assert.NotEmpty(matching);
+ Assert.All(matching, a => Assert.Equal(expected, a.Message));
+ }
+
+ // ── One test per remaining site that quotes a producer name ──────────────────────────────────
+
+ [Fact]
+ public void ActionType_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var name = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new($"<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /{name} >> >>"),
+ _pagesObj,
+ _pageObj,
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.5.1-action");
+ Assert.Equal(
+ "The action type /" + new string('A', 32) + "... (1048576 bytes) is not permitted in PDF/A.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void NamedAction_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var name = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>"),
+ _pagesObj,
+ _pageObj,
+ new($"<< /S /Named /N /{name} >>"),
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.5.1-named-action");
+ Assert.Equal(
+ "The named action /" + new string('A', 32) + "... (1048576 bytes) is not permitted "
+ + "in PDF/A (only NextPage, PrevPage, FirstPage, and LastPage are allowed).",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void AnnotationAppearanceExtraKey_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var key = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ _pagesObj,
+ new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R] >>"),
+ new("<< /Type /Annot /Subtype /Text /Rect [10 10 50 50] /F 4 /Contents (n) "
+ + $"/AP << /N 5 0 R /{key} 5 0 R >> >>"),
+ new("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", Stream: []),
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions,
+ a => a.RuleId == "ISO19005-2:6.3-annotation" && a.Message.Contains("(/AP)", StringComparison.Ordinal));
+ Assert.Equal(
+ "A /Text annotation's appearance dictionary (/AP) shall contain only the /N entry (found /"
+ + new string('A', 32) + "... (1048576 bytes)).",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void AnnotationLabel_withAnOversizedSubtype_reportsAFixedExcerptEverywhereItIsReused()
+ {
+ var subtype = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ _pagesObj,
+ new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R] >>"),
+ new($"<< /Type /Annot /Subtype /{subtype} /Rect [10 10 50 50] >>"),
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var label = "A /" + new string('A', 32) + "... (1048576 bytes) annotation";
+ var annotationFindings = result.Assertions
+ .Where(a => a.RuleId == "ISO19005-2:6.3-annotation")
+ .ToList();
+ // No /F and no /AP: the Print-flag message and the missing-appearance message both fire,
+ // and both must carry the excerpt rather than the whole /Subtype.
+ Assert.Equal(2, annotationFindings.Count);
+ Assert.All(
+ annotationFindings,
+ a => Assert.StartsWith(label, a.Message, StringComparison.Ordinal));
+ Assert.Contains(
+ annotationFindings,
+ a => a.Message == label + " shall have the Print flag set.");
+ Assert.Contains(
+ annotationFindings,
+ a => a.Message == label + " shall have a normal appearance (/AP /N).");
+ }
+
+ [Fact]
+ public void Type0CMapName_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var cmapName = new string('A', 1_048_576);
+ var bytes = BuildFontPdf(
+ new PdfObj($"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /{cmapName} "
+ + "/DescendantFonts [7 0 R] >>"),
+ new PdfObj("<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /FontDescriptor 8 0 R "
+ + "/CIDToGIDMap /Identity >>"),
+ new PdfObj("<< /Type /FontDescriptor /FontName /X /FontFile2 9 0 R >>"),
+ new PdfObj("/Length1 4", [1, 2, 3, 4]));
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.2.11.3.3-cmap-name");
+ Assert.Equal(
+ "A composite font's /Encoding names the CMap /" + new string('A', 32)
+ + "... (1048576 bytes), which is neither one of the predefined CMaps nor an embedded "
+ + "CMap stream.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void RoleMapEntry_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ // LogicalStructureRule's "shall map to a name" branch needs a /RoleMap value that is not a
+ // name (a name key alone cannot reach it); 42 is an arbitrary non-name.
+ var roleKey = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 4 0 R >>"),
+ _pagesObj,
+ _pageObj,
+ new($"<< /Type /StructTreeRoot /RoleMap << /{roleKey} 42 >> >>"),
+ ],
+ xmpConformance: "A");
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2A);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.8-logical-structure");
+ Assert.Equal(
+ "The structure tree /RoleMap entry /" + new string('A', 32)
+ + "... (1048576 bytes) shall map to a name.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void PermissionsKey_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var key = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new($"<< /Type /Catalog /Pages 2 0 R /Perms << /{key} << /Type /Sig >> >> >>"),
+ _pagesObj,
+ _pageObj,
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.1.12-1-permissions");
+ Assert.Equal(
+ "The permissions dictionary contains the key /" + new string('A', 32)
+ + "... (1048576 bytes); only /UR3 and /DocMDP are permitted in PDF/A-2.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void BlendMode_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var bm = new string('A', 1_048_576);
+ var bytes = AssemblePdf(
+ [
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ _pagesObj,
+ new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources 4 0 R /Contents 7 0 R >>"),
+ new("<< /ExtGState 5 0 R >>"),
+ new("<< /GS0 6 0 R >>"),
+ new($"<< /Type /ExtGState /BM /{bm} >>"),
+ new(string.Empty, Encoding.ASCII.GetBytes("q /GS0 gs Q")),
+ ]);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO19005-2:6.2.10-blend-mode");
+ Assert.Equal(
+ "The blend mode /" + new string('A', 32) + "... (1048576 bytes) is not one of the "
+ + "standard blend modes permitted in PDF/A-2.",
+ assertion.Message);
+ }
+
+ [Fact]
+ public void UaType0CMapName_withAnOversizedName_reportsAFixedExcerpt()
+ {
+ var bytes = BuildUa1OversizedCMapNamePdf(1_048_576);
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfUA1);
+
+ var assertion = Assert.Single(
+ result.Assertions, a => a.RuleId == "ISO14289-1:7.21.3.3-1");
+ Assert.Equal(
+ "A composite font's /Encoding names the CMap /" + new string('A', 32)
+ + "... (1048576 bytes), which is neither one of the predefined CMaps nor an embedded "
+ + "CMap stream (§7.21.3.3).",
+ assertion.Message);
+ }
+
+ // ── Layer A: the sink's own cut, at the boundary ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void Report_atTheBoundary_keepsExactlyMaxMessageChars(int scenario)
+ {
+ var bytes = AssemblePdf([new("<< /Type /Catalog /Pages 2 0 R >>"), _pagesObj, _pageObj]);
+ using var reader = PdfReader.Open(bytes);
+ var assertions = new List();
+ var context = new PreflightContext(reader, PdfConformance.PdfA2B, assertions);
+
+ var message = scenario switch
+ {
+ 0 => new string('x', PreflightContext.MaxMessageChars),
+ 1 => new string('x', PreflightContext.MaxMessageChars + 1),
+ _ => new string('x', 1023) + "\U0001F600" + "trailing text past the boundary",
+ };
+
+ context.Report("VellumTest:boundary", "n/a", PreflightSeverity.Error, message);
+
+ var retained = Assert.Single(assertions).Message;
+ switch (scenario)
+ {
+ case 0:
+ Assert.Equal(message, retained);
+ break;
+ case 1:
+ Assert.Equal(message[..1024] + $"... ({message.Length} chars)", retained);
+ break;
+ default:
+ Assert.Equal(message[..1023] + $"... ({message.Length} chars)", retained);
+ break;
+ }
+
+ // The Assert.Equal above is the discriminating check: with the surrogate step-back in
+ // Report removed, scenario 2 keeps 1024 characters and lands between the two halves of
+ // U+1F600, so the expected 1023-character prefix no longer matches. The walk below restates
+ // the invariant that check implies (no lone surrogate anywhere in the retained text) in the
+ // form the CLI's SARIF writer needs.
+ for (var i = 0; i < retained.Length; i++)
+ {
+ if (char.IsHighSurrogate(retained[i]))
+ {
+ Assert.True(i + 1 < retained.Length && char.IsLowSurrogate(retained[i + 1]),
+ $"Lone high surrogate at index {i}.");
+ i++;
+ }
+ else
+ {
+ Assert.False(char.IsLowSurrogate(retained[i]), $"Lone low surrogate at index {i}.");
+ }
+ }
+ }
+
+ // ── The issue's own shape: 400 pages sharing one oversized /Filter name ─────────────────────
+
+ [Fact]
+ public void FourHundredPagesSharingOneOversizedFilterName_retainOnlyBoundedMessages()
+ {
+ const int pageCount = 400;
+ const int nameBytes = 900_000;
+ var firstContentObj = 3 + pageCount;
+ var sharedFilterObj = 3 + pageCount * 2;
+
+ var objects = new List
+ {
+ new("<< /Type /Catalog /Pages 2 0 R >>"),
+ new("<< /Type /Pages /Kids ["
+ + string.Join(" ", Enumerable.Range(3, pageCount).Select(n => $"{n} 0 R"))
+ + $"] /Count {pageCount} >>"),
+ };
+ for (var i = 0; i < pageCount; i++)
+ objects.Add(new PdfObj(
+ $"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {firstContentObj + i} 0 R >>"));
+ for (var i = 0; i < pageCount; i++)
+ objects.Add(new PdfObj($"/Filter {sharedFilterObj} 0 R", []));
+ objects.Add(new PdfObj("/" + new string('A', nameBytes)));
+
+ var bytes = AssemblePdf(objects, xmpConformance: "A");
+
+ var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2A);
+
+ // "... (" + up to ten digits of length + " chars)" is the longest suffix Report appends.
+ const int maxSuffixChars = 5 + 10 + 7;
+ Assert.All(
+ result.Assertions,
+ a => Assert.True(
+ a.Message.Length <= PreflightContext.MaxMessageChars + maxSuffixChars,
+ $"A retained message of rule {a.RuleId} was {a.Message.Length} chars."));
+
+ // 128 Ki characters for 407 findings; the pre-fix total for this fixture was 693.6 MiB of
+ // message text (#403's 705.7 MiB is a GC delta measured on a 990 KB variant of the same
+ // shape).
+ var totalLength = result.Assertions.Sum(a => a.Message.Length);
+ Assert.True(totalLength < 131_072, $"Total retained message length was {totalLength} chars.");
+
+ Assert.Contains(
+ result.Assertions,
+ a => a.Message == "A stream uses the /" + new string('A', 32) + "... (900000 bytes) filter, "
+ + "which is not permitted in PDF/A-2.");
+ }
+}