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
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
|