facturx 1.1.0 → 1.2.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/CHANGELOG.md +16 -1
- data/DOCUMENTATION.md +38 -7
- data/README.md +14 -10
- data/lib/facturx/composers/ghostscript/runner.rb +19 -80
- data/lib/facturx/error.rb +1 -0
- data/lib/facturx/schematron_adapter.rb +30 -0
- data/lib/facturx/subprocess.rb +154 -0
- data/lib/facturx/validation.rb +1 -1
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx/writer.rb +5 -5
- data/lib/facturx/xml/conformance_validator.rb +20 -0
- data/lib/facturx/xml/validator.rb +9 -5
- data/lib/facturx/xml/verifier.rb +3 -2
- data/lib/facturx.rb +4 -1
- data/rbi/facturx.rbi +1 -0
- metadata +4 -2
- data/lib/facturx/composers/ghostscript/output_capture.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8beb5aea7a14256c4d062dfbf732462016ec2e0eaacafedf016235f085353eee
|
|
4
|
+
data.tar.gz: 24ea8be809ed5b980cb01b2dee2a775c76c21ae717dda9c056182880bfc44e3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93279c7e5faaebe8aa8d2b4c645f739e6a70517fa530340186621af496b95e3ec29b5f84a99fcb6edfadb0601013e0b945d3aaf2440eb02aa30c395eb06f5f6e
|
|
7
|
+
data.tar.gz: 04af7d886516bebfa086252a99892721b9a9a775271e35abc94641ac40fbfbb70865be020cf93b41123c317ae794d823ec7ce0104119f0a7cdb8829d6ac3ba64
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.0] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Add
|
|
10
|
+
|
|
11
|
+
- Add optional official Schematron validation through the lightweight `facturx-schematron` companion gem.
|
|
12
|
+
- Add Schematron issues and strict errors to the existing validation model for all writing and attachment workflows.
|
|
13
|
+
- Add shared bounded subprocess execution for Ghostscript and SaxonC without loading optional rules in the core gem.
|
|
14
|
+
|
|
15
|
+
### Fix
|
|
16
|
+
|
|
17
|
+
- Preserve non-UTF-8 invoice text when passing parsed XML to SaxonC.
|
|
18
|
+
- Report oversized Schematron output as a resource limit instead of malformed validation output.
|
|
19
|
+
- Keep subprocess timeout diagnostics valid UTF-8 while preserving raw successful output bytes.
|
|
20
|
+
|
|
7
21
|
## [1.1.0] - 2026-09-14
|
|
8
22
|
|
|
9
23
|
### Add
|
|
@@ -58,7 +72,8 @@ All notable changes to this project will be documented in this file.
|
|
|
58
72
|
- Compose PDF/A-3b Factur-X documents with Ghostscript.
|
|
59
73
|
- Extract embedded Factur-X XML from PDF documents.
|
|
60
74
|
|
|
61
|
-
[Unreleased]: https://github.com/AdVitam/facturx/compare/v1.
|
|
75
|
+
[Unreleased]: https://github.com/AdVitam/facturx/compare/v1.2.0...HEAD
|
|
76
|
+
[1.2.0]: https://github.com/AdVitam/facturx/compare/v1.1.0...v1.2.0
|
|
62
77
|
[1.1.0]: https://github.com/AdVitam/facturx/compare/v1.0.0...v1.1.0
|
|
63
78
|
[1.0.0]: https://github.com/AdVitam/facturx/compare/v0.2.0...v1.0.0
|
|
64
79
|
[0.2.0]: https://github.com/AdVitam/facturx/compare/v0.1.0...v0.2.0
|
data/DOCUMENTATION.md
CHANGED
|
@@ -193,14 +193,44 @@ report.issues.each do |issue|
|
|
|
193
193
|
end
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
Core validation issues use the `:error` severity. The optional Schematron validator also preserves official `:warning` findings, which do not invalidate a report.
|
|
197
197
|
|
|
198
|
-
Generated documents pass through two layers:
|
|
198
|
+
Generated documents always pass through two layers:
|
|
199
199
|
|
|
200
200
|
1. semantic conformance checks against the selected profile's business-term cardinalities and supported model mappings;
|
|
201
201
|
2. structural validation against the bundled official profile XSD.
|
|
202
202
|
|
|
203
|
-
Incoming XML passed to `validate_xml` is parsed, resolved to a profile from BT-24, and checked against that profile's XSD.
|
|
203
|
+
Incoming XML passed to `validate_xml` is parsed, resolved to a profile from BT-24, and checked against that profile's XSD. When enabled, Schematron runs after these core checks as a third validation layer.
|
|
204
|
+
|
|
205
|
+
### Optional Schematron validation
|
|
206
|
+
|
|
207
|
+
Install the companion gem to add the official Factur-X 1.09.2 business rules without increasing the core gem's package or runtime footprint:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
gem 'facturx'
|
|
211
|
+
gem 'facturx-schematron', require: 'facturx/schematron'
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Alternatively, require it explicitly after Bundler setup:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
require 'facturx'
|
|
218
|
+
require 'facturx/schematron'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Loading the companion enables Schematron after XSD validation for `validate_xml`, `validate_document`, `attach`, `build_xml`, and `generate`. Public method signatures and report types remain unchanged. `read` stays tolerant and does not validate implicitly.
|
|
222
|
+
|
|
223
|
+
The companion requires SaxonC-HE's `Transform` executable, version 12.10 or newer. Put it in `PATH`, or provide its absolute path:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
export FACTURX_SAXONC_TRANSFORM=/opt/saxonc/bin/Transform
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Loading fails immediately with `Facturx::Schematron::UnavailableError` when the executable is missing, cannot start, or is unsupported. Runtime engine failures raise `Facturx::Schematron::ExecutionError`; they never fall back silently to XSD-only validation.
|
|
230
|
+
|
|
231
|
+
Schematron findings use the `:schematron` layer and expose the official rule ID, test, and flag in `issue.details`. Official warnings keep reports valid; assertions without a warning flag are errors. Strict XML workflows raise `Facturx::SchematronValidationError` for blocking findings.
|
|
232
|
+
|
|
233
|
+
The companion ships only the five official compiled XSLT stylesheets and adjacent code databases. Each successful validation starts one isolated SaxonC subprocess, so applications processing large batches should account for native process startup and memory in their worker sizing.
|
|
204
234
|
|
|
205
235
|
Domain failures use a `Facturx::Error` subclass with structured context in `details`. Common errors include:
|
|
206
236
|
|
|
@@ -210,6 +240,7 @@ Domain failures use a `Facturx::Error` subclass with structured context in `deta
|
|
|
210
240
|
| `Facturx::InvalidXmlError` | XML cannot be parsed |
|
|
211
241
|
| `Facturx::UnknownProfileError` | BT-24 is absent or unsupported during strict XML validation |
|
|
212
242
|
| `Facturx::XsdValidationError` | XML does not satisfy the selected profile XSD |
|
|
243
|
+
| `Facturx::SchematronValidationError` | XML violates an enabled Schematron business rule |
|
|
213
244
|
| `Facturx::UnsupportedProfileError` | A writer profile is unsupported |
|
|
214
245
|
| `Facturx::InvalidDocumentError` | A typed document violates the selected profile |
|
|
215
246
|
| `Facturx::InvalidSourceError` | An XML or reader source is not a byte `String` |
|
|
@@ -220,7 +251,7 @@ Domain failures use a `Facturx::Error` subclass with structured context in `deta
|
|
|
220
251
|
| `Facturx::ExtractionError` | The embedded Factur-X XML cannot be selected or decoded |
|
|
221
252
|
| `Facturx::VerificationError` | The composed PDF does not match the requested invoice |
|
|
222
253
|
|
|
223
|
-
XSD validation is not complete regulatory validation.
|
|
254
|
+
XSD validation alone is not complete regulatory validation. The optional companion runs the official Schematron rules, but Facturx does not calculate invoice values or replace application-level accounting controls.
|
|
224
255
|
|
|
225
256
|
## PDF composition
|
|
226
257
|
|
|
@@ -238,7 +269,7 @@ export FACTURX_ZUGFERD_PS=/opt/ghostscript/share/ghostscript/lib/zugferd.ps
|
|
|
238
269
|
export FACTURX_ICC_PROFILE=/opt/ghostscript/share/ghostscript/iccprofiles/default_rgb.icc
|
|
239
270
|
```
|
|
240
271
|
|
|
241
|
-
The composer invokes Ghostscript as an external process with a timeout, bounded output capture, and file access restricted to its staged inputs and outputs.
|
|
272
|
+
The composer invokes Ghostscript as an external process with a timeout, bounded output capture, and file access restricted to its staged inputs and outputs. The optional Schematron integration reuses the same bounded process lifecycle for SaxonC.
|
|
242
273
|
|
|
243
274
|
Facturx does not create the visual invoice. The supplied PDF remains the visual source that is converted to PDF/A-3b and enriched with Factur-X XML and metadata.
|
|
244
275
|
|
|
@@ -260,8 +291,8 @@ Facturx does not communicate with a PDP or implement e-invoicing transport. Netw
|
|
|
260
291
|
mise install
|
|
261
292
|
bundle install
|
|
262
293
|
bundle exec rubocop
|
|
263
|
-
bundle exec
|
|
264
|
-
bundle exec rake
|
|
294
|
+
bundle exec rake
|
|
295
|
+
bundle exec rake build_all
|
|
265
296
|
```
|
|
266
297
|
|
|
267
298
|
Maintainers can compare the semantic registry, D22B mappings, diagnostics, official XML examples, and paired PDF attachments with an extracted upstream package:
|
data/README.md
CHANGED
|
@@ -26,39 +26,42 @@ Use typed Ruby objects to generate XSD-valid XML, embed it into an existing PDF,
|
|
|
26
26
|
| ✅ | Generate Factur-X XML | `Facturx.build_xml` |
|
|
27
27
|
| ✅ | Validate typed documents | `Facturx.validate_document` |
|
|
28
28
|
| ✅ | Validate XML against official XSDs | `Facturx.validate_xml` |
|
|
29
|
+
| 🔌 | Add official Schematron business-rule validation | `facturx-schematron` |
|
|
29
30
|
| ✅ | Extract the original embedded XML | `Facturx.extract_xml` |
|
|
30
31
|
|
|
31
32
|
### Boundaries
|
|
32
33
|
|
|
33
34
|
| | Capability | Responsibility |
|
|
34
35
|
|:---:|---|---|
|
|
35
|
-
| ⏳ | Schematron business rules | Planned |
|
|
36
36
|
| ➖ | Invoice calculations | Application |
|
|
37
37
|
| ➖ | Visual PDF generation | Application |
|
|
38
38
|
| ➖ | PDP transport and e-invoicing | Application |
|
|
39
39
|
|
|
40
|
-
✅
|
|
40
|
+
✅ Included · 🔌 Optional companion · ➖ Intentionally handled outside the gem
|
|
41
41
|
|
|
42
42
|
## Supported profiles
|
|
43
43
|
|
|
44
44
|
Facturx supports Factur-X 1.09.2 / ZUGFeRD 2.5.2.
|
|
45
45
|
|
|
46
|
-
| Profile | Read | Build | XSD
|
|
47
|
-
|
|
48
|
-
| MINIMUM | ✅ | ✅ | ✅ |
|
|
49
|
-
| BASIC WL | ✅ | ✅ | ✅ |
|
|
50
|
-
| BASIC | ✅ | ✅ | ✅ |
|
|
51
|
-
| EN 16931 | ✅ | ✅ | ✅ |
|
|
52
|
-
| EXTENDED | ✅* | ✅* | ✅ |
|
|
46
|
+
| Profile | Read | Build | XSD | Schematron** |
|
|
47
|
+
|---|:---:|:---:|:---:|:---:|
|
|
48
|
+
| MINIMUM | ✅ | ✅ | ✅ | ✅ |
|
|
49
|
+
| BASIC WL | ✅ | ✅ | ✅ | ✅ |
|
|
50
|
+
| BASIC | ✅ | ✅ | ✅ | ✅ |
|
|
51
|
+
| EN 16931 | ✅ | ✅ | ✅ | ✅ |
|
|
52
|
+
| EXTENDED | ✅* | ✅* | ✅ | ✅ |
|
|
53
53
|
|
|
54
54
|
\* EXTENDED-only fields that are not represented by the typed model remain available in the original XML and are reported through diagnostics.
|
|
55
55
|
|
|
56
|
+
\** Available through the optional `facturx-schematron` gem.
|
|
57
|
+
|
|
56
58
|
## Requirements
|
|
57
59
|
|
|
58
60
|
- Ruby 3.2 or newer
|
|
59
61
|
- Ghostscript 9.54 or newer, `zugferd.ps`, and an RGB ICC profile for PDF composition only
|
|
62
|
+
- SaxonC-HE 12.10 or newer only when `facturx-schematron` is enabled
|
|
60
63
|
|
|
61
|
-
XML building, reading, extraction, and validation require no external executable.
|
|
64
|
+
Core XML building, reading, extraction, and XSD validation require no external executable.
|
|
62
65
|
|
|
63
66
|
## Quick start
|
|
64
67
|
|
|
@@ -104,6 +107,7 @@ See [DOCUMENTATION.md](DOCUMENTATION.md) to:
|
|
|
104
107
|
- validate or attach existing XML;
|
|
105
108
|
- read invoices and handle diagnostics;
|
|
106
109
|
- understand profiles, validation boundaries, and typed errors;
|
|
110
|
+
- enable official Schematron business-rule validation;
|
|
107
111
|
- configure Ghostscript for PDF/A-3b composition.
|
|
108
112
|
|
|
109
113
|
## License
|
|
@@ -1,74 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'open3'
|
|
4
|
-
require 'timeout'
|
|
5
3
|
require_relative '../../error'
|
|
6
|
-
require_relative '
|
|
4
|
+
require_relative '../../subprocess'
|
|
7
5
|
|
|
8
6
|
module Facturx
|
|
9
7
|
module Composers
|
|
10
8
|
class Ghostscript
|
|
11
9
|
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
10
|
def initialize(
|
|
18
|
-
timeout: DEFAULT_TIMEOUT,
|
|
19
|
-
output_limit: DEFAULT_OUTPUT_LIMIT,
|
|
20
|
-
termination_grace: DEFAULT_TERMINATION_GRACE
|
|
11
|
+
timeout: Subprocess::Runner::DEFAULT_TIMEOUT,
|
|
12
|
+
output_limit: Subprocess::Runner::DEFAULT_OUTPUT_LIMIT,
|
|
13
|
+
termination_grace: Subprocess::Runner::DEFAULT_TERMINATION_GRACE
|
|
21
14
|
)
|
|
22
|
-
@
|
|
23
|
-
@output_limit = output_limit
|
|
24
|
-
@termination_grace = termination_grace
|
|
15
|
+
@runner = Subprocess::Runner.new(timeout:, output_limit:, termination_grace:)
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
def call(argv)
|
|
28
|
-
result =
|
|
19
|
+
result = @runner.call(argv)
|
|
29
20
|
return result if result.exit_status.zero?
|
|
30
21
|
|
|
31
22
|
raise_failed(result)
|
|
32
|
-
rescue
|
|
33
|
-
|
|
34
|
-
rescue SystemCallError => e
|
|
35
|
-
raise CompositionError.new(
|
|
36
|
-
"Ghostscript could not be executed: #{e.message}",
|
|
37
|
-
cause: e.class.name
|
|
38
|
-
)
|
|
23
|
+
rescue Subprocess::Error => e
|
|
24
|
+
raise_process_error(e)
|
|
39
25
|
end
|
|
40
26
|
|
|
41
27
|
private
|
|
42
28
|
|
|
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
29
|
def raise_failed(result)
|
|
73
30
|
raise CompositionError.new(
|
|
74
31
|
"Ghostscript failed with exit status #{result.exit_status}",
|
|
@@ -78,39 +35,21 @@ module Facturx
|
|
|
78
35
|
)
|
|
79
36
|
end
|
|
80
37
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
terminate(wait_thread)
|
|
38
|
+
def raise_process_error(error)
|
|
39
|
+
return raise_timeout(error) if error.details[:reason] == :timeout
|
|
40
|
+
|
|
85
41
|
raise CompositionError.new(
|
|
86
|
-
"Ghostscript
|
|
87
|
-
|
|
88
|
-
stderr: sanitize(stderr_reader.value)
|
|
42
|
+
"Ghostscript could not be executed: #{error.details[:message]}",
|
|
43
|
+
cause: error.details[:cause]
|
|
89
44
|
)
|
|
90
45
|
end
|
|
91
46
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
47
|
+
def raise_timeout(error)
|
|
48
|
+
raise CompositionError.new(
|
|
49
|
+
"Ghostscript timed out after #{error.details[:timeout]} seconds",
|
|
50
|
+
timeout: error.details[:timeout],
|
|
51
|
+
stderr: sanitize(error.details[:stderr])
|
|
52
|
+
)
|
|
114
53
|
end
|
|
115
54
|
|
|
116
55
|
def sanitize(output)
|
data/lib/facturx/error.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Facturx
|
|
|
15
15
|
class InvalidXmlError < ValidationError; end
|
|
16
16
|
class UnknownProfileError < InvalidXmlError; end
|
|
17
17
|
class XsdValidationError < InvalidXmlError; end
|
|
18
|
+
class SchematronValidationError < InvalidXmlError; end
|
|
18
19
|
class SchemaLoadError < Error; end
|
|
19
20
|
class InvalidSourceError < Error; end
|
|
20
21
|
class UnsupportedProfileError < Error; end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
module SchematronAdapter
|
|
5
|
+
class NullValidator
|
|
6
|
+
def call(**)
|
|
7
|
+
[]
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@validator = NullValidator.new.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def install(validator)
|
|
16
|
+
raise TypeError, 'Schematron validator must respond to call' unless validator.respond_to?(:call)
|
|
17
|
+
|
|
18
|
+
@mutex.synchronize { @validator = validator }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(document:, profile:)
|
|
22
|
+
@validator.call(document:, profile:)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_constant :NullValidator
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_constant :SchematronAdapter
|
|
30
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module Facturx
|
|
8
|
+
module Subprocess
|
|
9
|
+
Result = Data.define(:stdout, :stderr, :exit_status, :stdout_truncated)
|
|
10
|
+
|
|
11
|
+
class Error < Facturx::Error; end
|
|
12
|
+
|
|
13
|
+
class OutputCapture
|
|
14
|
+
TRUNCATION_MARKER = '...[truncated]'
|
|
15
|
+
Output = Data.define(:content, :truncated)
|
|
16
|
+
|
|
17
|
+
def initialize(stream, limit:)
|
|
18
|
+
@stream = stream
|
|
19
|
+
@limit = limit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
buffer = +''.b
|
|
24
|
+
truncated = false
|
|
25
|
+
while (chunk = @stream.read(4_096))
|
|
26
|
+
remaining = @limit - buffer.bytesize
|
|
27
|
+
buffer << chunk.byteslice(0, remaining) if remaining.positive?
|
|
28
|
+
truncated ||= chunk.bytesize > remaining
|
|
29
|
+
end
|
|
30
|
+
Output.new(content: truncate(buffer, truncated), truncated:)
|
|
31
|
+
rescue IOError
|
|
32
|
+
Output.new(content: buffer, truncated:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def truncate(buffer, truncated)
|
|
38
|
+
return buffer unless truncated
|
|
39
|
+
|
|
40
|
+
marker = TRUNCATION_MARKER.byteslice(0, @limit)
|
|
41
|
+
kept_bytes = [@limit - marker.bytesize, 0].max
|
|
42
|
+
buffer.byteslice(0, kept_bytes).to_s << marker
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Runner
|
|
47
|
+
DEFAULT_TIMEOUT = 60
|
|
48
|
+
DEFAULT_OUTPUT_LIMIT = 8_192
|
|
49
|
+
DEFAULT_TERMINATION_GRACE = 1
|
|
50
|
+
|
|
51
|
+
def initialize(
|
|
52
|
+
timeout: DEFAULT_TIMEOUT,
|
|
53
|
+
output_limit: DEFAULT_OUTPUT_LIMIT,
|
|
54
|
+
termination_grace: DEFAULT_TERMINATION_GRACE
|
|
55
|
+
)
|
|
56
|
+
@timeout = timeout
|
|
57
|
+
@output_limit = output_limit
|
|
58
|
+
@termination_grace = termination_grace
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call(argv, input: nil, chdir: nil)
|
|
62
|
+
execute(argv, input:, chdir:)
|
|
63
|
+
rescue SystemCallError => e
|
|
64
|
+
raise Error.new('Process could not be executed', reason: :spawn, cause: e.class.name, message: e.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def execute(argv, input:, chdir:)
|
|
70
|
+
options = { pgroup: true }
|
|
71
|
+
options[:chdir] = chdir if chdir
|
|
72
|
+
Open3.popen3(*argv, **options) do |stdin, stdout, stderr, wait_thread|
|
|
73
|
+
capture_process(stdin, stdout, stderr, wait_thread, input)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def capture_process(stdin, stdout, stderr, wait_thread, input)
|
|
78
|
+
stdin_writer = write(stdin, input)
|
|
79
|
+
stdout_reader = capture(stdout)
|
|
80
|
+
stderr_reader = capture(stderr)
|
|
81
|
+
build_result(wait_thread, stdout_reader, stderr_reader)
|
|
82
|
+
ensure
|
|
83
|
+
stdin_writer&.join
|
|
84
|
+
stdout_reader&.join
|
|
85
|
+
stderr_reader&.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write(stdin, input)
|
|
89
|
+
Thread.new do
|
|
90
|
+
stdin.binmode
|
|
91
|
+
stdin.write(input) if input
|
|
92
|
+
rescue Errno::EPIPE, IOError
|
|
93
|
+
nil
|
|
94
|
+
ensure
|
|
95
|
+
stdin.close
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def capture(stream)
|
|
100
|
+
Thread.new { OutputCapture.new(stream, limit: @output_limit).call }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_result(wait_thread, stdout_reader, stderr_reader)
|
|
104
|
+
status = wait_for(wait_thread, stderr_reader)
|
|
105
|
+
stdout = stdout_reader.value
|
|
106
|
+
stderr = stderr_reader.value
|
|
107
|
+
Result.new(
|
|
108
|
+
stdout: stdout.content,
|
|
109
|
+
stderr: stderr.content,
|
|
110
|
+
exit_status: status.exitstatus || (128 + status.termsig),
|
|
111
|
+
stdout_truncated: stdout.truncated
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def wait_for(wait_thread, stderr_reader)
|
|
116
|
+
Timeout.timeout(@timeout) { wait_thread.value }
|
|
117
|
+
rescue Timeout::Error
|
|
118
|
+
terminate(wait_thread)
|
|
119
|
+
raise Error.new(
|
|
120
|
+
"Process timed out after #{@timeout} seconds",
|
|
121
|
+
reason: :timeout,
|
|
122
|
+
timeout: @timeout,
|
|
123
|
+
stderr: sanitize(stderr_reader.value.content)
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def terminate(wait_thread)
|
|
128
|
+
signal('TERM', wait_thread.pid)
|
|
129
|
+
return if wait_thread.join(@termination_grace)
|
|
130
|
+
|
|
131
|
+
signal('KILL', wait_thread.pid)
|
|
132
|
+
wait_thread.join
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sanitize(output)
|
|
136
|
+
output.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def signal(name, pid)
|
|
140
|
+
Process.kill(name, -pid)
|
|
141
|
+
rescue Errno::ESRCH
|
|
142
|
+
nil
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
begin
|
|
145
|
+
Process.kill(name, pid)
|
|
146
|
+
rescue Errno::ESRCH
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private_constant :OutputCapture
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/facturx/validation.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative 'model/immutable'
|
|
|
5
5
|
|
|
6
6
|
module Facturx
|
|
7
7
|
module Validation
|
|
8
|
-
LAYERS = %i[syntax profile document xsd].freeze
|
|
8
|
+
LAYERS = %i[syntax profile document xsd schematron].freeze
|
|
9
9
|
SEVERITIES = %i[error warning].freeze
|
|
10
10
|
ISSUE_DEFAULTS = {
|
|
11
11
|
severity: :error,
|
data/lib/facturx/version.rb
CHANGED
data/lib/facturx/writer.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative 'writer/stages/trade_lines'
|
|
|
11
11
|
require_relative 'writer/stages/trade_agreement'
|
|
12
12
|
require_relative 'writer/stages/trade_delivery'
|
|
13
13
|
require_relative 'writer/stages/trade_settlement'
|
|
14
|
-
require_relative 'xml/
|
|
14
|
+
require_relative 'xml/conformance_validator'
|
|
15
15
|
require_relative 'xml/report_builder'
|
|
16
16
|
|
|
17
17
|
module Facturx
|
|
@@ -30,8 +30,8 @@ module Facturx
|
|
|
30
30
|
raise 'Factur-X writer stages must cover every modeled term exactly once'
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def initialize(
|
|
34
|
-
@
|
|
33
|
+
def initialize(conformance_validator: Xml::ConformanceValidator.new)
|
|
34
|
+
@conformance_validator = conformance_validator
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def call(document:, profile:)
|
|
@@ -60,8 +60,8 @@ module Facturx
|
|
|
60
60
|
report = context.tracker.report
|
|
61
61
|
return report if report.invalid?
|
|
62
62
|
|
|
63
|
-
@
|
|
64
|
-
report
|
|
63
|
+
issues = @conformance_validator.call(document: context.xml, profile:)
|
|
64
|
+
report.with(issues: report.issues + issues)
|
|
65
65
|
rescue XsdValidationError => e
|
|
66
66
|
Xml::ReportBuilder.xsd(profile, e)
|
|
67
67
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../schematron_adapter'
|
|
4
|
+
require_relative 'schema_validator'
|
|
5
|
+
|
|
6
|
+
module Facturx
|
|
7
|
+
module Xml
|
|
8
|
+
class ConformanceValidator
|
|
9
|
+
def initialize(schema_validator: SchemaValidator.new, schematron_validator: SchematronAdapter)
|
|
10
|
+
@schema_validator = schema_validator
|
|
11
|
+
@schematron_validator = schematron_validator
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(document:, profile:)
|
|
15
|
+
@schema_validator.call(document:, profile:)
|
|
16
|
+
@schematron_validator.call(document:, profile:)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -4,22 +4,26 @@ require_relative '../validation'
|
|
|
4
4
|
require_relative 'parser'
|
|
5
5
|
require_relative 'profile_detector'
|
|
6
6
|
require_relative 'report_builder'
|
|
7
|
-
require_relative '
|
|
7
|
+
require_relative 'conformance_validator'
|
|
8
8
|
|
|
9
9
|
module Facturx
|
|
10
10
|
module Xml
|
|
11
11
|
class Validator
|
|
12
|
-
def initialize(
|
|
12
|
+
def initialize(
|
|
13
|
+
parser: Parser.new,
|
|
14
|
+
profile_detector: ProfileDetector.new,
|
|
15
|
+
conformance_validator: ConformanceValidator.new
|
|
16
|
+
)
|
|
13
17
|
@parser = parser
|
|
14
18
|
@profile_detector = profile_detector
|
|
15
|
-
@
|
|
19
|
+
@conformance_validator = conformance_validator
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def call(xml:)
|
|
19
23
|
document = @parser.call(xml:)
|
|
20
24
|
profile = @profile_detector.call(document:)
|
|
21
|
-
@
|
|
22
|
-
Validation::Report.new(profile:)
|
|
25
|
+
issues = @conformance_validator.call(document:, profile:)
|
|
26
|
+
Validation::Report.new(profile:, issues:)
|
|
23
27
|
rescue UnknownProfileError => e
|
|
24
28
|
ReportBuilder.profile(e)
|
|
25
29
|
rescue XsdValidationError => e
|
data/lib/facturx/xml/verifier.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Facturx
|
|
|
8
8
|
ERROR_CLASSES = {
|
|
9
9
|
syntax: InvalidXmlError,
|
|
10
10
|
profile: UnknownProfileError,
|
|
11
|
-
xsd: XsdValidationError
|
|
11
|
+
xsd: XsdValidationError,
|
|
12
|
+
schematron: SchematronValidationError
|
|
12
13
|
}.freeze
|
|
13
14
|
private_constant :ERROR_CLASSES
|
|
14
15
|
|
|
@@ -20,7 +21,7 @@ module Facturx
|
|
|
20
21
|
report = @validator.call(xml:)
|
|
21
22
|
return report.profile if report.valid?
|
|
22
23
|
|
|
23
|
-
issue = report.issues.
|
|
24
|
+
issue = report.issues.find { |candidate| candidate.severity == :error }
|
|
24
25
|
error_class = ERROR_CLASSES.fetch(issue.layer)
|
|
25
26
|
raise error_class.new(issue.message, report:, issues: report.issues)
|
|
26
27
|
end
|
data/lib/facturx.rb
CHANGED
|
@@ -17,6 +17,9 @@ require 'facturx/reading'
|
|
|
17
17
|
require 'facturx/coerce'
|
|
18
18
|
require 'facturx/format'
|
|
19
19
|
require 'facturx/validation'
|
|
20
|
+
require 'facturx/schematron_adapter'
|
|
21
|
+
require 'facturx/subprocess'
|
|
22
|
+
require 'facturx/xml/conformance_validator'
|
|
20
23
|
require 'facturx/xml/validator'
|
|
21
24
|
require 'facturx/xml/verifier'
|
|
22
25
|
require 'facturx/pdf/document'
|
|
@@ -81,5 +84,5 @@ module Facturx
|
|
|
81
84
|
|
|
82
85
|
private_constant :Attach, :Builders, :Coerce, :CoercionError, :Composers, :Format, :FormattingError,
|
|
83
86
|
:Generate, :Group, :Model, :Pdf, :ProfileResolver, :Reader, :SourceReader, :Term,
|
|
84
|
-
:TermDeclarations, :Terms, :Writer, :Xml
|
|
87
|
+
:SchematronAdapter, :Subprocess, :TermDeclarations, :Terms, :Writer, :Xml
|
|
85
88
|
end
|
data/rbi/facturx.rbi
CHANGED
|
@@ -16,6 +16,7 @@ module Facturx
|
|
|
16
16
|
class InvalidXmlError < ValidationError; end
|
|
17
17
|
class UnknownProfileError < InvalidXmlError; end
|
|
18
18
|
class XsdValidationError < InvalidXmlError; end
|
|
19
|
+
class SchematronValidationError < InvalidXmlError; end
|
|
19
20
|
class SchemaLoadError < Error; end
|
|
20
21
|
class InvalidSourceError < Error; end
|
|
21
22
|
class UnsupportedProfileError < Error; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: facturx
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- AdVitam
|
|
@@ -66,7 +66,6 @@ files:
|
|
|
66
66
|
- lib/facturx/composers/ghostscript.rb
|
|
67
67
|
- lib/facturx/composers/ghostscript/defaults.rb
|
|
68
68
|
- lib/facturx/composers/ghostscript/locator.rb
|
|
69
|
-
- lib/facturx/composers/ghostscript/output_capture.rb
|
|
70
69
|
- lib/facturx/composers/ghostscript/runner.rb
|
|
71
70
|
- lib/facturx/composers/ghostscript/version_probe.rb
|
|
72
71
|
- lib/facturx/diagnostic.rb
|
|
@@ -130,7 +129,9 @@ files:
|
|
|
130
129
|
- lib/facturx/schema/LICENSE-APACHE-2.0.txt
|
|
131
130
|
- lib/facturx/schema/NOTICE.md
|
|
132
131
|
- lib/facturx/schema/SHA256SUMS
|
|
132
|
+
- lib/facturx/schematron_adapter.rb
|
|
133
133
|
- lib/facturx/source_reader.rb
|
|
134
|
+
- lib/facturx/subprocess.rb
|
|
134
135
|
- lib/facturx/term.rb
|
|
135
136
|
- lib/facturx/terms.rb
|
|
136
137
|
- lib/facturx/terms/declaration.rb
|
|
@@ -172,6 +173,7 @@ files:
|
|
|
172
173
|
- lib/facturx/writer/stages/trade_settlement/summary.rb
|
|
173
174
|
- lib/facturx/writer/stages/trade_settlement/taxes.rb
|
|
174
175
|
- lib/facturx/writer/tracker.rb
|
|
176
|
+
- lib/facturx/xml/conformance_validator.rb
|
|
175
177
|
- lib/facturx/xml/namespaces.rb
|
|
176
178
|
- lib/facturx/xml/parser.rb
|
|
177
179
|
- lib/facturx/xml/profile_detector.rb
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|