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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61ff49d55f56bb55d9b6624eb58fd59e16ed016672464f72507ea21b556c3050
4
- data.tar.gz: 3f6be26b5cb8a65ea5d024cc68c709835154cd67fd48147d4a184182f7938c19
3
+ metadata.gz: 8beb5aea7a14256c4d062dfbf732462016ec2e0eaacafedf016235f085353eee
4
+ data.tar.gz: 24ea8be809ed5b980cb01b2dee2a775c76c21ae717dda9c056182880bfc44e3d
5
5
  SHA512:
6
- metadata.gz: 071dce1b5bb8ca3925152e2c0b5bcebaff0b2d73399d7e681f9407eeb59bb0ba981dbcbc194cb9b8faace203cdddc7ba5b332c22e92b377b3082d2641dacce8b
7
- data.tar.gz: 1ffab4f27f2f0ba54e6ffcf3e5d949aafecde9dd92c758acb1f3a1c9f4a1792794b2c91372d564ec49c917114c715f061d90843a14393fabac44bb0058af03c7
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.1.0...HEAD
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
- All issues currently emitted use the `:error` severity. The `:warning` severity is reserved for planned Schematron diagnostics.
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. Facturx does not yet run the official Schematron business rules, calculate totals, or evaluate accounting consistency.
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 rspec
264
- bundle exec rake build
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
- Supported · Planned · ➖ Intentionally handled outside the gem
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 validation |
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 'output_capture'
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
- @timeout = timeout
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 = execute(argv)
19
+ result = @runner.call(argv)
29
20
  return result if result.exit_status.zero?
30
21
 
31
22
  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
- )
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 wait_for(wait_thread, stderr_reader)
82
- Timeout.timeout(@timeout) { wait_thread.value }
83
- rescue Timeout::Error
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 timed out after #{@timeout} seconds",
87
- timeout: @timeout,
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 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
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
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Facturx
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -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/schema_validator'
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(schema_validator: Xml::SchemaValidator.new)
34
- @schema_validator = schema_validator
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
- @schema_validator.call(document: context.xml, profile:)
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 'schema_validator'
7
+ require_relative 'conformance_validator'
8
8
 
9
9
  module Facturx
10
10
  module Xml
11
11
  class Validator
12
- def initialize(parser: Parser.new, profile_detector: ProfileDetector.new, schema_validator: SchemaValidator.new)
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
- @schema_validator = schema_validator
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
- @schema_validator.call(document:, profile:)
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
@@ -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.first
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.1.0
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