Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/VellumPdf.Conformance/PdfPreflight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Expand Down
10 changes: 9 additions & 1 deletion src/VellumPdf.Conformance/PreflightAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ public sealed class PreflightAssertion
/// <summary>The severity of the finding.</summary>
public PreflightSeverity Severity { get; }

/// <summary>A human-readable description of the finding.</summary>
/// <summary>
/// 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
/// <see cref="RuleId"/> instead of matching text here. The text is bounded: past 1024
/// characters it is replaced by <c>... (N chars)</c> 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 <c>... (N bytes)</c>; every other producer value is bounded by
/// the 1024-character cut alone.
/// </summary>
public string Message { get; }

/// <summary>
Expand Down
8 changes: 5 additions & 3 deletions src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Actions;

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Annotations;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Fonts;

Expand Down Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions src/VellumPdf.Conformance/Rules/PreflightContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -515,17 +515,61 @@ public IEnumerable<ParsedStream> EnumerateStreams()
/// </summary>
public ReadOnlyMemory<byte> DecryptedRawBody(ParsedStream stream) => Reader.DecryptedStreamView(stream).RawBody;

/// <summary>
/// The longest message a <see cref="PreflightAssertion"/> 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.
/// <para>
/// 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 <see cref="DiagnosticExcerpt"/> 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 <see cref="PdfName"/> parsed from a document is Latin-1
/// (one character per byte, never a surrogate pair), so
/// <see cref="DiagnosticExcerpt.Quote(string)"/> slices freely and counts bytes, while this cut
/// sees text decoded from UTF-16BE too and has to step around a surrogate pair.
/// </para>
/// </summary>
internal const int MaxMessageChars = 1024;

/// <summary>Records a finding for the current validation pass.</summary>
/// <param name="ruleId">Stable rule identifier (typically the rule's <see cref="IConformanceRule.RuleId"/>).</param>
/// <param name="clause">Specification clause citation.</param>
/// <param name="severity">The finding's severity.</param>
/// <param name="message">Human-readable description.</param>
/// <param name="message">Human-readable description. Text past <see cref="MaxMessageChars"/>
/// characters is replaced by <c>... (N chars)</c>, N being the full length in characters
/// (UTF-16 code units). One character less is kept when the cut would split a surrogate
/// pair.</param>
/// <param name="objectRef">Optional <c>"N 0 R"</c> object location.</param>
public void Report(
string ruleId,
string clause,
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Structure;

Expand Down Expand Up @@ -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.");
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Structure;

Expand Down Expand Up @@ -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.
}
Expand Down
6 changes: 5 additions & 1 deletion src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

using VellumPdf.Core;
using VellumPdf.Reader;

namespace VellumPdf.Conformance.Rules.Transparency;

Expand Down Expand Up @@ -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.");
}
}
}
5 changes: 3 additions & 2 deletions src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading