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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +22 -0
  4. data/NOTICE.md +9 -0
  5. data/README.md +75 -0
  6. data/lib/facturx/attach.rb +34 -0
  7. data/lib/facturx/composers/ghostscript/defaults.rb +29 -0
  8. data/lib/facturx/composers/ghostscript/locator.rb +134 -0
  9. data/lib/facturx/composers/ghostscript/output_capture.rb +39 -0
  10. data/lib/facturx/composers/ghostscript/runner.rb +122 -0
  11. data/lib/facturx/composers/ghostscript/version_probe.rb +61 -0
  12. data/lib/facturx/composers/ghostscript.rb +105 -0
  13. data/lib/facturx/embedding.rb +15 -0
  14. data/lib/facturx/error.rb +22 -0
  15. data/lib/facturx/pdf/document.rb +103 -0
  16. data/lib/facturx/pdf/extractor.rb +115 -0
  17. data/lib/facturx/pdf/inspector.rb +17 -0
  18. data/lib/facturx/pdf/name_tree.rb +60 -0
  19. data/lib/facturx/pdf/verifier.rb +116 -0
  20. data/lib/facturx/profile.rb +5 -0
  21. data/lib/facturx/profiles.rb +60 -0
  22. data/lib/facturx/schema/1.09.2/basic/Factur-X_1.09.2_BASIC.xsd +20 -0
  23. 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
  24. 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
  25. data/lib/facturx/schema/1.09.2/basic/ReusableAggregateBusinessInformationEntity_100.xsd +249 -0
  26. data/lib/facturx/schema/1.09.2/basic-wl/Factur-X_1.09.2_BASICWL.xsd +20 -0
  27. 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
  28. 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
  29. data/lib/facturx/schema/1.09.2/basic-wl/ReusableAggregateBusinessInformationEntity_100.xsd +196 -0
  30. data/lib/facturx/schema/1.09.2/en16931/Factur-X_1.09.2_EN16931.xsd +20 -0
  31. 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
  32. 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
  33. data/lib/facturx/schema/1.09.2/en16931/ReusableAggregateBusinessInformationEntity_100.xsd +318 -0
  34. data/lib/facturx/schema/1.09.2/extended/Factur-X_1.09.2_EXTENDED.xsd +20 -0
  35. 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
  36. 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
  37. data/lib/facturx/schema/1.09.2/extended/ReusableAggregateBusinessInformationEntity_100.xsd +490 -0
  38. data/lib/facturx/schema/1.09.2/minimum/Factur-X_1.09.2_MINIMUM.xsd +20 -0
  39. 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
  40. 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
  41. data/lib/facturx/schema/1.09.2/minimum/ReusableAggregateBusinessInformationEntity_100.xsd +86 -0
  42. data/lib/facturx/schema/LICENSE-APACHE-2.0.txt +201 -0
  43. data/lib/facturx/schema/NOTICE.md +40 -0
  44. data/lib/facturx/schema/SHA256SUMS +20 -0
  45. data/lib/facturx/version.rb +5 -0
  46. data/lib/facturx/xml/parser.rb +23 -0
  47. data/lib/facturx/xml/profile_detector.rb +26 -0
  48. data/lib/facturx/xml/schema_validator.rb +64 -0
  49. data/lib/facturx/xml/verifier.rb +24 -0
  50. data/lib/facturx.rb +34 -0
  51. metadata +130 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61ac3e80ab256d20b48527e6b92bccd30fcc6f0b7d1b87dec452161fd9e916fb
