Module: Pdfrb::Conformance::PdfA

Defined in:
lib/pdfrb/conformance/pdf_a.rb

Constant Summary collapse

SHARED =
RuleSet.new("PDF/A-shared").tap do |rs|
  rs.register(Rule.new(
                id: "6.1-1", description: "Encryption prohibited",
                severity: :error, spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  next nil unless doc.trailer && doc.trailer[:Encrypt]

                  Violation.new(rule_id: "6.1-1", message: "PDF/A prohibits encryption",
                                object: "trailer", severity: :error, spec_clause: "ISO 19005-1 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "6.1-2", description: "XMP Metadata required",
                severity: :error, spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  next nil if doc.catalog[:Metadata]

                  Violation.new(rule_id: "6.1-2", message: "PDF/A requires /Catalog/Metadata XMP stream",
                                object: "Catalog", severity: :error, spec_clause: "ISO 19005-1 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "6.1-4", description: "JavaScript prohibited",
                severity: :error, spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
                    if obj.value[:S] == :JavaScript
                      return Violation.new(rule_id: "6.1-4", message: "PDF/A prohibits JavaScript actions",
                                           object: "JavaScript action", severity: :error,
                                           spec_clause: "ISO 19005-1 6.1")
                    end
                  end
                  nil
                }
              ))
  rs.register(Rule.new(
                id: "6.1-7", description: "Language recommended",
                severity: :warning, spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  next nil if doc.catalog[:Lang]

                  Violation.new(rule_id: "6.1-7", message: "PDF/A recommends /Catalog/Lang",
                                object: "Catalog", severity: :warning, spec_clause: "ISO 19005-1 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "embedded-fonts", description: "All fonts must be embedded",
                severity: :error, spec_clause: "ISO 19005-1 6.2",
                check: ->(doc) {
                  vs = []
                  doc.pages.each do |page|
                    res = page.value[:Resources]
                    next unless res

                    fonts = res.is_a?(Pdfrb::Model::Cos::Dictionary) ? res.value[:Font] : res[:Font]
                    next unless fonts

                    fonts.each_value do |ref|
                      f = ref.is_a?(Pdfrb::Model::Reference) ? doc.object(ref) : ref
                      next unless f
                      next if f[:FontDescriptor]

                      vs << Violation.new(rule_id: "embedded-fonts",
                                          message: "Font /#{f[:BaseFont]} missing FontDescriptor",
                                          object: f[:BaseFont]&.to_s, severity: :error,
                                          spec_clause: "ISO 19005-1 6.2")
                    end
                  end
                  vs.empty? ? nil : vs
                }
              ))
end
PREFLIGHT_DEEP =

Deep preflight rules common to all PDF/A levels. Each is a categorical ban: JavaScript, PS XObjects, non-A-3 embedded files, and unencoded text operators.

RuleSet.new("PDF/A-preflight").tap do |rs|
  rs.register(Rule.new(
                id: "preflight-1",
                description: "JavaScript is forbidden",
                severity: :error,
                spec_clause: "ISO 19005-1 6.6",
                check: ->(doc) {
                  vs = []
                  catalog = doc.catalog
                  names = catalog[:Names]
                  names = doc.object(names) if names.is_a?(Pdfrb::Model::Reference)
                  if names && (names[:JavaScript] || (names.is_a?(::Hash) && names[:JavaScript]))
                    vs << Violation.new(
                      rule_id: "preflight-1",
                      message: "/Names /JavaScript is forbidden in PDF/A",
                      object: "Catalog/Names/JavaScript",
                      severity: :error,
                      spec_clause: "ISO 19005-1 6.6"
                    )
                  end
                  if catalog[:AA] || catalog[:OpenAction]
                    vs << Violation.new(
                      rule_id: "preflight-1",
                      message: "Additional actions (/AA) and /OpenAction with JavaScript are restricted in PDF/A",
                      object: "Catalog",
                      severity: :warning,
                      spec_clause: "ISO 19005-1 6.6"
                    )
                  end
                  vs.empty? ? nil : vs
                }
              ))

  rs.register(Rule.new(
                id: "preflight-2",
                description: "PostScript XObjects are forbidden",
                severity: :error,
                spec_clause: "ISO 19005-1 6.2.3",
                check: ->(doc) {
                  vs = []
                  doc.each_indirect_object do |obj|
                    next unless obj.value[:Type] == :XObject
                    next unless obj.value[:Subtype] == :PS

                    vs << Violation.new(
                      rule_id: "preflight-2",
                      message: "PostScript XObjects are forbidden in PDF/A",
                      object: "XObject",
                      severity: :error,
                      spec_clause: "ISO 19005-1 6.2.3"
                    )
                  end
                  vs.empty? ? nil : vs
                }
              ))

  rs.register(Rule.new(
                id: "preflight-3",
                description: "EmbeddedFile streams restricted",
                severity: :error,
                spec_clause: "ISO 19005-3 6.4",
                check: ->(_doc) {
                  # Only PDF/A-3 allows EmbeddedFile. The rule
                  # is permissive by default; the per-level
                  # rule sets filter it down for A-1 and A-2.
                }
              ))

  rs.register(Rule.new(
                id: "preflight-4",
                description: "Encryption is forbidden",
                severity: :error,
                spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  next nil unless doc.trailer && doc.trailer[:Encrypt]

                  Violation.new(
                    rule_id: "preflight-4",
                    message: "Encryption is forbidden in PDF/A",
                    object: "Trailer/Encrypt",
                    severity: :error,
                    spec_clause: "ISO 19005-1 6.1"
                  )
                }
              ))
end
PREFLIGHT_NO_EMBED =

Embeds files forbidden in PDF/A-1 and PDF/A-2 (only A-3 permits them).

RuleSet.new("PDF/A-no-embedded").tap do |rs|
  rs.register(Rule.new(
                id: "preflight-3-a1a2",
                description: "EmbeddedFile streams are forbidden in PDF/A-1 and A-2",
                severity: :error,
                spec_clause: "ISO 19005-1 6.4",
                check: ->(doc) {
                  vs = []
                  doc.each_indirect_object do |obj|
                    next unless obj.value[:Type] == :EmbeddedFile

                    vs << Violation.new(
                      rule_id: "preflight-3-a1a2",
                      message: "EmbeddedFile streams are forbidden in PDF/A-1 and A-2",
                      object: "EmbeddedFile",
                      severity: :error,
                      spec_clause: "ISO 19005-1 6.4"
                    )
                  end
                  vs.empty? ? nil : vs
                }
              ))
end
A1_SPECIFIC =
RuleSet.new("PDF/A-1-specific").tap do |rs|
  rs.register(Rule.new(
                id: "a1-1", description: "JPEG2000 not allowed in A-1",
                severity: :error, spec_clause: "ISO 19005-1 6.2.4",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Stream)
                    next unless obj[:Filter] == :JPXDecode

                    return Violation.new(rule_id: "a1-1",
                                         message: "JPEG2000 not allowed in PDF/A-1",
                                         object: "XObject", severity: :error,
                                         spec_clause: "ISO 19005-1 6.2.4")
                  end
                  nil
                }
              ))
  rs.register(Rule.new(
                id: "a1-2", description: "Object streams not allowed in A-1",
                severity: :error, spec_clause: "ISO 19005-1 6.2.4",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
                    next unless obj.value[:Type] == :ObjStm

                    return Violation.new(rule_id: "a1-2",
                                         message: "Object streams not allowed in PDF/A-1",
                                         object: "ObjStm", severity: :error,
                                         spec_clause: "ISO 19005-1 6.2.4")
                  end
                  nil
                }
              ))
  rs.register(Rule.new(
                id: "a1-3", description: "PDF version must not exceed 1.4",
                severity: :error, spec_clause: "ISO 19005-1 6.1",
                check: ->(doc) {
                  parts = doc.version.to_s.split(".")
                  major = parts[0].to_i
                  minor = (parts[1] || "0").to_i
                  next nil if major < 1 || (major == 1 && minor <= 4)

                  Violation.new(rule_id: "a1-3",
                                message: "PDF/A-1 requires version <= 1.4, got #{doc.version}",
                                object: "header", severity: :error, spec_clause: "ISO 19005-1 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "a1-4", description: "LZWDecode not allowed in A-1",
                severity: :error, spec_clause: "ISO 19005-1 6.2.4",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Stream)

                    f = obj[:Filter]
                    fs = f.is_a?(::Array) ? f : [f]
                    next unless fs.include?(:LZWDecode)

                    return Violation.new(rule_id: "a1-4",
                                         message: "LZWDecode not allowed in PDF/A-1",
                                         object: "Stream", severity: :error,
                                         spec_clause: "ISO 19005-1 6.2.4")
                  end
                  nil
                }
              ))
