facturx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +22 -0
- data/NOTICE.md +9 -0
- data/README.md +75 -0
- data/lib/facturx/attach.rb +34 -0
- data/lib/facturx/composers/ghostscript/defaults.rb +29 -0
- data/lib/facturx/composers/ghostscript/locator.rb +134 -0
- data/lib/facturx/composers/ghostscript/output_capture.rb +39 -0
- data/lib/facturx/composers/ghostscript/runner.rb +122 -0
- data/lib/facturx/composers/ghostscript/version_probe.rb +61 -0
- data/lib/facturx/composers/ghostscript.rb +105 -0
- data/lib/facturx/embedding.rb +15 -0
- data/lib/facturx/error.rb +22 -0
- data/lib/facturx/pdf/document.rb +103 -0
- data/lib/facturx/pdf/extractor.rb +115 -0
- data/lib/facturx/pdf/inspector.rb +17 -0
- data/lib/facturx/pdf/name_tree.rb +60 -0
- data/lib/facturx/pdf/verifier.rb +116 -0
- data/lib/facturx/profile.rb +5 -0
- data/lib/facturx/profiles.rb +60 -0
- data/lib/facturx/schema/1.09.2/basic/Factur-X_1.09.2_BASIC.xsd +20 -0
- data/lib/facturx/schema/1.09.2/basic/Factur-X_1.09.2_BASIC_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd +86 -0
- data/lib/facturx/schema/1.09.2/basic/Factur-X_1.09.2_BASIC_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd +60 -0
- data/lib/facturx/schema/1.09.2/basic/ReusableAggregateBusinessInformationEntity_100.xsd +249 -0
- data/lib/facturx/schema/1.09.2/basic-wl/Factur-X_1.09.2_BASICWL.xsd +20 -0
- data/lib/facturx/schema/1.09.2/basic-wl/Factur-X_1.09.2_BASICWL_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd +86 -0
- data/lib/facturx/schema/1.09.2/basic-wl/Factur-X_1.09.2_BASICWL_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd +53 -0
- data/lib/facturx/schema/1.09.2/basic-wl/ReusableAggregateBusinessInformationEntity_100.xsd +196 -0
- data/lib/facturx/schema/1.09.2/en16931/Factur-X_1.09.2_EN16931.xsd +20 -0
- data/lib/facturx/schema/1.09.2/en16931/Factur-X_1.09.2_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd +94 -0
- data/lib/facturx/schema/1.09.2/en16931/Factur-X_1.09.2_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd +84 -0
- data/lib/facturx/schema/1.09.2/en16931/ReusableAggregateBusinessInformationEntity_100.xsd +318 -0
- data/lib/facturx/schema/1.09.2/extended/Factur-X_1.09.2_EXTENDED.xsd +20 -0
- data/lib/facturx/schema/1.09.2/extended/Factur-X_1.09.2_EXTENDED_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd +144 -0
- data/lib/facturx/schema/1.09.2/extended/Factur-X_1.09.2_EXTENDED_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd +101 -0
- data/lib/facturx/schema/1.09.2/extended/ReusableAggregateBusinessInformationEntity_100.xsd +490 -0
- data/lib/facturx/schema/1.09.2/minimum/Factur-X_1.09.2_MINIMUM.xsd +20 -0
- data/lib/facturx/schema/1.09.2/minimum/Factur-X_1.09.2_MINIMUM_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd +30 -0
- data/lib/facturx/schema/1.09.2/minimum/Factur-X_1.09.2_MINIMUM_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd +38 -0
- data/lib/facturx/schema/1.09.2/minimum/ReusableAggregateBusinessInformationEntity_100.xsd +86 -0
- data/lib/facturx/schema/LICENSE-APACHE-2.0.txt +201 -0
- data/lib/facturx/schema/NOTICE.md +40 -0
- data/lib/facturx/schema/SHA256SUMS +20 -0
- data/lib/facturx/version.rb +5 -0
- data/lib/facturx/xml/parser.rb +23 -0
- data/lib/facturx/xml/profile_detector.rb +26 -0
- data/lib/facturx/xml/schema_validator.rb +64 -0
- data/lib/facturx/xml/verifier.rb +24 -0
- data/lib/facturx.rb +34 -0
- metadata +130 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :details
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, **details)
|
|
8
|
+
@details = details.freeze
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class InvalidXmlError < Error; end
|
|
14
|
+
class UnknownProfileError < InvalidXmlError; end
|
|
15
|
+
class XsdValidationError < InvalidXmlError; end
|
|
16
|
+
class InvalidPdfError < Error; end
|
|
17
|
+
class ProtectedPdfError < InvalidPdfError; end
|
|
18
|
+
class ComposerUnavailableError < Error; end
|
|
19
|
+
class CompositionError < Error; end
|
|
20
|
+
class ExtractionError < Error; end
|
|
21
|
+
class VerificationError < Error; end
|
|
22
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pdf/reader'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
require_relative '../error'
|
|
7
|
+
|
|
8
|
+
module Facturx
|
|
9
|
+
module Pdf
|
|
10
|
+
class Document
|
|
11
|
+
attr_reader :catalog, :reader
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def open(pdf, reject_signed: false)
|
|
15
|
+
parse(pdf, reject_signed:)
|
|
16
|
+
rescue InvalidPdfError
|
|
17
|
+
raise
|
|
18
|
+
rescue PDF::Reader::MalformedPDFError, PDF::Reader::UnsupportedFeatureError,
|
|
19
|
+
ArgumentError, IOError => e
|
|
20
|
+
raise invalid_error(e)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse(pdf, reject_signed:)
|
|
26
|
+
raise InvalidPdfError.new('PDF must be a byte string', reason: :invalid_input) unless pdf.is_a?(String)
|
|
27
|
+
|
|
28
|
+
reader = build_reader(pdf)
|
|
29
|
+
raise encrypted_error if reader.objects.encrypted?
|
|
30
|
+
|
|
31
|
+
document = new(reader)
|
|
32
|
+
document.validate!
|
|
33
|
+
raise signed_error if reject_signed && document.signed?
|
|
34
|
+
|
|
35
|
+
document
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_reader(pdf)
|
|
39
|
+
PDF::Reader.new(StringIO.new(pdf.b))
|
|
40
|
+
rescue PDF::Reader::EncryptedPDFError
|
|
41
|
+
raise encrypted_error
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encrypted_error
|
|
45
|
+
ProtectedPdfError.new('Encrypted PDFs are not supported', protection: :encryption)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def signed_error
|
|
49
|
+
ProtectedPdfError.new('Signed PDFs are not supported', protection: :signature)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def invalid_error(error)
|
|
53
|
+
InvalidPdfError.new('Invalid PDF', reason: :malformed, cause: error.class.name)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(reader)
|
|
58
|
+
@reader = reader
|
|
59
|
+
@catalog = reader.objects.deref_hash(reader.objects.trailer[:Root])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def objects
|
|
63
|
+
reader.objects
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def page_count
|
|
67
|
+
reader.page_count
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def metadata
|
|
71
|
+
stream = objects.deref_stream(catalog[:Metadata])
|
|
72
|
+
return unless stream
|
|
73
|
+
|
|
74
|
+
stream.unfiltered_data.dup.force_encoding(Encoding::BINARY)
|
|
75
|
+
rescue PDF::Reader::MalformedPDFError, PDF::Reader::UnsupportedFeatureError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate!
|
|
80
|
+
raise PDF::Reader::MalformedPDFError, 'PDF catalog is missing' unless catalog
|
|
81
|
+
|
|
82
|
+
reader.page_count
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def signed?
|
|
86
|
+
return true if catalog.key?(:Perms)
|
|
87
|
+
|
|
88
|
+
objects.any? do |_reference, object|
|
|
89
|
+
dictionary = object.is_a?(PDF::Reader::Stream) ? object.hash : object
|
|
90
|
+
signature_dictionary?(dictionary)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def signature_dictionary?(dictionary)
|
|
97
|
+
return false unless dictionary.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
dictionary[:Type] == :Sig || (dictionary.key?(:ByteRange) && dictionary.key?(:Contents))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'document'
|
|
4
|
+
require_relative 'name_tree'
|
|
5
|
+
require_relative '../embedding'
|
|
6
|
+
|
|
7
|
+
module Facturx
|
|
8
|
+
module Pdf
|
|
9
|
+
class Extractor
|
|
10
|
+
Result = Data.define(:xml, :filename, :relationship, :metadata, :page_count)
|
|
11
|
+
Candidate = Data.define(:file_specification, :names)
|
|
12
|
+
|
|
13
|
+
def call(pdf)
|
|
14
|
+
document = Document.open(pdf)
|
|
15
|
+
build_result(document, select_candidate(document))
|
|
16
|
+
rescue InvalidPdfError, ExtractionError
|
|
17
|
+
raise
|
|
18
|
+
rescue PDF::Reader::MalformedPDFError => e
|
|
19
|
+
raise InvalidPdfError.new('Invalid PDF', reason: :malformed, cause: e.class.name)
|
|
20
|
+
rescue PDF::Reader::UnsupportedFeatureError => e
|
|
21
|
+
raise ExtractionError.new('Unable to decode the Factur-X attachment', reason: :unsupported_stream,
|
|
22
|
+
cause: e.class.name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_result(document, candidate)
|
|
28
|
+
file_specification = candidate.file_specification
|
|
29
|
+
validate_names!(candidate.names)
|
|
30
|
+
validate_relationship!(file_specification)
|
|
31
|
+
|
|
32
|
+
Result.new(xml: embedded_xml(document, file_specification),
|
|
33
|
+
filename: FACTURX_EMBEDDING.filename,
|
|
34
|
+
relationship: FACTURX_EMBEDDING.relationship,
|
|
35
|
+
metadata: document.metadata,
|
|
36
|
+
page_count: document.page_count)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def select_candidate(document)
|
|
40
|
+
matches = matching_candidates(associated_file_candidates(document))
|
|
41
|
+
matches = matching_candidates(name_tree_candidates(document)) if matches.empty?
|
|
42
|
+
|
|
43
|
+
raise ExtractionError.new('Factur-X XML attachment is missing', reason: :missing_attachment) if matches.empty?
|
|
44
|
+
if matches.length > 1
|
|
45
|
+
raise ExtractionError.new('Factur-X XML attachment is ambiguous', reason: :ambiguous_attachment,
|
|
46
|
+
count: matches.length)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
matches.first
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def matching_candidates(candidates)
|
|
53
|
+
candidates.select { |candidate| candidate.names.include?(FACTURX_EMBEDDING.filename) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def associated_file_candidates(document)
|
|
57
|
+
references = document.objects.deref_array(document.catalog[:AF]) || []
|
|
58
|
+
references.filter_map { |reference| candidate_from(document, reference) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def name_tree_candidates(document)
|
|
62
|
+
NameTree.new(document).call.filter_map do |tree_name, file_specification|
|
|
63
|
+
candidate_from(document, file_specification, tree_name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def candidate_from(document, reference, tree_name = nil)
|
|
68
|
+
file_specification = document.objects.deref_hash(reference)
|
|
69
|
+
return unless file_specification
|
|
70
|
+
|
|
71
|
+
names = [tree_name, file_specification[:F], file_specification[:UF]]
|
|
72
|
+
.compact
|
|
73
|
+
.map { |name| normalize_name(document, name) }
|
|
74
|
+
Candidate.new(file_specification:, names:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_name(document, name)
|
|
78
|
+
value = document.objects.deref(name)
|
|
79
|
+
return value unless value.is_a?(String)
|
|
80
|
+
return value unless value.byteslice(0, 2) == "\xFE\xFF".b
|
|
81
|
+
|
|
82
|
+
value.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
|
|
83
|
+
rescue EncodingError
|
|
84
|
+
raise ExtractionError.new('Factur-X attachment has an invalid filename encoding',
|
|
85
|
+
reason: :invalid_filename_encoding)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_names!(names)
|
|
89
|
+
return if names.all?(FACTURX_EMBEDDING.filename)
|
|
90
|
+
|
|
91
|
+
raise ExtractionError.new('Factur-X attachment has an invalid filename', reason: :invalid_filename,
|
|
92
|
+
filenames: names)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_relationship!(file_specification)
|
|
96
|
+
return if file_specification[:AFRelationship] == FACTURX_EMBEDDING.relationship
|
|
97
|
+
|
|
98
|
+
raise ExtractionError.new('Factur-X attachment must use the Alternative relationship',
|
|
99
|
+
reason: :invalid_relationship,
|
|
100
|
+
relationship: file_specification[:AFRelationship])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def embedded_xml(document, file_specification)
|
|
104
|
+
embedded_files = document.objects.deref_hash(file_specification[:EF])
|
|
105
|
+
stream_reference = embedded_files && (embedded_files[:UF] || embedded_files[:F])
|
|
106
|
+
stream = document.objects.deref(stream_reference)
|
|
107
|
+
unless stream.is_a?(PDF::Reader::Stream)
|
|
108
|
+
raise ExtractionError.new('Factur-X attachment stream is missing', reason: :missing_stream)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
stream.unfiltered_data.dup.force_encoding(Encoding::BINARY)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'document'
|
|
4
|
+
|
|
5
|
+
module Facturx
|
|
6
|
+
module Pdf
|
|
7
|
+
class Inspector
|
|
8
|
+
Result = Data.define(:page_count)
|
|
9
|
+
|
|
10
|
+
def call(pdf)
|
|
11
|
+
document = Document.open(pdf, reject_signed: true)
|
|
12
|
+
|
|
13
|
+
Result.new(page_count: document.page_count)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
module Pdf
|
|
5
|
+
class NameTree
|
|
6
|
+
def initialize(document)
|
|
7
|
+
@document = document
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
names = objects.deref_hash(document.catalog[:Names])
|
|
12
|
+
root = names && names[:EmbeddedFiles]
|
|
13
|
+
root ? collect(root, {}) : []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :document
|
|
19
|
+
|
|
20
|
+
def objects
|
|
21
|
+
document.objects
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def collect(reference, visited)
|
|
25
|
+
key = reference_key(reference)
|
|
26
|
+
visit!(visited, key)
|
|
27
|
+
node = objects.deref_hash(reference) || invalid_tree!
|
|
28
|
+
|
|
29
|
+
entries(node[:Names]) + children(node).flat_map { |child| collect(child, visited) }
|
|
30
|
+
ensure
|
|
31
|
+
visited.delete(key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def entries(reference)
|
|
35
|
+
return [] unless reference
|
|
36
|
+
|
|
37
|
+
names = objects.deref_array(reference)
|
|
38
|
+
invalid_tree! unless names&.length&.even?
|
|
39
|
+
names.each_slice(2).map { |tree_name, file_specification| [tree_name, file_specification] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def children(node)
|
|
43
|
+
objects.deref_array(node[:Kids]) || []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def visit!(visited, key)
|
|
47
|
+
invalid_tree!('Embedded files name tree contains a cycle') if visited.key?(key)
|
|
48
|
+
visited[key] = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reference_key(reference)
|
|
52
|
+
reference.is_a?(PDF::Reader::Reference) ? [reference.id, reference.gen] : reference.object_id
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def invalid_tree!(message = 'Embedded files name tree is invalid')
|
|
56
|
+
raise ExtractionError.new(message, reason: :invalid_name_tree)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
require_relative '../embedding'
|
|
7
|
+
require_relative '../error'
|
|
8
|
+
|
|
9
|
+
module Facturx
|
|
10
|
+
module Pdf
|
|
11
|
+
class Verifier
|
|
12
|
+
PDFA_NAMESPACE = 'http://www.aiim.org/pdfa/ns/id/'
|
|
13
|
+
RDF_NAMESPACE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
|
|
14
|
+
XMP_META_NAMESPACE = 'adobe:ns:meta/'
|
|
15
|
+
XMP_NAMESPACES = {
|
|
16
|
+
'fx' => FACTURX_EMBEDDING.xmp_namespace,
|
|
17
|
+
'pdfaid' => PDFA_NAMESPACE,
|
|
18
|
+
'rdf' => RDF_NAMESPACE,
|
|
19
|
+
'x' => XMP_META_NAMESPACE
|
|
20
|
+
}.freeze
|
|
21
|
+
DESCRIPTION_XPATH = '/x:xmpmeta/rdf:RDF/rdf:Description'
|
|
22
|
+
XMPField = Data.define(:xpath, :expected)
|
|
23
|
+
XMP_FIELDS = {
|
|
24
|
+
pdfa_part: XMPField.new(
|
|
25
|
+
xpath: "#{DESCRIPTION_XPATH}/pdfaid:part | " \
|
|
26
|
+
"#{DESCRIPTION_XPATH}/@pdfaid:part",
|
|
27
|
+
expected: '3'
|
|
28
|
+
),
|
|
29
|
+
pdfa_conformance: XMPField.new(
|
|
30
|
+
xpath: "#{DESCRIPTION_XPATH}/pdfaid:conformance | " \
|
|
31
|
+
"#{DESCRIPTION_XPATH}/@pdfaid:conformance",
|
|
32
|
+
expected: 'B'
|
|
33
|
+
),
|
|
34
|
+
document_filename: XMPField.new(
|
|
35
|
+
xpath: "#{DESCRIPTION_XPATH}/fx:DocumentFileName", expected: FACTURX_EMBEDDING.filename
|
|
36
|
+
),
|
|
37
|
+
document_type: XMPField.new(
|
|
38
|
+
xpath: "#{DESCRIPTION_XPATH}/fx:DocumentType", expected: FACTURX_EMBEDDING.document_type
|
|
39
|
+
),
|
|
40
|
+
facturx_version: XMPField.new(
|
|
41
|
+
xpath: "#{DESCRIPTION_XPATH}/fx:Version", expected: FACTURX_EMBEDDING.version
|
|
42
|
+
)
|
|
43
|
+
}.freeze
|
|
44
|
+
CONFORMANCE_LEVEL_XPATH = "#{DESCRIPTION_XPATH}/fx:ConformanceLevel".freeze
|
|
45
|
+
private_constant :XMPField
|
|
46
|
+
|
|
47
|
+
def call(result:, expected_xml:, expected_page_count:, profile:)
|
|
48
|
+
verify_result!(result, expected_xml, expected_page_count)
|
|
49
|
+
xmp = parse_xmp(result.metadata)
|
|
50
|
+
verify_xmp!(xmp, profile)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def verify_xmp!(xmp, profile)
|
|
56
|
+
XMP_FIELDS.each do |field, definition|
|
|
57
|
+
verify_xmp_value!(xmp, field, definition.xpath, definition.expected)
|
|
58
|
+
end
|
|
59
|
+
verify_xmp_value!(xmp, :facturx_conformance_level, CONFORMANCE_LEVEL_XPATH, profile.conformance_level)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verify_result!(result, expected_xml, expected_page_count)
|
|
63
|
+
verify_xml!(result.xml, expected_xml)
|
|
64
|
+
verify_value!(:filename, result.filename, FACTURX_EMBEDDING.filename)
|
|
65
|
+
verify_value!(:relationship, result.relationship, FACTURX_EMBEDDING.relationship)
|
|
66
|
+
verify_value!(:page_count, result.page_count, expected_page_count)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def verify_xml!(actual, expected)
|
|
70
|
+
unless actual.is_a?(String) && expected.is_a?(String)
|
|
71
|
+
raise VerificationError.new('PDF verification input is invalid', reason: :invalid_input, field: :xml)
|
|
72
|
+
end
|
|
73
|
+
return if actual.b == expected.b
|
|
74
|
+
|
|
75
|
+
raise VerificationError.new('Composed PDF does not contain the requested XML bytes',
|
|
76
|
+
reason: :result_mismatch, field: :xml, **xml_details(actual, expected))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def xml_details(actual, expected)
|
|
80
|
+
{
|
|
81
|
+
expected_sha256: Digest::SHA256.hexdigest(expected),
|
|
82
|
+
actual_sha256: Digest::SHA256.hexdigest(actual),
|
|
83
|
+
expected_size: expected.bytesize,
|
|
84
|
+
actual_size: actual.bytesize
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_xmp(metadata)
|
|
89
|
+
raise VerificationError.new('PDF XMP metadata is missing', reason: :missing_xmp) unless metadata.is_a?(String)
|
|
90
|
+
|
|
91
|
+
Nokogiri::XML(metadata) { |config| config.strict.nonet }
|
|
92
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
93
|
+
raise VerificationError.new('PDF XMP metadata is invalid', reason: :invalid_xmp, cause: e.class.name)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def verify_xmp_value!(xmp, field, xpath, expected)
|
|
97
|
+
values = xmp.xpath(xpath, XMP_NAMESPACES).map(&:text)
|
|
98
|
+
return if values.one? && values.first == expected
|
|
99
|
+
|
|
100
|
+
raise VerificationError.new('PDF XMP metadata does not match Factur-X requirements',
|
|
101
|
+
reason: :xmp_mismatch, field:, expected:, actual: xmp_value(values))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def xmp_value(values)
|
|
105
|
+
values.one? ? values.first : values
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def verify_value!(field, actual, expected)
|
|
109
|
+
return if actual == expected
|
|
110
|
+
|
|
111
|
+
raise VerificationError.new('Composed PDF does not match the requested Factur-X document',
|
|
112
|
+
reason: :result_mismatch, field:, expected:, actual:)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'profile'
|
|
4
|
+
|
|
5
|
+
module Facturx
|
|
6
|
+
module Profiles
|
|
7
|
+
SPECIFICATION_VERSION = '1.09.2'
|
|
8
|
+
SCHEMA_ROOT = File.expand_path("schema/#{SPECIFICATION_VERSION}", __dir__).freeze
|
|
9
|
+
|
|
10
|
+
ALL = [
|
|
11
|
+
Profile.new(
|
|
12
|
+
id: :minimum,
|
|
13
|
+
guideline_urn: 'urn:factur-x.eu:1p0:minimum',
|
|
14
|
+
xsd_path: File.join(SCHEMA_ROOT, 'minimum/Factur-X_1.09.2_MINIMUM.xsd').freeze,
|
|
15
|
+
conformance_level: 'MINIMUM'
|
|
16
|
+
),
|
|
17
|
+
Profile.new(
|
|
18
|
+
id: :basic_wl,
|
|
19
|
+
guideline_urn: 'urn:factur-x.eu:1p0:basicwl',
|
|
20
|
+
xsd_path: File.join(SCHEMA_ROOT, 'basic-wl/Factur-X_1.09.2_BASICWL.xsd').freeze,
|
|
21
|
+
conformance_level: 'BASIC WL'
|
|
22
|
+
),
|
|
23
|
+
Profile.new(
|
|
24
|
+
id: :basic,
|
|
25
|
+
guideline_urn: 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic',
|
|
26
|
+
xsd_path: File.join(SCHEMA_ROOT, 'basic/Factur-X_1.09.2_BASIC.xsd').freeze,
|
|
27
|
+
conformance_level: 'BASIC'
|
|
28
|
+
),
|
|
29
|
+
Profile.new(
|
|
30
|
+
id: :en16931,
|
|
31
|
+
guideline_urn: 'urn:cen.eu:en16931:2017',
|
|
32
|
+
xsd_path: File.join(SCHEMA_ROOT, 'en16931/Factur-X_1.09.2_EN16931.xsd').freeze,
|
|
33
|
+
conformance_level: 'EN 16931'
|
|
34
|
+
),
|
|
35
|
+
Profile.new(
|
|
36
|
+
id: :extended,
|
|
37
|
+
guideline_urn: 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended',
|
|
38
|
+
xsd_path: File.join(SCHEMA_ROOT, 'extended/Factur-X_1.09.2_EXTENDED.xsd').freeze,
|
|
39
|
+
conformance_level: 'EXTENDED'
|
|
40
|
+
)
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
BY_ID = ALL.to_h { |profile| [profile.id, profile] }.freeze
|
|
44
|
+
BY_GUIDELINE_URN = ALL.to_h { |profile| [profile.guideline_urn, profile] }.freeze
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def all
|
|
49
|
+
ALL
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch(id)
|
|
53
|
+
BY_ID.fetch(id.to_sym)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def for_guideline_urn(guideline_urn)
|
|
57
|
+
BY_GUIDELINE_URN[guideline_urn]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<xs:schema xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
3
|
+
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
4
|
+
xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
5
|
+
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
|
6
|
+
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
7
|
+
targetNamespace="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
8
|
+
elementFormDefault="qualified">
|
|
9
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09.2_BASIC_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd"/>
|
|
10
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" schemaLocation="ReusableAggregateBusinessInformationEntity_100.xsd"/>
|
|
11
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09.2_BASIC_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd"/>
|
|
12
|
+
<xs:element name="CrossIndustryInvoice" type="rsm:CrossIndustryInvoiceType"/>
|
|
13
|
+
<xs:complexType name="CrossIndustryInvoiceType">
|
|
14
|
+
<xs:sequence>
|
|
15
|
+
<xs:element name="ExchangedDocumentContext" type="ram:ExchangedDocumentContextType"/>
|
|
16
|
+
<xs:element name="ExchangedDocument" type="ram:ExchangedDocumentType"/>
|
|
17
|
+
<xs:element name="SupplyChainTradeTransaction" type="ram:SupplyChainTradeTransactionType"/>
|
|
18
|
+
</xs:sequence>
|
|
19
|
+
</xs:complexType>
|
|
20
|
+
</xs:schema>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<xs:schema xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
3
|
+
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
4
|
+
targetNamespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
5
|
+
elementFormDefault="qualified">
|
|
6
|
+
<xs:simpleType name="AllowanceChargeReasonCodeContentType">
|
|
7
|
+
<xs:restriction base="xs:token"/>
|
|
8
|
+
</xs:simpleType>
|
|
9
|
+
<xs:complexType name="AllowanceChargeReasonCodeType">
|
|
10
|
+
<xs:simpleContent>
|
|
11
|
+
<xs:extension base="qdt:AllowanceChargeReasonCodeContentType"/>
|
|
12
|
+
</xs:simpleContent>
|
|
13
|
+
</xs:complexType>
|
|
14
|
+
<xs:simpleType name="CountryIDContentType">
|
|
15
|
+
<xs:restriction base="xs:token"/>
|
|
16
|
+
</xs:simpleType>
|
|
17
|
+
<xs:complexType name="CountryIDType">
|
|
18
|
+
<xs:simpleContent>
|
|
19
|
+
<xs:extension base="qdt:CountryIDContentType"/>
|
|
20
|
+
</xs:simpleContent>
|
|
21
|
+
</xs:complexType>
|
|
22
|
+
<xs:simpleType name="CurrencyCodeContentType">
|
|
23
|
+
<xs:restriction base="xs:token"/>
|
|
24
|
+
</xs:simpleType>
|
|
25
|
+
<xs:complexType name="CurrencyCodeType">
|
|
26
|
+
<xs:simpleContent>
|
|
27
|
+
<xs:extension base="qdt:CurrencyCodeContentType"/>
|
|
28
|
+
</xs:simpleContent>
|
|
29
|
+
</xs:complexType>
|
|
30
|
+
<xs:simpleType name="DocumentCodeContentType">
|
|
31
|
+
<xs:restriction base="xs:token"/>
|
|
32
|
+
</xs:simpleType>
|
|
33
|
+
<xs:complexType name="DocumentCodeType">
|
|
34
|
+
<xs:simpleContent>
|
|
35
|
+
<xs:extension base="qdt:DocumentCodeContentType"/>
|
|
36
|
+
</xs:simpleContent>
|
|
37
|
+
</xs:complexType>
|
|
38
|
+
<xs:simpleType name="FormattedDateTimeFormatContentType">
|
|
39
|
+
<xs:restriction base="xs:string"/>
|
|
40
|
+
</xs:simpleType>
|
|
41
|
+
<xs:complexType name="FormattedDateTimeType">
|
|
42
|
+
<xs:sequence>
|
|
43
|
+
<xs:element name="DateTimeString">
|
|
44
|
+
<xs:complexType>
|
|
45
|
+
<xs:simpleContent>
|
|
46
|
+
<xs:extension base="xs:string">
|
|
47
|
+
<xs:attribute name="format" type="qdt:FormattedDateTimeFormatContentType" use="required"/>
|
|
48
|
+
</xs:extension>
|
|
49
|
+
</xs:simpleContent>
|
|
50
|
+
</xs:complexType>
|
|
51
|
+
</xs:element>
|
|
52
|
+
</xs:sequence>
|
|
53
|
+
</xs:complexType>
|
|
54
|
+
<xs:simpleType name="PaymentMeansCodeContentType">
|
|
55
|
+
<xs:restriction base="xs:token"/>
|
|
56
|
+
</xs:simpleType>
|
|
57
|
+
<xs:complexType name="PaymentMeansCodeType">
|
|
58
|
+
<xs:simpleContent>
|
|
59
|
+
<xs:extension base="qdt:PaymentMeansCodeContentType"/>
|
|
60
|
+
</xs:simpleContent>
|
|
61
|
+
</xs:complexType>
|
|
62
|
+
<xs:simpleType name="TaxCategoryCodeContentType">
|
|
63
|
+
<xs:restriction base="xs:token"/>
|
|
64
|
+
</xs:simpleType>
|
|
65
|
+
<xs:complexType name="TaxCategoryCodeType">
|
|
66
|
+
<xs:simpleContent>
|
|
67
|
+
<xs:extension base="qdt:TaxCategoryCodeContentType"/>
|
|
68
|
+
</xs:simpleContent>
|
|
69
|
+
</xs:complexType>
|
|
70
|
+
<xs:simpleType name="TaxTypeCodeContentType">
|
|
71
|
+
<xs:restriction base="xs:token"/>
|
|
72
|
+
</xs:simpleType>
|
|
73
|
+
<xs:complexType name="TaxTypeCodeType">
|
|
74
|
+
<xs:simpleContent>
|
|
75
|
+
<xs:extension base="qdt:TaxTypeCodeContentType"/>
|
|
76
|
+
</xs:simpleContent>
|
|
77
|
+
</xs:complexType>
|
|
78
|
+
<xs:simpleType name="TimeReferenceCodeContentType">
|
|
79
|
+
<xs:restriction base="xs:token"/>
|
|
80
|
+
</xs:simpleType>
|
|
81
|
+
<xs:complexType name="TimeReferenceCodeType">
|
|
82
|
+
<xs:simpleContent>
|
|
83
|
+
<xs:extension base="qdt:TimeReferenceCodeContentType"/>
|
|
84
|
+
</xs:simpleContent>
|
|
85
|
+
</xs:complexType>
|
|
86
|
+
</xs:schema>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<xs:schema xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
3
|
+
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
4
|
+
targetNamespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
5
|
+
elementFormDefault="qualified">
|
|
6
|
+
<xs:complexType name="AmountType">
|
|
7
|
+
<xs:simpleContent>
|
|
8
|
+
<xs:extension base="xs:decimal">
|
|
9
|
+
<xs:attribute name="currencyID" type="xs:token"/>
|
|
10
|
+
</xs:extension>
|
|
11
|
+
</xs:simpleContent>
|
|
12
|
+
</xs:complexType>
|
|
13
|
+
<xs:complexType name="CodeType">
|
|
14
|
+
<xs:simpleContent>
|
|
15
|
+
<xs:extension base="xs:token"/>
|
|
16
|
+
</xs:simpleContent>
|
|
17
|
+
</xs:complexType>
|
|
18
|
+
<xs:complexType name="DateTimeType">
|
|
19
|
+
<xs:choice>
|
|
20
|
+
<xs:element name="DateTimeString">
|
|
21
|
+
<xs:complexType>
|
|
22
|
+
<xs:simpleContent>
|
|
23
|
+
<xs:extension base="xs:string">
|
|
24
|
+
<xs:attribute name="format" type="xs:string" use="required"/>
|
|
25
|
+
</xs:extension>
|
|
26
|
+
</xs:simpleContent>
|
|
27
|
+
</xs:complexType>
|
|
28
|
+
</xs:element>
|
|
29
|
+
</xs:choice>
|
|
30
|
+
</xs:complexType>
|
|
31
|
+
<xs:complexType name="IDType">
|
|
32
|
+
<xs:simpleContent>
|
|
33
|
+
<xs:extension base="xs:token">
|
|
34
|
+
<xs:attribute name="schemeID" type="xs:token"/>
|
|
35
|
+
</xs:extension>
|
|
36
|
+
</xs:simpleContent>
|
|
37
|
+
</xs:complexType>
|
|
38
|
+
<xs:complexType name="IndicatorType">
|
|
39
|
+
<xs:choice>
|
|
40
|
+
<xs:element name="Indicator" type="xs:boolean"/>
|
|
41
|
+
</xs:choice>
|
|
42
|
+
</xs:complexType>
|
|
43
|
+
<xs:complexType name="PercentType">
|
|
44
|
+
<xs:simpleContent>
|
|
45
|
+
<xs:extension base="xs:decimal"/>
|
|
46
|
+
</xs:simpleContent>
|
|
47
|
+
</xs:complexType>
|
|
48
|
+
<xs:complexType name="QuantityType">
|
|
49
|
+
<xs:simpleContent>
|
|
50
|
+
<xs:extension base="xs:decimal">
|
|
51
|
+
<xs:attribute name="unitCode" type="xs:token"/>
|
|
52
|
+
</xs:extension>
|
|
53
|
+
</xs:simpleContent>
|
|
54
|
+
</xs:complexType>
|
|
55
|
+
<xs:complexType name="TextType">
|
|
56
|
+
<xs:simpleContent>
|
|
57
|
+
<xs:extension base="xs:string"/>
|
|
58
|
+
</xs:simpleContent>
|
|
59
|
+
</xs:complexType>
|
|
60
|
+
</xs:schema>
|