zugpferd 0.2.1 → 0.3.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 +4 -4
- data/lib/zugpferd/cii/mapping.rb +4 -0
- data/lib/zugpferd/cii/reader.rb +2 -0
- data/lib/zugpferd/cii/writer.rb +13 -1
- data/lib/zugpferd/model/billing_document.rb +5 -4
- data/lib/zugpferd/pdf/embedder.rb +126 -0
- data/lib/zugpferd/pdf.rb +1 -0
- data/lib/zugpferd/ubl/mapping.rb +4 -0
- data/lib/zugpferd/ubl/reader.rb +3 -0
- data/lib/zugpferd/ubl/writer.rb +7 -0
- data/lib/zugpferd/validation/mustang_validator.rb +41 -0
- data/lib/zugpferd/validation/pdf_validator.rb +93 -0
- data/lib/zugpferd/validation/schema_validator.rb +46 -0
- data/lib/zugpferd/validation/schematron_validator.rb +114 -0
- data/lib/zugpferd/validation.rb +2 -0
- data/vendor/zugferd/default_rgb.icc +0 -0
- data/vendor/zugferd/zugferd.ps +527 -0
- metadata +16 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 668190b295d1286b2f8efd2f2896054e9c102c48da549d70486ef13677d042ad
|
|
4
|
+
data.tar.gz: '0274834d75a8c12690e178b5e075a8bed9792180b627c19a1fd3313534d9914c'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74be6147cd6ef40e90a23548e0e15c0b65e7c5e402535c2575f1dc41926b66b981f7c1e555d297f006ae69988ff9470c4d8b2eb2e8a4c7c83aa8791cc8bde1f5
|
|
7
|
+
data.tar.gz: 2cb11d5ef99e1d3341006fd37d2fa8e9ba3703fbebc7c642577f269aaa9aae3ddf666c2ed892a6ca5e92df17945d1d2b916b9b542c16f283b909ec862ddd8cdd
|
data/lib/zugpferd/cii/mapping.rb
CHANGED
|
@@ -23,6 +23,10 @@ module Zugpferd
|
|
|
23
23
|
profile_id: "#{CONTEXT}/ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID",
|
|
24
24
|
}.freeze
|
|
25
25
|
|
|
26
|
+
# Delivery (BG-13)
|
|
27
|
+
DELIVERY = "#{TRANSACTION}/ram:ApplicableHeaderTradeDelivery"
|
|
28
|
+
DELIVERY_DATE = "ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString"
|
|
29
|
+
|
|
26
30
|
# Settlement (contains currency, payment, tax, totals)
|
|
27
31
|
SETTLEMENT = "#{TRANSACTION}/ram:ApplicableHeaderTradeSettlement"
|
|
28
32
|
AGREEMENT = "#{TRANSACTION}/ram:ApplicableHeaderTradeAgreement"
|
data/lib/zugpferd/cii/reader.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Zugpferd
|
|
|
35
35
|
|
|
36
36
|
def build_invoice(root)
|
|
37
37
|
settlement = root.at_xpath(SETTLEMENT, NS)
|
|
38
|
+
delivery = root.at_xpath(DELIVERY, NS)
|
|
38
39
|
type_code = text(root, INVOICE[:type_code])
|
|
39
40
|
model_class = TYPE_CODE_MAP.fetch(type_code, Model::Invoice)
|
|
40
41
|
|
|
@@ -42,6 +43,7 @@ module Zugpferd
|
|
|
42
43
|
number: text(root, INVOICE[:number]),
|
|
43
44
|
issue_date: parse_cii_date(text(root, INVOICE[:issue_date])),
|
|
44
45
|
due_date: parse_cii_date(settlement ? text(settlement, PAYMENT_TERMS_DUE_DATE) : nil),
|
|
46
|
+
delivery_date: parse_cii_date(delivery ? text(delivery, DELIVERY_DATE) : nil),
|
|
45
47
|
type_code: text(root, INVOICE[:type_code]),
|
|
46
48
|
currency_code: text(root, INVOICE_SETTLEMENT[:currency_code]),
|
|
47
49
|
buyer_reference: text(root, INVOICE_SETTLEMENT[:buyer_reference]),
|
data/lib/zugpferd/cii/writer.rb
CHANGED
|
@@ -66,7 +66,7 @@ module Zugpferd
|
|
|
66
66
|
xml["rsm"].SupplyChainTradeTransaction do
|
|
67
67
|
doc.line_items.each { |li| build_line_item(xml, li) }
|
|
68
68
|
build_agreement(xml, doc)
|
|
69
|
-
xml
|
|
69
|
+
build_delivery(xml, doc)
|
|
70
70
|
build_settlement(xml, doc)
|
|
71
71
|
end
|
|
72
72
|
end
|
|
@@ -141,6 +141,18 @@ module Zugpferd
|
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
+
def build_delivery(xml, doc)
|
|
145
|
+
xml["ram"].ApplicableHeaderTradeDelivery do
|
|
146
|
+
if doc.delivery_date
|
|
147
|
+
xml["ram"].ActualDeliverySupplyChainEvent do
|
|
148
|
+
xml["ram"].OccurrenceDateTime do
|
|
149
|
+
xml["udt"].DateTimeString(format_cii_date(doc.delivery_date), format: "102")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
144
156
|
def build_settlement(xml, doc)
|
|
145
157
|
xml["ram"].ApplicableHeaderTradeSettlement do
|
|
146
158
|
if doc.payment_instructions&.creditor_reference_id
|
|
@@ -18,6 +18,7 @@ module Zugpferd
|
|
|
18
18
|
# @return [Date, nil] BT-9 Payment due date
|
|
19
19
|
# @return [String] BT-3 Invoice type code
|
|
20
20
|
# @return [String] BT-5 Document currency code (default: "EUR")
|
|
21
|
+
# @return [Date, nil] BT-72 Actual delivery date
|
|
21
22
|
# @return [String, nil] BT-10 Buyer reference
|
|
22
23
|
# @return [String, nil] BT-24 Specification identifier
|
|
23
24
|
# @return [String, nil] BT-23 Business process type
|
|
@@ -30,10 +31,10 @@ module Zugpferd
|
|
|
30
31
|
# @return [PaymentInstructions, nil] BG-16 Payment information
|
|
31
32
|
# @return [Array<AllowanceCharge>] BG-20/BG-21 Document-level allowances and charges
|
|
32
33
|
attr_accessor :number, :issue_date, :due_date, :type_code,
|
|
33
|
-
:currency_code, :
|
|
34
|
-
:profile_id, :note, :seller, :buyer,
|
|
35
|
-
:
|
|
36
|
-
:allowance_charges
|
|
34
|
+
:currency_code, :delivery_date, :buyer_reference,
|
|
35
|
+
:customization_id, :profile_id, :note, :seller, :buyer,
|
|
36
|
+
:line_items, :tax_breakdown, :monetary_totals,
|
|
37
|
+
:payment_instructions, :allowance_charges
|
|
37
38
|
|
|
38
39
|
# @param number [String] BT-1 Invoice number
|
|
39
40
|
# @param issue_date [Date] BT-2 Issue date
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
|
|
4
|
+
module Zugpferd
|
|
5
|
+
module PDF
|
|
6
|
+
class Embedder
|
|
7
|
+
class GhostscriptNotFound < Zugpferd::Error; end
|
|
8
|
+
class EmbedError < Zugpferd::Error; end
|
|
9
|
+
|
|
10
|
+
VERSIONS = %w[rc 1p0 2p0 2p1].freeze
|
|
11
|
+
|
|
12
|
+
CONFORMANCE_LEVELS = {
|
|
13
|
+
"2p1" => ["MINIMUM", "BASIC WL", "BASIC", "EN 16931", "EXTENDED", "XRECHNUNG"],
|
|
14
|
+
"2p0" => ["MINIMUM", "BASIC WL", "BASIC", "EN 16931", "EXTENDED", "XRECHNUNG"],
|
|
15
|
+
"1p0" => ["BASIC", "COMFORT", "EXTENDED"],
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# @param pdf_path [String] Pfad zum Input-PDF
|
|
19
|
+
# @param xml [String] XML-String (UBL oder CII)
|
|
20
|
+
# @param output_path [String] Pfad zur Ausgabe-PDF
|
|
21
|
+
# @param version [String] "2p1" (default), "2p0", "1p0", "rc"
|
|
22
|
+
# @param conformance_level [String] "EN 16931" (default), "BASIC", etc.
|
|
23
|
+
# @return [String] Pfad zur erzeugten PDF-Datei
|
|
24
|
+
def embed(pdf_path:, xml:, output_path:, version: "2p1", conformance_level: "EN 16931")
|
|
25
|
+
validate_params!(pdf_path, version, conformance_level)
|
|
26
|
+
ensure_ghostscript!
|
|
27
|
+
|
|
28
|
+
xml_file = write_xml_tempfile(xml)
|
|
29
|
+
begin
|
|
30
|
+
run_ghostscript(pdf_path, xml_file.path, output_path, version, conformance_level)
|
|
31
|
+
ensure
|
|
32
|
+
xml_file.close!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
output_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_params!(pdf_path, version, conformance_level)
|
|
41
|
+
raise ArgumentError, "PDF file not found: #{pdf_path}" unless File.exist?(pdf_path)
|
|
42
|
+
raise ArgumentError, "Unknown version: #{version}. Valid: #{VERSIONS.join(", ")}" unless VERSIONS.include?(version)
|
|
43
|
+
|
|
44
|
+
if version != "rc"
|
|
45
|
+
valid_levels = CONFORMANCE_LEVELS[version]
|
|
46
|
+
unless valid_levels&.include?(conformance_level)
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"Unknown conformance level '#{conformance_level}' for version #{version}. " \
|
|
49
|
+
"Valid: #{valid_levels.join(", ")}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ensure_ghostscript!
|
|
55
|
+
_, _, status = Open3.capture3("gs", "--version")
|
|
56
|
+
raise GhostscriptNotFound, "Ghostscript (gs) not found in PATH" unless status.success?
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
raise GhostscriptNotFound, "Ghostscript (gs) not found in PATH"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def vendor_dir
|
|
62
|
+
File.expand_path("../../../vendor/zugferd", __dir__)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def zugferd_ps_path
|
|
66
|
+
File.join(vendor_dir, "zugferd.ps")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def icc_profile_path
|
|
70
|
+
File.join(vendor_dir, "default_rgb.icc")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_xml_tempfile(xml)
|
|
74
|
+
tmpfile = Tempfile.new(["zugferd", ".xml"])
|
|
75
|
+
tmpfile.binmode
|
|
76
|
+
tmpfile.write(xml)
|
|
77
|
+
tmpfile.flush
|
|
78
|
+
tmpfile
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run_ghostscript(pdf_path, xml_path, output_path, version, conformance_level)
|
|
82
|
+
ps_path = zugferd_ps_path
|
|
83
|
+
icc_path = icc_profile_path
|
|
84
|
+
|
|
85
|
+
unless File.exist?(ps_path)
|
|
86
|
+
raise EmbedError, "zugferd.ps not found at #{ps_path}. Run bin/setup-schemas."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless File.exist?(icc_path)
|
|
90
|
+
raise EmbedError, "ICC profile not found at #{icc_path}. Run bin/setup-schemas."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
cmd = build_command(pdf_path, xml_path, output_path, version, conformance_level, ps_path, icc_path)
|
|
94
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
95
|
+
|
|
96
|
+
unless status.success?
|
|
97
|
+
raise EmbedError, "Ghostscript failed (exit #{status.exitstatus}):\n#{stderr}#{stdout}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
unless File.exist?(output_path)
|
|
101
|
+
raise EmbedError, "Ghostscript did not produce output file: #{output_path}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_command(pdf_path, xml_path, output_path, version, conformance_level, ps_path, icc_path)
|
|
106
|
+
cmd = %w[gs]
|
|
107
|
+
cmd += %w[-dBATCH -dNOPAUSE -dNOOUTERSAVE]
|
|
108
|
+
cmd += %w[-sDEVICE=pdfwrite]
|
|
109
|
+
cmd += %w[-dPDFA=3 -dPDFACompatibilityPolicy=1]
|
|
110
|
+
cmd += %w[-sColorConversionStrategy=RGB -sProcessColorModel=DeviceRGB]
|
|
111
|
+
cmd << "--permit-file-read=#{xml_path}"
|
|
112
|
+
cmd << "--permit-file-read=#{icc_path}"
|
|
113
|
+
cmd << "--permit-file-read=#{pdf_path}"
|
|
114
|
+
cmd << "-sZUGFeRDXMLFile=#{xml_path}"
|
|
115
|
+
cmd << "-sZUGFeRDProfile=#{icc_path}"
|
|
116
|
+
cmd << "-sZUGFeRDVersion=#{version}"
|
|
117
|
+
cmd << "-sZUGFeRDConformanceLevel=#{conformance_level}"
|
|
118
|
+
cmd << "-o"
|
|
119
|
+
cmd << output_path
|
|
120
|
+
cmd << ps_path
|
|
121
|
+
cmd << pdf_path
|
|
122
|
+
cmd
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/zugpferd/pdf.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative "pdf/embedder"
|
data/lib/zugpferd/ubl/mapping.rb
CHANGED
data/lib/zugpferd/ubl/reader.rb
CHANGED
|
@@ -33,10 +33,13 @@ module Zugpferd
|
|
|
33
33
|
|
|
34
34
|
model_class = @credit_note ? Model::CreditNote : Model::Invoice
|
|
35
35
|
|
|
36
|
+
delivery_node = root.at_xpath(DELIVERY, @ns)
|
|
37
|
+
|
|
36
38
|
model_class.new(
|
|
37
39
|
number: text(root, INVOICE[:number]),
|
|
38
40
|
issue_date: parse_date(text(root, INVOICE[:issue_date])),
|
|
39
41
|
due_date: parse_date(text(root, INVOICE[:due_date])),
|
|
42
|
+
delivery_date: delivery_node ? parse_date(text(delivery_node, DELIVERY_DATE)) : nil,
|
|
40
43
|
type_code: text(root, type_code_element),
|
|
41
44
|
currency_code: text(root, INVOICE[:currency_code]),
|
|
42
45
|
buyer_reference: text(root, INVOICE[:buyer_reference]),
|
data/lib/zugpferd/ubl/writer.rb
CHANGED
|
@@ -53,6 +53,7 @@ module Zugpferd
|
|
|
53
53
|
|
|
54
54
|
build_supplier(xml, doc.seller, doc.payment_instructions) if doc.seller
|
|
55
55
|
build_customer(xml, doc.buyer) if doc.buyer
|
|
56
|
+
build_delivery(xml, doc) if doc.delivery_date
|
|
56
57
|
build_payment_means(xml, doc.payment_instructions) if doc.payment_instructions
|
|
57
58
|
build_payment_terms(xml, doc.payment_instructions) if doc.payment_instructions&.note
|
|
58
59
|
doc.allowance_charges.each { |ac| build_allowance_charge(xml, ac, doc.currency_code) }
|
|
@@ -140,6 +141,12 @@ module Zugpferd
|
|
|
140
141
|
end
|
|
141
142
|
end
|
|
142
143
|
|
|
144
|
+
def build_delivery(xml, doc)
|
|
145
|
+
xml["cac"].Delivery do
|
|
146
|
+
xml["cbc"].ActualDeliveryDate doc.delivery_date.to_s
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
143
150
|
def build_payment_means(xml, payment)
|
|
144
151
|
xml["cac"].PaymentMeans do
|
|
145
152
|
xml["cbc"].PaymentMeansCode payment.payment_means_code
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Zugpferd
|
|
4
|
+
module Validation
|
|
5
|
+
class MustangValidator
|
|
6
|
+
Result = Struct.new(:valid, :output, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
CONTAINER_NAME = "zugpferd-mustang"
|
|
9
|
+
IMAGE = "zugpferd-mustang:latest"
|
|
10
|
+
|
|
11
|
+
# @param pdf_path [String] Pfad zur PDF-Datei
|
|
12
|
+
# @return [Result]
|
|
13
|
+
def validate(pdf_path)
|
|
14
|
+
pdf_path = File.expand_path(pdf_path)
|
|
15
|
+
dir = File.dirname(pdf_path)
|
|
16
|
+
filename = File.basename(pdf_path)
|
|
17
|
+
|
|
18
|
+
cmd = [
|
|
19
|
+
"docker", "run", "--rm",
|
|
20
|
+
"-v", "#{dir}:/data:ro",
|
|
21
|
+
IMAGE,
|
|
22
|
+
"--action", "validate",
|
|
23
|
+
"--source", "/data/#{filename}",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
27
|
+
output = "#{stdout}#{stderr}".strip
|
|
28
|
+
|
|
29
|
+
Result.new(valid: status.success?, output: output)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] true wenn das Docker-Image vorhanden ist
|
|
33
|
+
def available?
|
|
34
|
+
_, _, status = Open3.capture3("docker", "image", "inspect", IMAGE)
|
|
35
|
+
status.success?
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Zugpferd
|
|
6
|
+
module Validation
|
|
7
|
+
class PdfValidator
|
|
8
|
+
Result = Struct.new(:compliant, :failures, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(host: "localhost", port: 8080)
|
|
11
|
+
@base_uri = URI("http://#{host}:#{port}")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param pdf_path [String] Pfad zur PDF-Datei
|
|
15
|
+
# @param profile [String] veraPDF-Profil ("3b", "3a", "3u")
|
|
16
|
+
# @return [Result]
|
|
17
|
+
def validate(pdf_path, profile: "3b")
|
|
18
|
+
uri = URI("#{@base_uri}/api/validate/#{profile}")
|
|
19
|
+
|
|
20
|
+
boundary = "----ZugpferdBoundary#{SecureRandom.hex(16)}"
|
|
21
|
+
body = build_multipart_body(pdf_path, boundary)
|
|
22
|
+
|
|
23
|
+
request = Net::HTTP::Post.new(uri)
|
|
24
|
+
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
25
|
+
request["Accept"] = "application/json"
|
|
26
|
+
request.body = body
|
|
27
|
+
|
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port) do |http|
|
|
29
|
+
http.read_timeout = 120
|
|
30
|
+
http.request(request)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
parse_response(response)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] true wenn veraPDF erreichbar ist
|
|
37
|
+
def available?
|
|
38
|
+
uri = URI("#{@base_uri}/api/info")
|
|
39
|
+
response = Net::HTTP.get_response(uri)
|
|
40
|
+
response.code == "200"
|
|
41
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError, Net::OpenTimeout
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_multipart_body(pdf_path, boundary)
|
|
48
|
+
filename = File.basename(pdf_path)
|
|
49
|
+
content = File.binread(pdf_path)
|
|
50
|
+
|
|
51
|
+
body = +""
|
|
52
|
+
body << "--#{boundary}\r\n"
|
|
53
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
|
|
54
|
+
body << "Content-Type: application/pdf\r\n"
|
|
55
|
+
body << "\r\n"
|
|
56
|
+
body << content
|
|
57
|
+
body << "\r\n"
|
|
58
|
+
body << "--#{boundary}--\r\n"
|
|
59
|
+
body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_response(response)
|
|
63
|
+
unless response.code == "200"
|
|
64
|
+
raise "veraPDF returned HTTP #{response.code}: #{response.body}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
data = JSON.parse(response.body)
|
|
68
|
+
job = data.dig("report", "jobs", 0) || data.dig("report", "batchSummary") || {}
|
|
69
|
+
validation = job["validationResult"] || job
|
|
70
|
+
|
|
71
|
+
compliant = validation["compliant"] == true
|
|
72
|
+
failures = extract_failures(validation)
|
|
73
|
+
|
|
74
|
+
Result.new(compliant: compliant, failures: failures)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_failures(validation)
|
|
78
|
+
details = validation["details"] || validation["rulesSummary"] || {}
|
|
79
|
+
rules = details["rules"] || []
|
|
80
|
+
|
|
81
|
+
rules.select { |r| r["status"] == "failed" }.map do |rule|
|
|
82
|
+
{
|
|
83
|
+
clause: rule["clause"],
|
|
84
|
+
test_number: rule["testNumber"],
|
|
85
|
+
description: rule["description"],
|
|
86
|
+
object: rule["object"],
|
|
87
|
+
checks: rule["checks"]&.select { |c| c["status"] == "failed" },
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
|
|
3
|
+
module Zugpferd
|
|
4
|
+
module Validation
|
|
5
|
+
# Validates XML against XSD schemas using Nokogiri.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# validator = SchemaValidator.new(schemas_path: "vendor/schemas")
|
|
9
|
+
# errors = validator.validate(xml, schema_key: :ubl_invoice)
|
|
10
|
+
class SchemaValidator
|
|
11
|
+
XSD_PATHS = {
|
|
12
|
+
ubl_invoice: "ubl/xsd/maindoc/UBL-Invoice-2.1.xsd",
|
|
13
|
+
ubl_credit_note: "ubl/xsd/maindoc/UBL-CreditNote-2.1.xsd",
|
|
14
|
+
cii: "cii/CrossIndustryInvoice_100pD16B.xsd",
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# @param schemas_path [String] path to the schemas directory
|
|
18
|
+
def initialize(schemas_path:)
|
|
19
|
+
@schemas_path = schemas_path
|
|
20
|
+
@schema_cache = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Validates XML against an XSD schema.
|
|
24
|
+
#
|
|
25
|
+
# @param xml_string [String] XML to validate
|
|
26
|
+
# @param schema_key [Symbol] one of +:ubl_invoice+, +:ubl_credit_note+, +:cii+
|
|
27
|
+
# @return [Array<String>] error messages (empty if valid)
|
|
28
|
+
def validate(xml_string, schema_key:)
|
|
29
|
+
doc = Nokogiri::XML(xml_string)
|
|
30
|
+
schema = load_schema(schema_key)
|
|
31
|
+
schema.validate(doc).map(&:message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def load_schema(key)
|
|
37
|
+
@schema_cache[key] ||= begin
|
|
38
|
+
path = File.join(@schemas_path, XSD_PATHS.fetch(key))
|
|
39
|
+
Dir.chdir(File.dirname(path)) do
|
|
40
|
+
Nokogiri::XML::Schema(File.read(File.basename(path)))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Zugpferd
|
|
6
|
+
module Validation
|
|
7
|
+
# Validates XML against Schematron business rules using Saxon HE.
|
|
8
|
+
#
|
|
9
|
+
# Supports CEN EN 16931 and XRechnung rule sets.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# validator = SchematronValidator.new(schemas_path: "vendor/schemas")
|
|
13
|
+
# errors = validator.validate(xml, rule_set: :xrechnung_ubl)
|
|
14
|
+
# fatals = errors.select { |e| e.flag == "fatal" }
|
|
15
|
+
class SchematronValidator
|
|
16
|
+
SVRL_NS = "http://purl.oclc.org/dsdl/svrl"
|
|
17
|
+
|
|
18
|
+
XSLT_PATHS = {
|
|
19
|
+
cen_ubl: "schematron/cen/ubl/xslt/EN16931-UBL-validation.xslt",
|
|
20
|
+
cen_cii: "schematron/cen/cii/xslt/EN16931-CII-validation.xslt",
|
|
21
|
+
xrechnung_ubl: "schematron/xrechnung/schematron/ubl/XRechnung-UBL-validation.xsl",
|
|
22
|
+
xrechnung_cii: "schematron/xrechnung/schematron/cii/XRechnung-CII-validation.xsl",
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
SAXON_JARS = [
|
|
26
|
+
"saxon/saxon-he-12.5.jar",
|
|
27
|
+
"saxon/xmlresolver-5.2.2.jar",
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
class TransformError < StandardError; end
|
|
31
|
+
|
|
32
|
+
Result = Struct.new(:id, :location, :text, :flag, keyword_init: true)
|
|
33
|
+
|
|
34
|
+
# @param schemas_path [String] path to the schemas directory
|
|
35
|
+
def initialize(schemas_path:)
|
|
36
|
+
@schemas_path = schemas_path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validates XML against a single Schematron rule set.
|
|
40
|
+
#
|
|
41
|
+
# @param xml_string [String] XML to validate
|
|
42
|
+
# @param rule_set [Symbol] one of +:cen_ubl+, +:cen_cii+, +:xrechnung_ubl+, +:xrechnung_cii+
|
|
43
|
+
# @return [Array<Result>] validation errors
|
|
44
|
+
# @raise [TransformError] if Saxon fails
|
|
45
|
+
def validate(xml_string, rule_set:)
|
|
46
|
+
xslt_path = resolve_xslt(rule_set)
|
|
47
|
+
svrl_xml = run_saxon(xml_string, xslt_path)
|
|
48
|
+
svrl_doc = Nokogiri::XML(svrl_xml)
|
|
49
|
+
parse_failed_asserts(svrl_doc)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Validates XML against multiple rule sets.
|
|
53
|
+
#
|
|
54
|
+
# @param xml_string [String] XML to validate
|
|
55
|
+
# @param rule_sets [Array<Symbol>] rule sets to apply
|
|
56
|
+
# @return [Array<Result>] merged validation errors
|
|
57
|
+
def validate_all(xml_string, rule_sets:)
|
|
58
|
+
rule_sets.flat_map { |rs| validate(xml_string, rule_set: rs) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def resolve_xslt(rule_set)
|
|
64
|
+
path = File.join(@schemas_path, XSLT_PATHS.fetch(rule_set))
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"XSLT not found: #{path} – run bin/setup-schemas" \
|
|
67
|
+
unless File.exist?(path)
|
|
68
|
+
path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def saxon_classpath
|
|
72
|
+
SAXON_JARS.map do |jar|
|
|
73
|
+
path = File.join(@schemas_path, jar)
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"#{jar} not found: #{path} – run bin/setup-schemas" \
|
|
76
|
+
unless File.exist?(path)
|
|
77
|
+
path
|
|
78
|
+
end.join(":")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run_saxon(xml_string, xslt_path)
|
|
82
|
+
input = Tempfile.new(["input", ".xml"])
|
|
83
|
+
input.write(xml_string)
|
|
84
|
+
input.close
|
|
85
|
+
|
|
86
|
+
stdout, stderr, status = Open3.capture3(
|
|
87
|
+
"java", "-cp", saxon_classpath,
|
|
88
|
+
"net.sf.saxon.Transform",
|
|
89
|
+
"-s:#{input.path}",
|
|
90
|
+
"-xsl:#{xslt_path}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
unless status.success?
|
|
94
|
+
raise TransformError, "Saxon transform failed: #{stderr}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
stdout
|
|
98
|
+
ensure
|
|
99
|
+
input&.unlink
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_failed_asserts(svrl_doc)
|
|
103
|
+
svrl_doc.xpath("//svrl:failed-assert", "svrl" => SVRL_NS).map do |node|
|
|
104
|
+
Result.new(
|
|
105
|
+
id: node["id"],
|
|
106
|
+
location: node["location"],
|
|
107
|
+
text: node.at_xpath("svrl:text", "svrl" => SVRL_NS)&.text&.strip,
|
|
108
|
+
flag: node["flag"]
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
%!PS
|
|
2
|
+
|
|
3
|
+
% Copyright (C) 2001-2024 Artifex Software, Inc.
|
|
4
|
+
% All Rights Reserved.
|
|
5
|
+
%
|
|
6
|
+
% This software is provided AS-IS with no warranty, either express or
|
|
7
|
+
% implied.
|
|
8
|
+
%
|
|
9
|
+
% This software is distributed under license and may not be copied,
|
|
10
|
+
% modified or distributed except as expressly authorized under the terms
|
|
11
|
+
% of the license contained in the file LICENSE in this distribution.
|
|
12
|
+
%
|
|
13
|
+
% Refer to licensing information at http://www.artifex.com or contact
|
|
14
|
+
% Artifex Software, Inc., 39 Mesa Street, Suite 108A, San Francisco,
|
|
15
|
+
% CA 94129, USA, for further information.
|
|
16
|
+
%
|
|
17
|
+
% ZUGFeRD.ps
|
|
18
|
+
% This program will create an (unsigned) ZUGFeRD compliant PDF file.
|
|
19
|
+
% In order to do so the user must provide certain information, or edit
|
|
20
|
+
% this program.
|
|
21
|
+
%
|
|
22
|
+
% Required information is the path to the XML file containing the invoice
|
|
23
|
+
% data, and the path to an ICC profile appropriate for the chosen
|
|
24
|
+
% ColorConversionStrategy.
|
|
25
|
+
%
|
|
26
|
+
% -sZUGFeRDXMLFile defines a path to the XML invoice file.
|
|
27
|
+
%
|
|
28
|
+
% -sZUGFeRDProfile defines the path to the ICC profile.
|
|
29
|
+
%
|
|
30
|
+
% -sZUGFeRDVersion defines the version of the ZUGFeRD standard to be used.
|
|
31
|
+
% Missing or invalid values would be silently replaced by the default ("2p1").
|
|
32
|
+
%
|
|
33
|
+
% -sZUGFeRDConformanceLevel defines the level of conformance.
|
|
34
|
+
% Missing or invalid values would be silently replaced by the default ("BASIC").
|
|
35
|
+
%
|
|
36
|
+
% Note that the ZUGFeRD standard states:
|
|
37
|
+
%
|
|
38
|
+
% The content of the field fx:ConformanceLevel has to be picked from
|
|
39
|
+
% the content of the element "GuidelineSpecifiedDocumentContextParameter"
|
|
40
|
+
% (specification identifier BT-24) of the XML instance file.
|
|
41
|
+
%
|
|
42
|
+
% Optionally:
|
|
43
|
+
% -sZUGFeRDDateTime can be used to set a string representing the modification
|
|
44
|
+
% date of the XML invoice file. If this is ommitted a dummy value will be
|
|
45
|
+
% used. It is up to the user to create a correctly formatted PDF date/time
|
|
46
|
+
% string. See section 7.9.4 of the PDF 2.0 specification (ISO-32000-2:2017)
|
|
47
|
+
% for details of the format.
|
|
48
|
+
%
|
|
49
|
+
% The user must additionally set -dPDFA=3 and -sColorConversionStrategy
|
|
50
|
+
% on the Ghostscript command line, and set the permissions for Ghostscript
|
|
51
|
+
% to read both these files. It is simplest to put the files in a directory
|
|
52
|
+
% and then permit reading of the entire directory.
|
|
53
|
+
%
|
|
54
|
+
% Example command line :
|
|
55
|
+
%
|
|
56
|
+
% gs --permit-file-read=/usr/home/me/zugferd/ \
|
|
57
|
+
% -sDEVICE=pdfwrite \
|
|
58
|
+
% -dPDFA=3 \
|
|
59
|
+
% -sColorConversionStrategy=RGB \
|
|
60
|
+
% -sZUGFeRDXMLFile=/usr/home/me/zugferd/invoice.xml \
|
|
61
|
+
% -sZUGFeRDProfile=/usr/home/me/zugferd/rgb.icc \
|
|
62
|
+
% -sZUGFeRDVersion=2p1 \
|
|
63
|
+
% -sZUGFeRDConformanceLevel=BASIC \
|
|
64
|
+
% -o /usr/home/me/zugferd/zugferd.pdf \
|
|
65
|
+
% /usr/home/me/zugferd/zugferd.ps \
|
|
66
|
+
% /usr/home/me/zugferd/original.pdf
|
|
67
|
+
%
|
|
68
|
+
% Much of this program results from a Ghostscript bug report, the thread
|
|
69
|
+
% can be found at
|
|
70
|
+
% https://bugs.ghostscript.com/show_bug.cgi?id=696472
|
|
71
|
+
% Portions of the code below were supplied by Reinhard Nissl and
|
|
72
|
+
% I'm indebted to him for his efforts in helping me create a solution for
|
|
73
|
+
% this problem as well as for the code he supplied, particularly for the
|
|
74
|
+
% SimpleUTF16BE routine.
|
|
75
|
+
%
|
|
76
|
+
% The program was further refined and expanded by Adrian Devries in :
|
|
77
|
+
% https://bugs.ghostscript.com/show_bug.cgi?id=703862
|
|
78
|
+
%
|
|
79
|
+
% And refined again following feedback from Thorsten Engel in:
|
|
80
|
+
% https://bugs.ghostscript.com/show_bug.cgi?id=707694
|
|
81
|
+
%
|
|
82
|
+
% It should not be necessary to modify this program, the comments in the
|
|
83
|
+
% code are there purely for information, but there is one area which
|
|
84
|
+
% might reasonably be altered. The section with the --8<-- lines could be
|
|
85
|
+
% replaced with a simpler /N 3 or /N 4 if you always intend to produce
|
|
86
|
+
% the same kind of files; RGB or CMYK.
|
|
87
|
+
%
|
|
88
|
+
% Remaining tasks have been marked with "TODO".
|
|
89
|
+
|
|
90
|
+
% istring SimpleUTF16BE ostring
|
|
91
|
+
/SimpleUTF16BE
|
|
92
|
+
{
|
|
93
|
+
dup length
|
|
94
|
+
1 add
|
|
95
|
+
2 mul
|
|
96
|
+
string
|
|
97
|
+
% istring ostring
|
|
98
|
+
dup 0 16#FE put
|
|
99
|
+
dup 1 16#FF put
|
|
100
|
+
2
|
|
101
|
+
3 -1 roll
|
|
102
|
+
% ostring index istring
|
|
103
|
+
{
|
|
104
|
+
% ostring index ichar
|
|
105
|
+
3 1 roll
|
|
106
|
+
% ichar ostring index
|
|
107
|
+
2 copy 16#00 put
|
|
108
|
+
1 add
|
|
109
|
+
2 copy
|
|
110
|
+
5 -1 roll
|
|
111
|
+
% ostring index ostring index ichar
|
|
112
|
+
put
|
|
113
|
+
1 add
|
|
114
|
+
% ostring index
|
|
115
|
+
}
|
|
116
|
+
forall
|
|
117
|
+
% ostring index
|
|
118
|
+
pop
|
|
119
|
+
}
|
|
120
|
+
bind def
|
|
121
|
+
|
|
122
|
+
% Cf. https://en.wikibooks.org/wiki/PostScript_FAQ#How_to_concatenate_strings%3F
|
|
123
|
+
/concatstringarray { % [(a) (b) ... (z)] --> (ab...z)
|
|
124
|
+
0 1 index {
|
|
125
|
+
length add
|
|
126
|
+
} forall
|
|
127
|
+
string
|
|
128
|
+
0 3 2 roll {
|
|
129
|
+
3 copy putinterval
|
|
130
|
+
length add
|
|
131
|
+
} forall
|
|
132
|
+
pop
|
|
133
|
+
} bind def
|
|
134
|
+
|
|
135
|
+
/ZUGFeRDVersion where {
|
|
136
|
+
pop % Discard the dictionary
|
|
137
|
+
ZUGFeRDVersion (rc) ne {
|
|
138
|
+
ZUGFeRDVersion (1p0) ne {
|
|
139
|
+
ZUGFeRDVersion (2p0) ne {
|
|
140
|
+
ZUGFeRDVersion (2p1) ne {
|
|
141
|
+
/ZUGFeRDVersion (2p1) def
|
|
142
|
+
} if
|
|
143
|
+
} if
|
|
144
|
+
} if
|
|
145
|
+
} if
|
|
146
|
+
}{
|
|
147
|
+
/ZUGFeRDVersion (2p1) def
|
|
148
|
+
} ifelse
|
|
149
|
+
|
|
150
|
+
/ZUGFeRDConformanceLevel where {
|
|
151
|
+
pop % Discard the dictionary
|
|
152
|
+
ZUGFeRDVersion (rc) eq
|
|
153
|
+
ZUGFeRDVersion (1p0) eq or {
|
|
154
|
+
ZUGFeRDConformanceLevel (BASIC) ne {
|
|
155
|
+
ZUGFeRDConformanceLevel (COMFORT) ne {
|
|
156
|
+
ZUGFeRDConformanceLevel (EXTENDED) ne {
|
|
157
|
+
/ZUGFeRDConformanceLevel (BASIC) def
|
|
158
|
+
} if
|
|
159
|
+
} if
|
|
160
|
+
} if
|
|
161
|
+
} if
|
|
162
|
+
ZUGFeRDVersion (2p0) eq
|
|
163
|
+
ZUGFeRDVersion (2p1) eq or {
|
|
164
|
+
ZUGFeRDConformanceLevel (MINIMUM) ne {
|
|
165
|
+
ZUGFeRDConformanceLevel (BASIC WL) ne {
|
|
166
|
+
ZUGFeRDConformanceLevel (BASIC) ne {
|
|
167
|
+
ZUGFeRDConformanceLevel (EN 16931) ne {
|
|
168
|
+
ZUGFeRDConformanceLevel (EXTENDED) ne {
|
|
169
|
+
ZUGFeRDConformanceLevel (XRECHNUNG) ne {
|
|
170
|
+
/ZUGFeRDConformanceLevel (BASIC) def
|
|
171
|
+
} if
|
|
172
|
+
} if
|
|
173
|
+
} if
|
|
174
|
+
} if
|
|
175
|
+
} if
|
|
176
|
+
} if
|
|
177
|
+
} if
|
|
178
|
+
}{
|
|
179
|
+
/ZUGFeRDConformanceLevel (BASIC) def
|
|
180
|
+
} ifelse
|
|
181
|
+
|
|
182
|
+
% ZUGFeRDSchema
|
|
183
|
+
/ZUGFeRDSchema () def
|
|
184
|
+
ZUGFeRDVersion (rc) eq
|
|
185
|
+
ZUGFeRDVersion (1p0) eq or
|
|
186
|
+
ZUGFeRDVersion (2p0) eq or {
|
|
187
|
+
/ZUGFeRDSchema (ZUGFeRD PDFA Extension Schema) def
|
|
188
|
+
} if
|
|
189
|
+
ZUGFeRDVersion (2p1) eq {
|
|
190
|
+
/ZUGFeRDSchema (Factur-X PDFA Extension Schema) def
|
|
191
|
+
} if
|
|
192
|
+
|
|
193
|
+
% ZUGFeRDNamespaceURI
|
|
194
|
+
/ZUGFeRDNamespaceURI () def
|
|
195
|
+
ZUGFeRDVersion (rc) eq {
|
|
196
|
+
/ZUGFeRDNamespaceURI (urn:ferd:pdfa:invoice:rc#) def
|
|
197
|
+
} if
|
|
198
|
+
ZUGFeRDVersion (1p0) eq {
|
|
199
|
+
/ZUGFeRDNamespaceURI (urn:ferd:pdfa:CrossIndustryDocument:invoice:1p0#) def
|
|
200
|
+
} if
|
|
201
|
+
ZUGFeRDVersion (2p0) eq {
|
|
202
|
+
/ZUGFeRDNamespaceURI (urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0#) def
|
|
203
|
+
} if
|
|
204
|
+
ZUGFeRDVersion (2p1) eq {
|
|
205
|
+
/ZUGFeRDNamespaceURI (urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#) def
|
|
206
|
+
} if
|
|
207
|
+
|
|
208
|
+
% ZUGFeRDPrefix
|
|
209
|
+
/ZUGFeRDPrefix () def
|
|
210
|
+
ZUGFeRDVersion (rc) eq
|
|
211
|
+
ZUGFeRDVersion (1p0) eq or
|
|
212
|
+
ZUGFeRDVersion (2p0) eq or {
|
|
213
|
+
/ZUGFeRDPrefix (zf) def
|
|
214
|
+
} if
|
|
215
|
+
ZUGFeRDVersion (2p1) eq {
|
|
216
|
+
/ZUGFeRDPrefix (fx) def
|
|
217
|
+
} if
|
|
218
|
+
|
|
219
|
+
% ZUGFeRDVersionDescription
|
|
220
|
+
/ZUGFeRDVersionDescription () def
|
|
221
|
+
ZUGFeRDVersion (rc) eq
|
|
222
|
+
ZUGFeRDVersion (1p0) eq or
|
|
223
|
+
ZUGFeRDVersion (2p0) eq or {
|
|
224
|
+
/ZUGFeRDVersionDescription (The actual version of the ZUGFeRD XML schema) def
|
|
225
|
+
} if
|
|
226
|
+
ZUGFeRDVersion (2p1) eq {
|
|
227
|
+
/ZUGFeRDVersionDescription (The actual version of the Factur-X XML schema) def
|
|
228
|
+
} if
|
|
229
|
+
|
|
230
|
+
% ZUGFeRDConformanceLevelDescription
|
|
231
|
+
/ZUGFeRDConformanceLevelDescription () def
|
|
232
|
+
ZUGFeRDVersion (rc) eq
|
|
233
|
+
ZUGFeRDVersion (1p0) eq or
|
|
234
|
+
ZUGFeRDVersion (2p0) eq or {
|
|
235
|
+
/ZUGFeRDConformanceLevelDescription (The conformance level of the embedded ZUGFeRD data) def
|
|
236
|
+
} if
|
|
237
|
+
ZUGFeRDVersion (2p1) eq {
|
|
238
|
+
/ZUGFeRDConformanceLevelDescription (The conformance level of the embedded Factur-X data) def
|
|
239
|
+
} if
|
|
240
|
+
|
|
241
|
+
% ZUGFeRDDocumentFileName
|
|
242
|
+
/ZUGFeRDDocumentFileName () def
|
|
243
|
+
ZUGFeRDVersion (rc) eq {
|
|
244
|
+
/ZUGFeRDDocumentFileName (ZUGFeRD-invoice.xml) def
|
|
245
|
+
} if
|
|
246
|
+
ZUGFeRDVersion (1p0) eq {
|
|
247
|
+
/ZUGFeRDDocumentFileName (ZUGFeRD-invoice.xml) def
|
|
248
|
+
} if
|
|
249
|
+
ZUGFeRDVersion (2p0) eq {
|
|
250
|
+
ZUGFeRDConformanceLevel (XRECHNUNG) ne {
|
|
251
|
+
/ZUGFeRDDocumentFileName (zugferd-invoice.xml) def
|
|
252
|
+
}{
|
|
253
|
+
/ZUGFeRDDocumentFileName (xrechnung.xml) def
|
|
254
|
+
} ifelse
|
|
255
|
+
} if
|
|
256
|
+
ZUGFeRDVersion (2p1) eq {
|
|
257
|
+
ZUGFeRDConformanceLevel (XRECHNUNG) ne {
|
|
258
|
+
/ZUGFeRDDocumentFileName (factur-x.xml) def
|
|
259
|
+
}{
|
|
260
|
+
/ZUGFeRDDocumentFileName (xrechnung.xml) def
|
|
261
|
+
} ifelse
|
|
262
|
+
} if
|
|
263
|
+
|
|
264
|
+
% ZUGFeRDVersionData
|
|
265
|
+
/ZUGFeRDVersionData () def
|
|
266
|
+
ZUGFeRDVersion (rc) eq {
|
|
267
|
+
/ZUGFeRDVersionData (RC) def
|
|
268
|
+
} if
|
|
269
|
+
ZUGFeRDVersion (1p0) eq {
|
|
270
|
+
/ZUGFeRDVersionData (1.0) def
|
|
271
|
+
} if
|
|
272
|
+
ZUGFeRDVersion (2p0) eq {
|
|
273
|
+
ZUGFeRDConformanceLevel (XRECHNUNG) ne {
|
|
274
|
+
/ZUGFeRDVersionData (2p0) def
|
|
275
|
+
}{
|
|
276
|
+
/ZUGFeRDVersionData (1p2) def
|
|
277
|
+
} ifelse
|
|
278
|
+
} if
|
|
279
|
+
ZUGFeRDVersion (2p1) eq {
|
|
280
|
+
ZUGFeRDConformanceLevel (XRECHNUNG) ne {
|
|
281
|
+
/ZUGFeRDVersionData (1.0) def
|
|
282
|
+
}{
|
|
283
|
+
/ZUGFeRDVersionData (1p2) def
|
|
284
|
+
} ifelse
|
|
285
|
+
} if
|
|
286
|
+
|
|
287
|
+
/ZUGFeRDMetadata [
|
|
288
|
+
(
|
|
289
|
+
<rdf:Description)
|
|
290
|
+
( xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/")
|
|
291
|
+
( xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#")
|
|
292
|
+
( xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#")
|
|
293
|
+
( rdf:about="">
|
|
294
|
+
<pdfaExtension:schemas>
|
|
295
|
+
<rdf:Bag>
|
|
296
|
+
<rdf:li rdf:parseType="Resource">
|
|
297
|
+
<pdfaSchema:schema>)ZUGFeRDSchema(</pdfaSchema:schema>
|
|
298
|
+
<pdfaSchema:namespaceURI>)ZUGFeRDNamespaceURI(</pdfaSchema:namespaceURI>
|
|
299
|
+
<pdfaSchema:prefix>)ZUGFeRDPrefix(</pdfaSchema:prefix>
|
|
300
|
+
<pdfaSchema:property>
|
|
301
|
+
<rdf:Seq>
|
|
302
|
+
<rdf:li rdf:parseType="Resource">
|
|
303
|
+
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
|
304
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
305
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
306
|
+
<pdfaProperty:description>Name of the embedded XML invoice file</pdfaProperty:description>
|
|
307
|
+
</rdf:li>
|
|
308
|
+
<rdf:li rdf:parseType="Resource">
|
|
309
|
+
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
|
310
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
311
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
312
|
+
<pdfaProperty:description>INVOICE</pdfaProperty:description>
|
|
313
|
+
</rdf:li>
|
|
314
|
+
<rdf:li rdf:parseType="Resource">
|
|
315
|
+
<pdfaProperty:name>Version</pdfaProperty:name>
|
|
316
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
317
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
318
|
+
<pdfaProperty:description>)ZUGFeRDVersionDescription(</pdfaProperty:description>
|
|
319
|
+
</rdf:li>
|
|
320
|
+
<rdf:li rdf:parseType="Resource">
|
|
321
|
+
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
|
322
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
323
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
324
|
+
<pdfaProperty:description>)ZUGFeRDConformanceLevelDescription(</pdfaProperty:description>
|
|
325
|
+
</rdf:li>
|
|
326
|
+
</rdf:Seq>
|
|
327
|
+
</pdfaSchema:property>
|
|
328
|
+
</rdf:li>
|
|
329
|
+
</rdf:Bag>
|
|
330
|
+
</pdfaExtension:schemas>
|
|
331
|
+
</rdf:Description>
|
|
332
|
+
<rdf:Description xmlns:)ZUGFeRDPrefix(=")ZUGFeRDNamespaceURI(" rdf:about="">
|
|
333
|
+
<)ZUGFeRDPrefix(:ConformanceLevel>)ZUGFeRDConformanceLevel(</)ZUGFeRDPrefix(:ConformanceLevel>
|
|
334
|
+
<)ZUGFeRDPrefix(:DocumentFileName>)ZUGFeRDDocumentFileName(</)ZUGFeRDPrefix(:DocumentFileName>
|
|
335
|
+
<)ZUGFeRDPrefix(:DocumentType>INVOICE</)ZUGFeRDPrefix(:DocumentType>
|
|
336
|
+
<)ZUGFeRDPrefix(:Version>)ZUGFeRDVersionData(</)ZUGFeRDPrefix(:Version>
|
|
337
|
+
</rdf:Description>
|
|
338
|
+
)
|
|
339
|
+
] concatstringarray def
|
|
340
|
+
|
|
341
|
+
/Usage {
|
|
342
|
+
(example usage: \n) print
|
|
343
|
+
( gs --permit-file-read=/usr/home/me/zugferd/ \\\n) print
|
|
344
|
+
( -sDEVICE=pdfwrite \\\n) print
|
|
345
|
+
( -dPDFA=3 \\\n) print
|
|
346
|
+
( -sColorConversionStrategy=RGB \\\n) print
|
|
347
|
+
( -sZUGFeRDXMLFile=/usr/home/me/zugferd/invoice.xml \\\n) print
|
|
348
|
+
( -sZUGFeRDProfile=/usr/home/me/zugferd\rgb.icc \\\n) print
|
|
349
|
+
( -sZUGFeRDVersion=2p1 \\\n) print
|
|
350
|
+
( -sZUGFeRDConformanceLevel=BASIC \\\n) print
|
|
351
|
+
( -o /usr/home/me/zugferd/zugferd.pdf \\\n) print
|
|
352
|
+
( /usr/home/me/zugferd/zugferd.ps \\\n) print
|
|
353
|
+
( /usr/home/me/zugferd/original.pdf \n) print
|
|
354
|
+
flush
|
|
355
|
+
} def
|
|
356
|
+
|
|
357
|
+
% First check that the user has defined the XML invoice file on the command line
|
|
358
|
+
%
|
|
359
|
+
/ZUGFeRDXMLFile where {
|
|
360
|
+
pop % Discard the dictionary
|
|
361
|
+
%
|
|
362
|
+
% Now check that the ICC Profile is defined
|
|
363
|
+
%
|
|
364
|
+
/ZUGFeRDProfile where {
|
|
365
|
+
pop % Discard the dictionary
|
|
366
|
+
|
|
367
|
+
% Step 1, add the required PDF/A boilerplate.
|
|
368
|
+
% This is mostly copied from lib/pdfa_def.ps
|
|
369
|
+
|
|
370
|
+
% Create a PDF stream object to hold the ICC profile.
|
|
371
|
+
[ /_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
|
372
|
+
|
|
373
|
+
% Add the required entries to the stream dictionary (/N only)
|
|
374
|
+
[ {icc_PDFA}
|
|
375
|
+
<<
|
|
376
|
+
%% This code attempts to set the /N (number of components) key for the ICC colour space.
|
|
377
|
+
%% To do this it checks the ColorConversionStrategy or the device ProcessColorModel if
|
|
378
|
+
%% ColorConversionStrategy is not set.
|
|
379
|
+
%% This is not 100% reliable. A better solution is for the user to edit this and replace
|
|
380
|
+
%% the code between the ---8<--- lines with a simple declaration like:
|
|
381
|
+
%% /N 3
|
|
382
|
+
%% where the value of N is the number of components from the profile defined in ZUGFeRDProfile.
|
|
383
|
+
%%
|
|
384
|
+
%% ----------8<--------------8<-------------8<--------------8<----------
|
|
385
|
+
systemdict /ColorConversionStrategy known {
|
|
386
|
+
systemdict /ColorConversionStrategy get cvn dup /Gray eq {
|
|
387
|
+
pop /N 1 false
|
|
388
|
+
}{
|
|
389
|
+
dup /RGB eq {
|
|
390
|
+
pop /N 3 false
|
|
391
|
+
}{
|
|
392
|
+
/CMYK eq {
|
|
393
|
+
/N 4 false
|
|
394
|
+
}{
|
|
395
|
+
(ColorConversionStrategy not a device space, falling back to ProcessColorModel, output may not be valid PDF/A.)=
|
|
396
|
+
true
|
|
397
|
+
} ifelse
|
|
398
|
+
} ifelse
|
|
399
|
+
} ifelse
|
|
400
|
+
} {
|
|
401
|
+
(ColorConversionStrategy not set, falling back to ProcessColorModel, output may not be valid PDF/A.)=
|
|
402
|
+
true
|
|
403
|
+
} ifelse
|
|
404
|
+
|
|
405
|
+
{
|
|
406
|
+
currentpagedevice /ProcessColorModel get
|
|
407
|
+
dup /DeviceGray eq {
|
|
408
|
+
pop /N 1
|
|
409
|
+
}{
|
|
410
|
+
dup /DeviceRGB eq {
|
|
411
|
+
pop /N 3
|
|
412
|
+
}{
|
|
413
|
+
dup /DeviceCMYK eq {
|
|
414
|
+
pop /N 4
|
|
415
|
+
} {
|
|
416
|
+
(ProcessColorModel not a device space.)=
|
|
417
|
+
/ProcessColorModel cvx /rangecheck signalerror
|
|
418
|
+
} ifelse
|
|
419
|
+
} ifelse
|
|
420
|
+
} ifelse
|
|
421
|
+
} if
|
|
422
|
+
%% ----------8<--------------8<-------------8<--------------8<----------
|
|
423
|
+
>> /PUT pdfmark
|
|
424
|
+
|
|
425
|
+
% Now read the ICC profile from the file into the stream
|
|
426
|
+
[ {icc_PDFA} ZUGFeRDProfile (r) file /PUT pdfmark
|
|
427
|
+
|
|
428
|
+
% Define the output intent dictionary :
|
|
429
|
+
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
|
430
|
+
|
|
431
|
+
% Add the required keys to the dictionary
|
|
432
|
+
[{OutputIntent_PDFA} <<
|
|
433
|
+
/Type /OutputIntent
|
|
434
|
+
/S /GTS_PDFA1 % Required for PDF/A.
|
|
435
|
+
/DestOutputProfile {icc_PDFA} % The actual profile.
|
|
436
|
+
/OutputConditionIdentifier (Custom) % TODO: A better solution is a
|
|
437
|
+
% a string from the ICC
|
|
438
|
+
% Registry, but Custom
|
|
439
|
+
% is always valid.
|
|
440
|
+
>> /PUT pdfmark
|
|
441
|
+
|
|
442
|
+
% And now add the OutputIntent to the Catalog dictionary
|
|
443
|
+
[ {Catalog} << /OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
|
444
|
+
|
|
445
|
+
% Step 2, define the XML file and read it into the PDF
|
|
446
|
+
% First we define the PDF stream to contain the XML invoice
|
|
447
|
+
[ /_objdef {InvoiceStream} /type /stream /OBJ pdfmark
|
|
448
|
+
% Fill in the dictionary elements we need. We believe the
|
|
449
|
+
% ModDate is not useful so it's just set to a valid value.
|
|
450
|
+
[ {InvoiceStream} <<
|
|
451
|
+
/Type /EmbeddedFile
|
|
452
|
+
/Subtype (text/xml) cvn
|
|
453
|
+
/Params <<
|
|
454
|
+
/ModDate systemdict /ZUGFeRDDateTime known
|
|
455
|
+
{ZUGFeRDDateTime}
|
|
456
|
+
{(D:20130121081433+01'00')}
|
|
457
|
+
ifelse
|
|
458
|
+
/Size ZUGFeRDXMLFile status
|
|
459
|
+
{pop pop exch pop}
|
|
460
|
+
{(Failed to get file status!\n)print /status load /ioerror signalerror}
|
|
461
|
+
ifelse
|
|
462
|
+
>>
|
|
463
|
+
>> /PUT pdfmark
|
|
464
|
+
% Now read the data from the file and store it in the stream
|
|
465
|
+
[ {InvoiceStream} ZUGFeRDXMLFile (r) file /PUT pdfmark
|
|
466
|
+
% and close the stream
|
|
467
|
+
[ {InvoiceStream} /CLOSE pdfmark
|
|
468
|
+
|
|
469
|
+
% Step 3 create the File Specification dictionary for the embedded file
|
|
470
|
+
% Create the dictionary
|
|
471
|
+
[ /_objdef {FSDict} /type /dict /OBJ pdfmark
|
|
472
|
+
% Fill in the required dictionary elements
|
|
473
|
+
[ {FSDict} <<
|
|
474
|
+
/Type /Filespec
|
|
475
|
+
/F ZUGFeRDDocumentFileName
|
|
476
|
+
/UF ZUGFeRDDocumentFileName SimpleUTF16BE
|
|
477
|
+
/Desc (ZUGFeRD electronic invoice)
|
|
478
|
+
/AFRelationship /Alternative
|
|
479
|
+
/EF <<
|
|
480
|
+
/F {InvoiceStream}
|
|
481
|
+
/UF {InvoiceStream}
|
|
482
|
+
>>
|
|
483
|
+
>>
|
|
484
|
+
/PUT pdfmark
|
|
485
|
+
|
|
486
|
+
% Step 4 Create the Associated Files dictionary to hold the FS dict
|
|
487
|
+
% Create the dictionary
|
|
488
|
+
[ /_objdef {AFArray} /type /array /OBJ pdfmark
|
|
489
|
+
% Put (append) the FS dictionary into the Associated Files array
|
|
490
|
+
[ {AFArray} {FSDict} /APPEND pdfmark
|
|
491
|
+
|
|
492
|
+
% Step 5 Add an entry in the Catalog dictionary containing the AF array
|
|
493
|
+
% Since Ghostscript 10.04.0 this is no longer required, providing the output file is
|
|
494
|
+
% PDF/A-3 or higher. The PDF/A-3 tech note 0010 (p28) clarifies that the /AF in the Catalog
|
|
495
|
+
% is mandatory and GS 10.04.0 will emit one itself.
|
|
496
|
+
%
|
|
497
|
+
% This line retained for historical reference, and as an example for cases where the output
|
|
498
|
+
% is not PDF/A-3
|
|
499
|
+
%
|
|
500
|
+
% [ {Catalog} << /AF {AFArray} >> /PUT pdfmark
|
|
501
|
+
|
|
502
|
+
% Step 6 use the EMBED pdfmark to add the XML file and FS dictionary to the PDF name tree
|
|
503
|
+
[ /Name ZUGFeRDDocumentFileName /FS {FSDict} /EMBED pdfmark
|
|
504
|
+
|
|
505
|
+
% Step 7 Add the extra ZUGFeRD XML data to the Metadata
|
|
506
|
+
[ /XML ZUGFeRDMetadata /Ext_Metadata pdfmark
|
|
507
|
+
}
|
|
508
|
+
{
|
|
509
|
+
% No ICC Profile definition on the command line;
|
|
510
|
+
% chide the user and give them an example
|
|
511
|
+
(\nERROR - ZUGFeRDProfile has not been supplied, you must supply an ICC profile) print
|
|
512
|
+
(\n Producing a potentially INVALID PDF/A file. \n) print
|
|
513
|
+
Usage
|
|
514
|
+
} ifelse
|
|
515
|
+
}
|
|
516
|
+
{
|
|
517
|
+
% No XML invoice definition on the command line;
|
|
518
|
+
% chide the user and give them an example
|
|
519
|
+
(\nERROR - ZUGFeRDXMLFile has not been supplied, you must supply a XML invoice file) print
|
|
520
|
+
(\n Producing a PDF/A file, NOT a ZUGFeRD file. \n) print
|
|
521
|
+
Usage
|
|
522
|
+
} ifelse
|
|
523
|
+
|
|
524
|
+
% That's all the ZUGFeRD and PDF/A-3 setup completed,
|
|
525
|
+
% all that remains now is to run the input file
|
|
526
|
+
|
|
527
|
+
%%EOF
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zugpferd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexander Zeitler
|
|
@@ -66,9 +66,10 @@ dependencies:
|
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '13.0'
|
|
69
|
-
description: Read, write and convert
|
|
70
|
-
UBL 2.1 and UN/CEFACT CII syntaxes with dedicated
|
|
71
|
-
Corrected Invoice, Self-billed Invoice, Partial
|
|
69
|
+
description: Read, write and convert XRechnung and ZUGFeRD electronic invoices (e-Rechnung)
|
|
70
|
+
according to EN 16931. Supports UBL 2.1 and UN/CEFACT CII syntaxes with dedicated
|
|
71
|
+
classes for Invoice, Credit Note, Corrected Invoice, Self-billed Invoice, Partial
|
|
72
|
+
Invoice and Prepayment Invoice.
|
|
72
73
|
email:
|
|
73
74
|
executables: []
|
|
74
75
|
extensions: []
|
|
@@ -96,9 +97,18 @@ files:
|
|
|
96
97
|
- lib/zugpferd/model/tax_breakdown.rb
|
|
97
98
|
- lib/zugpferd/model/tax_subtotal.rb
|
|
98
99
|
- lib/zugpferd/model/trade_party.rb
|
|
100
|
+
- lib/zugpferd/pdf.rb
|
|
101
|
+
- lib/zugpferd/pdf/embedder.rb
|
|
99
102
|
- lib/zugpferd/ubl/mapping.rb
|
|
100
103
|
- lib/zugpferd/ubl/reader.rb
|
|
101
104
|
- lib/zugpferd/ubl/writer.rb
|
|
105
|
+
- lib/zugpferd/validation.rb
|
|
106
|
+
- lib/zugpferd/validation/mustang_validator.rb
|
|
107
|
+
- lib/zugpferd/validation/pdf_validator.rb
|
|
108
|
+
- lib/zugpferd/validation/schema_validator.rb
|
|
109
|
+
- lib/zugpferd/validation/schematron_validator.rb
|
|
110
|
+
- vendor/zugferd/default_rgb.icc
|
|
111
|
+
- vendor/zugferd/zugferd.ps
|
|
102
112
|
homepage: https://alexzeitler.github.io/zugpferd/
|
|
103
113
|
licenses:
|
|
104
114
|
- MIT
|
|
@@ -106,7 +116,7 @@ metadata:
|
|
|
106
116
|
source_code_uri: https://github.com/alexzeitler/zugpferd
|
|
107
117
|
homepage_uri: https://alexzeitler.github.io/zugpferd/
|
|
108
118
|
changelog_uri: https://github.com/alexzeitler/zugpferd/blob/master/CHANGELOG.md
|
|
109
|
-
documentation_uri: https://
|
|
119
|
+
documentation_uri: https://www.rubydoc.info/gems/zugpferd
|
|
110
120
|
bug_tracker_uri: https://github.com/alexzeitler/zugpferd/issues
|
|
111
121
|
post_install_message:
|
|
112
122
|
rdoc_options: []
|
|
@@ -126,5 +136,5 @@ requirements: []
|
|
|
126
136
|
rubygems_version: 3.5.20
|
|
127
137
|
signing_key:
|
|
128
138
|
specification_version: 4
|
|
129
|
-
summary:
|
|
139
|
+
summary: XRechnung & ZUGFeRD e-invoicing library for Ruby (UBL + CII)
|
|
130
140
|
test_files: []
|