4
+ data.tar.gz: 35227d6b843d4c7f51608c2d149b4ce456ca854b16a14ed11317dab0ba2f2cfe
5
+ SHA512:
6
+ metadata.gz: aa5602b1058a4e5cd06821ea7995d761983b9ddf6cb736e3bcc46e8473230baaf6f215b8f78034f633f19223072b24ce6e474466f5a534801e89516e6d949854
7
+ data.tar.gz: 3f9504f7f0c04245bd50948d38a1608f1accc18c5bda8dc953229ef60ac4e7cd1071aa15bef81172007e41d6c4eae76cbeaa4999d67d2fb80e52fe9dd00db4da
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-09-11
8
+
9
+ ### Added
10
+
11
+ - Validate Factur-X 1.09.2 XML against its profile XSD.
12
+ - Compose PDF/A-3b Factur-X documents with Ghostscript.
13
+ - Extract embedded Factur-X XML from PDF documents.
14
+
15
+ [Unreleased]: https://github.com/AdVitam/facturx/compare/v0.1.0...HEAD
16
+ [0.1.0]: https://github.com/AdVitam/facturx/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2026 AdVitam (https://www.advitam.fr)
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/NOTICE.md ADDED
@@ -0,0 +1,9 @@
1
+ # Third-party notices
2
+
3
+ ## Factur-X validation schemas
4
+
5
+ The gem includes Factur-X 1.09.2 / ZUGFeRD 2.5.2 XSD validation artefacts. Their Apache License 2.0 text, provenance, RubyGems packaging adaptation, and file checksums are packaged under `lib/facturx/schema/`.
6
+
7
+ ## Ghostscript
8
+
9
+ PDF composition invokes a Ghostscript installation supplied by the user. Ghostscript, `zugferd.ps`, and ICC profiles are not part of this gem and remain governed by their respective licences.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # facturx
2
+
3
+ `facturx` validates Factur-X XML, creates PDF/A-3b Factur-X invoices from existing PDFs, and extracts their embedded XML.
4
+
5
+ The first release supports Factur-X 1.09.2 / ZUGFeRD 2.5.2 with the MINIMUM, BASIC WL, BASIC, EN 16931, and EXTENDED profiles.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to your bundle:
10
+
11
+ ```ruby
12
+ gem 'facturx'
13
+ ```
14
+
15
+ Then run `bundle install`.
16
+
17
+ Composition requires Ghostscript 9.54 or newer with `zugferd.ps` and an RGB ICC profile installed on the system. Package contents vary by operating system and distribution: after installing Ghostscript, verify that both resources are present and configure their paths as described below when automatic discovery cannot find them.
18
+
19
+ ## Usage
20
+
21
+ All inputs and outputs are byte strings. The gem does not interpret strings as file paths.
22
+
23
+ ```ruby
24
+ require 'facturx'
25
+
26
+ xml = File.binread('invoice.xml')
27
+ pdf = File.binread('invoice.pdf')
28
+
29
+ Facturx.verify_xml(xml: xml) # => true
30
+
31
+ facturx_pdf = Facturx.attach(pdf: pdf, xml: xml)
32
+ File.binwrite('invoice-facturx.pdf', facturx_pdf)
33
+
34
+ embedded_xml = Facturx.extract_xml(pdf: facturx_pdf)
35
+ ```
36
+
37
+ `verify_xml` infers the profile from BT-24 and validates the document against that profile's XSD. It returns `true` on success and raises a typed `Facturx::Error` on failure.
38
+
39
+ `attach` always validates the XML first. It converts the input PDF to PDF/A-3b, embeds the original XML bytes as `factur-x.xml`, and verifies the resulting attachment and metadata. Signed and encrypted PDFs are rejected because rewriting them would invalidate their protection.
40
+
41
+ `extract_xml` returns the embedded bytes without validating them. This allows callers to inspect non-conforming invoices received from third parties and explicitly call `verify_xml` when appropriate.
42
+
43
+ The byte-string API materializes PDF streams in memory. Process untrusted PDFs in a resource-limited worker; limiting only the input file size does not prevent amplification by a compressed embedded stream.
44
+
45
+ ## Ghostscript discovery
46
+
47
+ The composer searches common installation paths. Override discovery when necessary:
48
+
49
+ ```bash
50
+ export GHOSTSCRIPT_BIN=/opt/ghostscript/bin/gs
51
+ export FACTURX_ZUGFERD_PS=/opt/ghostscript/share/ghostscript/lib/zugferd.ps
52
+ export FACTURX_ICC_PROFILE=/opt/ghostscript/share/ghostscript/iccprofiles/default_rgb.icc
53
+ ```
54
+
55
+ The gem invokes Ghostscript as an external process. It does not distribute Ghostscript, `zugferd.ps`, or ICC profiles.
56
+
57
+ ## Validation scope
58
+
59
+ Version 0.1 validates XML structure with the official XSDs. It does not yet run the Factur-X Schematron business rules. XSD validation alone must not be presented as complete semantic or regulatory validation.
60
+
61
+ The gem does not generate invoice XML or the visual invoice PDF, communicate with a PDP, or implement e-reporting.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ mise install
67
+ bundle install
68
+ bundle exec rubocop
69
+ bundle exec rspec
70
+ bundle exec rake build
71
+ ```
72
+
73
+ ## License
74
+
75
+ The gem code is available under the MIT License. Bundled standard artefacts retain their respective upstream notices; see `NOTICE.md`.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'xml/verifier'
4
+ require_relative 'pdf/inspector'
5
+ require_relative 'pdf/extractor'
6
+ require_relative 'pdf/verifier'
7
+ require_relative 'composers/ghostscript'
8
+
9
+ module Facturx
10
+ class Attach
11
+ def initialize(
12
+ xml_verifier: Xml::Verifier.new,
13
+ pdf_inspector: Pdf::Inspector.new,
14
+ composer: Composers::Ghostscript.new,
15
+ extractor: Pdf::Extractor.new,
16
+ pdf_verifier: Pdf::Verifier.new
17
+ )
18
+ @xml_verifier = xml_verifier
19
+ @pdf_inspector = pdf_inspector
20
+ @composer = composer
21
+ @extractor = extractor
22
+ @pdf_verifier = pdf_verifier
23
+ end
24
+
25
+ def call(pdf:, xml:)
26
+ profile = @xml_verifier.call(xml:)
27
+ inspection = @pdf_inspector.call(pdf)
28
+ output = @composer.call(pdf:, xml:, profile:)
29
+ result = @extractor.call(output)
30
+ @pdf_verifier.call(result:, expected_xml: xml, expected_page_count: inspection.page_count, profile:)
31
+ output
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Composers
5
+ class Ghostscript
6
+ module Defaults
7
+ ENV_KEYS = {
8
+ binary: 'GHOSTSCRIPT_BIN',
9
+ zugferd_ps: 'FACTURX_ZUGFERD_PS',
10
+ icc_profile: 'FACTURX_ICC_PROFILE'
11
+ }.freeze
12
+ BINARY_NAMES = %w[gs gswin64c.exe gswin32c.exe].freeze
13
+ ZUGFERD_CANDIDATES = [
14
+ '/usr/share/ghostscript/*/lib/zugferd.ps',
15
+ '/usr/local/share/ghostscript/*/lib/zugferd.ps',
16
+ '/opt/homebrew/share/ghostscript/*/lib/zugferd.ps',
17
+ '/opt/local/share/ghostscript/*/lib/zugferd.ps'
18
+ ].freeze
19
+ ICC_CANDIDATES = [
20
+ '/usr/share/color/icc/ghostscript/default_rgb.icc',
21
+ '/usr/share/ghostscript/*/iccprofiles/default_rgb.icc',
22
+ '/usr/local/share/ghostscript/*/iccprofiles/default_rgb.icc',
23
+ '/opt/homebrew/share/ghostscript/*/iccprofiles/default_rgb.icc',
24
+ '/opt/local/share/ghostscript/*/iccprofiles/default_rgb.icc'
25
+ ].freeze
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'defaults'
4
+ require_relative 'version_probe'
5
+
6
+ module Facturx
7
+ module Composers
8
+ class Ghostscript
9
+ class Locator
10
+ Config = Data.define(:binary, :zugferd_ps, :icc_profile)
11
+ ENV_KEYS = Defaults::ENV_KEYS
12
+
13
+ def initialize(
14
+ binary: nil,
15
+ zugferd_ps: nil,
16
+ icc_profile: nil,
17
+ version_probe: VersionProbe.new,
18
+ **discovery
19
+ )
20
+ @overrides = { binary:, zugferd_ps:, icc_profile: }
21
+ @env = discovery.fetch(:env, ENV)
22
+ candidates = discovery.fetch(:candidates, {})
23
+ @zugferd_candidates = candidates.fetch(:zugferd_ps, Defaults::ZUGFERD_CANDIDATES)
24
+ @icc_candidates = candidates.fetch(:icc_profile, Defaults::ICC_CANDIDATES)
25
+ @version_probe = version_probe
26
+ end
27
+
28
+ def available?
29
+ preflight!
30
+ true
31
+ rescue ComposerUnavailableError
32
+ false
33
+ end
34
+
35
+ def preflight!
36
+ candidate = config
37
+ missing_resources = missing(candidate)
38
+ raise_missing(missing_resources) unless missing_resources.empty?
39
+ verify_version!(candidate.binary)
40
+ @config = candidate
41
+ end
42
+
43
+ private
44
+
45
+ def config
46
+ @config || Config.new(
47
+ binary: resolve_binary,
48
+ zugferd_ps: resolve_resource(:zugferd_ps, @zugferd_candidates),
49
+ icc_profile: resolve_resource(:icc_profile, @icc_candidates)
50
+ )
51
+ end
52
+
53
+ def missing(candidate)
54
+ candidate.to_h.filter_map { |name, value| name if value.nil? }
55
+ end
56
+
57
+ def raise_missing(resources)
58
+ raise ComposerUnavailableError.new(
59
+ "Ghostscript composition is unavailable: missing #{resources.join(', ')}",
60
+ missing: resources
61
+ )
62
+ end
63
+
64
+ def verify_version!(binary)
65
+ @verify_version ||= @version_probe.call(binary)
66
+ end
67
+
68
+ def resolve_binary
69
+ configured = configured_value(:binary)
70
+ return find_executable(configured) if configured
71
+
72
+ Defaults::BINARY_NAMES.lazy.filter_map { |name| find_executable(name) }.first
73
+ end
74
+
75
+ def resolve_resource(name, candidates)
76
+ configured = configured_value(name)
77
+ return readable_file(configured) if configured
78
+
79
+ candidates.lazy.flat_map { |pattern| Dir.glob(pattern).reverse }.filter_map do |path|
80
+ readable_file(path)
81
+ end.first
82
+ end
83
+
84
+ def configured_value(name)
85
+ override = @overrides.fetch(name)
86
+ return override unless blank?(override)
87
+
88
+ value = @env[ENV_KEYS.fetch(name)]
89
+ value unless blank?(value)
90
+ end
91
+
92
+ def find_executable(value)
93
+ path = value.to_s
94
+ return executable_file(path) if path.include?(File::SEPARATOR) ||
95
+ (File::ALT_SEPARATOR && path.include?(File::ALT_SEPARATOR))
96
+
97
+ executable_extensions(path).each do |executable_name|
98
+ search_paths.each do |directory|
99
+ executable = executable_file(File.join(directory, executable_name))
100
+ return executable if executable
101
+ end
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def executable_extensions(name)
108
+ return [name] unless Gem.win_platform? && File.extname(name) == ''
109
+
110
+ extensions = @env.fetch('PATHEXT', '.COM;.EXE;.BAT;.CMD').split(';')
111
+ [name, *extensions.map { |extension| "#{name}#{extension.downcase}" }]
112
+ end
113
+
114
+ def search_paths
115
+ @env.fetch('PATH', '').split(File::PATH_SEPARATOR).reject(&:empty?)
116
+ end
117
+
118
+ def executable_file(path)
119
+ expanded = File.expand_path(path)
120
+ expanded if File.file?(expanded) && File.executable?(expanded)
121
+ end
122
+
123
+ def readable_file(path)
124
+ expanded = File.expand_path(path.to_s)
125
+ expanded if File.file?(expanded) && File.readable?(expanded)
126
+ end
127
+
128
+ def blank?(value)
129
+ value.nil? || value.to_s.empty?
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Composers
5
+ class Ghostscript
6
+ class OutputCapture
7
+ TRUNCATION_MARKER = '...[truncated]'
8
+
9
+ def initialize(stream, limit:)
10
+ @stream = stream
11
+ @limit = limit
12
+ end
13
+
14
+ def call
15
+ buffer = +''.b
16
+ truncated = false
17
+ while (chunk = @stream.read(4_096))
18
+ remaining = @limit - buffer.bytesize
19
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
20
+ truncated ||= chunk.bytesize > remaining
21
+ end
22
+ truncate(buffer, truncated)
23
+ rescue IOError
24
+ buffer
25
+ end
26
+
27
+ private
28
+
29
+ def truncate(buffer, truncated)
30
+ return buffer unless truncated
31
+
32
+ marker = TRUNCATION_MARKER.byteslice(0, @limit)
33
+ kept_bytes = [@limit - marker.bytesize, 0].max
34
+ buffer.byteslice(0, kept_bytes).to_s << marker
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+ require_relative '../../error'
6
+ require_relative 'output_capture'
7
+
8
+ module Facturx
9
+ module Composers
10
+ class Ghostscript
11
+ class Runner
12
+ Result = Data.define(:stdout, :stderr, :exit_status)
13
+
14
+ DEFAULT_TIMEOUT = 60
15
+ DEFAULT_OUTPUT_LIMIT = 8_192
16
+ DEFAULT_TERMINATION_GRACE = 1
17
+ def initialize(
18
+ timeout: DEFAULT_TIMEOUT,
19
+ output_limit: DEFAULT_OUTPUT_LIMIT,
20
+ termination_grace: DEFAULT_TERMINATION_GRACE
21
+ )
22
+ @timeout = timeout
23
+ @output_limit = output_limit
24
+ @termination_grace = termination_grace
25
+ end
26
+
27
+ def call(argv)
28
+ result = execute(argv)
29
+ return result if result.exit_status.zero?
30
+
31
+ raise_failed(result)
32
+ rescue CompositionError
33
+ raise
34
+ rescue SystemCallError => e
35
+ raise CompositionError.new(
36
+ "Ghostscript could not be executed: #{e.message}",
37
+ cause: e.class.name
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def execute(argv)
44
+ Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, wait_thread|
45
+ capture_process(stdin, stdout, stderr, wait_thread)
46
+ end
47
+ end
48
+
49
+ def capture_process(stdin, stdout, stderr, wait_thread)
50
+ stdin.close
51
+ stdout_reader = capture(stdout)
52
+ stderr_reader = capture(stderr)
53
+ build_result(wait_thread, stdout_reader, stderr_reader)
54
+ ensure
55
+ stdout_reader&.join
56
+ stderr_reader&.join
57
+ end
58
+
59
+ def build_result(wait_thread, stdout_reader, stderr_reader)
60
+ status = wait_for(wait_thread, stderr_reader)
61
+ Result.new(
62
+ stdout: stdout_reader.value,
63
+ stderr: stderr_reader.value,
64
+ exit_status: exit_status(status)
65
+ )
66
+ end
67
+
68
+ def exit_status(status)
69
+ status.exitstatus || (128 + status.termsig)
70
+ end
71
+
72
+ def raise_failed(result)
73
+ raise CompositionError.new(
74
+ "Ghostscript failed with exit status #{result.exit_status}",
75
+ exit_status: result.exit_status,
76
+ stdout: sanitize(result.stdout),
77
+ stderr: sanitize(result.stderr)
78
+ )
79
+ end
80
+
81
+ def wait_for(wait_thread, stderr_reader)
82
+ Timeout.timeout(@timeout) { wait_thread.value }
83
+ rescue Timeout::Error
84
+ terminate(wait_thread)
85
+ raise CompositionError.new(
86
+ "Ghostscript timed out after #{@timeout} seconds",
87
+ timeout: @timeout,
88
+ stderr: sanitize(stderr_reader.value)
89
+ )
90
+ end
91
+
92
+ def capture(stream)
93
+ Thread.new { OutputCapture.new(stream, limit: @output_limit).call }
94
+ end
95
+
96
+ def terminate(wait_thread)
97
+ signal('TERM', wait_thread.pid)
98
+ return if wait_thread.join(@termination_grace)
99
+
100
+ signal('KILL', wait_thread.pid)
101
+ wait_thread.join
102
+ end
103
+
104
+ def signal(name, pid)
105
+ Process.kill(name, -pid)
106
+ rescue Errno::ESRCH
107
+ nil
108
+ rescue SystemCallError
109
+ begin
110
+ Process.kill(name, pid)
111
+ rescue Errno::ESRCH
112
+ nil
113
+ end
114
+ end
115
+
116
+ def sanitize(output)
117
+ output.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?').strip
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems/version'
4
+ require_relative 'runner'
5
+
6
+ module Facturx
7
+ module Composers
8
+ class Ghostscript
9
+ class VersionProbe
10
+ MINIMUM_VERSION = Gem::Version.new('9.54')
11
+ VERSION_PATTERN = /\A\d+(?:\.\d+)+\z/
12
+
13
+ def initialize(runner: Runner.new)
14
+ @runner = runner
15
+ end
16
+
17
+ def call(binary)
18
+ validate(parse(@runner.call([binary, '--version']).stdout))
19
+ rescue CompositionError => e
20
+ raise_probe_failure(e)
21
+ end
22
+
23
+ private
24
+
25
+ def parse(output)
26
+ value = output.to_s.lines.first.to_s.strip
27
+ raise_missing_version if value.empty?
28
+ raise_invalid_version unless VERSION_PATTERN.match?(value)
29
+ Gem::Version.new(value)
30
+ end
31
+
32
+ def validate(version)
33
+ return version if version >= MINIMUM_VERSION
34
+
35
+ raise ComposerUnavailableError.new(
36
+ "Ghostscript #{version} is unsupported; version #{MINIMUM_VERSION} or newer is required",
37
+ reason: :unsupported_version,
38
+ detected_version: version.to_s,
39
+ minimum_version: MINIMUM_VERSION.to_s
40
+ )
41
+ end
42
+
43
+ def raise_missing_version
44
+ raise ComposerUnavailableError.new('Ghostscript did not report its version', reason: :missing_version)
45
+ end
46
+
47
+ def raise_invalid_version
48
+ raise ComposerUnavailableError.new('Ghostscript reported an invalid version', reason: :invalid_version)
49
+ end
50
+
51
+ def raise_probe_failure(error)
52
+ raise ComposerUnavailableError.new(
53
+ 'Unable to determine the Ghostscript version',
54
+ reason: :version_unavailable,
55
+ probe_error: error.details
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../embedding'
5
+ require_relative 'ghostscript/locator'
6
+ require_relative 'ghostscript/runner'
7
+
8
+ module Facturx
9
+ module Composers
10
+ class Ghostscript
11
+ INPUT_FILENAME = 'input.pdf'
12
+ OUTPUT_FILENAME = 'output.pdf'
13
+ DEVICE_ARGUMENTS = %w[
14
+ -dBATCH
15
+ -dNOPAUSE
16
+ -sDEVICE=pdfwrite
17
+ -dPDFA=3
18
+ -dPDFACompatibilityPolicy=1
19
+ -sColorConversionStrategy=RGB
20
+ ].freeze
21
+
22
+ Paths = Data.define(:input, :xml, :output)
23
+
24
+ def initialize(locator: Locator.new, runner: Runner.new)
25
+ @locator = locator
26
+ @runner = runner
27
+ end
28
+
29
+ def available?
30
+ @locator.available?
31
+ end
32
+
33
+ def preflight!
34
+ @locator.preflight!
35
+ end
36
+
37
+ def call(pdf:, xml:, profile:)
38
+ config = preflight!
39
+ Dir.mktmpdir('facturx-') do |directory|
40
+ paths = paths_for(directory)
41
+ write_inputs(paths, pdf, xml)
42
+ @runner.call(arguments(config, paths, profile))
43
+ read_output(paths.output)
44
+ end
45
+ rescue SystemCallError => e
46
+ raise_temporary_file_error(e)
47
+ end
48
+
49
+ private
50
+
51
+ def raise_temporary_file_error(error)
52
+ raise CompositionError.new(
53
+ "Ghostscript composition failed while handling temporary files: #{error.message}",
54
+ cause: error.class.name
55
+ )
56
+ end
57
+
58
+ def paths_for(directory)
59
+ Paths.new(
60
+ input: File.join(directory, INPUT_FILENAME),
61
+ xml: File.join(directory, FACTURX_EMBEDDING.filename),
62
+ output: File.join(directory, OUTPUT_FILENAME)
63
+ )
64
+ end
65
+
66
+ def write_inputs(paths, pdf, xml)
67
+ File.binwrite(paths.input, pdf)
68
+ File.binwrite(paths.xml, xml)
69
+ end
70
+
71
+ def arguments(config, paths, profile)
72
+ [config.binary, permitted_files_argument(config, paths)] + DEVICE_ARGUMENTS +
73
+ zugferd_arguments(config, paths, profile) + ['-o', paths.output, config.zugferd_ps, paths.input]
74
+ end
75
+
76
+ def permitted_files_argument(config, paths)
77
+ permitted_files = [
78
+ paths.input,
79
+ paths.xml,
80
+ config.zugferd_ps,
81
+ config.icc_profile
82
+ ].join(File::PATH_SEPARATOR)
83
+
84
+ "--permit-file-read=#{permitted_files}"
85
+ end
86
+
87
+ def zugferd_arguments(config, paths, profile)
88
+ [
89
+ "-sZUGFeRDXMLFile=#{paths.xml}",
90
+ "-sZUGFeRDProfile=#{config.icc_profile}",
91
+ '-sZUGFeRDVersion=2p1',
92
+ "-sZUGFeRDConformanceLevel=#{profile.conformance_level}"
93
+ ]
94
+ end
95
+
96
+ def read_output(path)
97
+ unless File.file?(path) && File.size?(path)
98
+ raise CompositionError, 'Ghostscript did not produce a non-empty PDF'
99
+ end
100
+
101
+ File.binread(path)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ Embedding = Data.define(:filename, :relationship, :document_type, :version, :xmp_namespace)
5
+
6
+ FACTURX_EMBEDDING = Embedding.new(
7
+ filename: 'factur-x.xml',
8
+ relationship: :Alternative,
9
+ document_type: 'INVOICE',
10
+ version: '1.0',
11
+ xmp_namespace: 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#'
12
+ )
13
+
14
+ private_constant :Embedding, :FACTURX_EMBEDDING
15
+ end