end
A2_SPECIFIC =
RuleSet.new("PDF/A-2-specific").tap do |rs|
  rs.register(Rule.new(
                id: "a2-1", description: "PostScript XObjects prohibited",
                severity: :warning, spec_clause: "ISO 19005-2 6.2.4",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Stream)
                    next unless [:PS, :PSXObject].include?(obj[:Subtype])

                    return Violation.new(rule_id: "a2-1",
                                         message: "PostScript XObjects not allowed in PDF/A-2",
                                         object: "XObject", severity: :warning,
                                         spec_clause: "ISO 19005-2 6.2.4")
                  end
                  nil
                }
              ))
  rs.register(Rule.new(
                id: "a2-2", description: "Embedded files not allowed in A-2",
                severity: :error, spec_clause: "ISO 19005-2 6.2",
                check: ->(doc) {
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
                    next unless obj.value[:Type] == :EmbeddedFile

                    return Violation.new(rule_id: "a2-2",
                                         message: "Embedded files not allowed in PDF/A-2 (use PDF/A-3)",
                                         object: "EmbeddedFile", severity: :error,
                                         spec_clause: "ISO 19005-2 6.2")
                  end
                  nil
                }
              ))
  rs.register(Rule.new(
                id: "a2-3", description: "PDF 2.0 not allowed",
                severity: :error, spec_clause: "ISO 19005-2 6.1",
                check: ->(doc) {
                  next nil unless doc.version.to_s.start_with?("2.")

                  Violation.new(rule_id: "a2-3",
                                message: "PDF/A-2 does not allow PDF 2.0, got #{doc.version}",
                                object: "header", severity: :error, spec_clause: "ISO 19005-2 6.1")
                }
              ))
end
A3_SPECIFIC =
RuleSet.new("PDF/A-3-specific").tap do |rs|
  rs.register(Rule.new(
                id: "a3-1", description: "Filespecs must have AFRelationship",
                severity: :error, spec_clause: "ISO 19005-3 6.2",
                check: ->(doc) {
                  vs = []
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
                    next unless obj.value[:Type] == :Filespec
                    next if obj.value[:AFRelationship]

                    vs << Violation.new(rule_id: "a3-1",
                                        message: "Filespec missing /AFRelationship",
                                        object: "Filespec", severity: :error,
                                        spec_clause: "ISO 19005-3 6.2")
                  end
                  vs.empty? ? nil : vs
                }
              ))
end
A4_SPECIFIC =
RuleSet.new("PDF/A-4-specific").tap do |rs|
  rs.register(Rule.new(
                id: "a4-1", description: "PDF 2.0 required",
                severity: :error, spec_clause: "ISO 19005-4 6.1",
                check: ->(doc) {
                  next nil if doc.version.to_s.start_with?("2.0")

                  Violation.new(rule_id: "a4-1",
                                message: "PDF/A-4 requires PDF 2.0, got #{doc.version}",
                                object: "header", severity: :error, spec_clause: "ISO 19005-4 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "a4-2", description: "Filespecs must have AFRelationship",
                severity: :error, spec_clause: "ISO 19005-4 6.2",
                check: ->(doc) {
                  vs = []
                  doc.each_indirect_object do |obj|
                    next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
                    next unless obj.value[:Type] == :Filespec
                    next if obj.value[:AFRelationship]

                    vs << Violation.new(rule_id: "a4-2",
                                        message: "Filespec missing /AFRelationship",
                                        object: "Filespec", severity: :error,
                                        spec_clause: "ISO 19005-4 6.2")
                  end
                  vs.empty? ? nil : vs
                }
              ))
  rs.register(Rule.new(
                id: "a4-3", description: "Annotations need appearance streams",
                severity: :error, spec_clause: "ISO 19005-4 6.2.3",
                check: ->(doc) {
                  vs = []
                  doc.each_indirect_object do |annot|
                    next unless annot.is_a?(Pdfrb::Model::Cos::Dictionary)
                    next unless annot.value[:Type] == :Annot

                    st = annot.value[:Subtype]
                    next if st == :Link

                    r = annot.value[:Rect]
                    next if r.is_a?(::Array) && r.length == 4 && r[0] == r[2] && r[1] == r[3]
                    next if annot.value[:AP]

                    vs << Violation.new(rule_id: "a4-3",
                                        message: "Annotation /#{st} missing /AP",
                                        object: "Annot", severity: :error,
                                        spec_clause: "ISO 19005-4 6.2.3")
                  end
                  vs.empty? ? nil : vs
                }
              ))
  rs.register(Rule.new(
                id: "a4-4", description: "Trailer /ID recommended",
                severity: :warning, spec_clause: "ISO 19005-4 6.1",
                check: ->(doc) {
                  next nil if doc.trailer && doc.trailer[:ID]

                  Violation.new(rule_id: "a4-4", message: "PDF/A-4 recommends /ID in trailer",
                                object: "trailer", severity: :warning, spec_clause: "ISO 19005-4 6.1")
                }
              ))
  rs.register(Rule.new(
                id: "a4-5", description: "Metadata XMP recommended",
                severity: :warning, spec_clause: "ISO 19005-4 6.1",
                check: ->(doc) {
                  next nil if doc.catalog[:Metadata]

                  Violation.new(rule_id: "a4-5",
                                message: "PDF/A-4 recommends /Catalog/Metadata XMP stream",
                                object: "Catalog", severity: :warning, spec_clause: "ISO 19005-4 6.1")
                }
              ))
end
A1 =
RuleSet.new("PDF/A-1").tap do |rs|
  SHARED.rules.each { |r| rs.register(r) }
  A1_SPECIFIC.rules.each { |r| rs.register(r) }
  PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
  PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
end
A2 =
RuleSet.new("PDF/A-2").tap do |rs|
  SHARED.rules.each { |r| rs.register(r) }
  A2_SPECIFIC.rules.each { |r| rs.register(r) }
  PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
  PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
end
A3 =
RuleSet.new("PDF/A-3").tap do |rs|
  SHARED.rules.each { |r| rs.register(r) }
  A3_SPECIFIC.rules.each { |r| rs.register(r) }
  PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
end
A4 =
RuleSet.new("PDF/A-4").tap do |rs|
  SHARED.rules.each { |r| rs.register(r) }
  A4_SPECIFIC.rules.each { |r| rs.register(r) }
  PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
  PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
end
LEVEL_RULESETS =
{
  a1b: A1, a1a: A1,
  a2b: A2, a2a: A2,
  a3b: A3, a3a: A3,
  a4: A4
}.freeze

Class Method Summary collapse

Class Method Details

.profilesObject



445
446
447
448
# File 'lib/pdfrb/conformance/pdf_a.rb', line 445

def profiles
  { a1b: A1, a1a: A1, a2b: A2, a2a: A2,
    a3b: A3, a3a: A3, a4: A4 }
end

.validate(document, level: :a2b) ⇒ Object



450
451
452
453
# File 'lib/pdfrb/conformance/pdf_a.rb', line 450

def validate(document, level: :a2b)
  rs = LEVEL_RULESETS[level] || SHARED
  rs.validate(document)
end