sevgi-sundries 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20ba96c5308412cca2773c3678698968da83f06814ccdcd9b2d64c9ee522312c
4
- data.tar.gz: 52b7e80ff6e5828ca83359eeeca6e593440f1dcd74943142987a1a7f149a5a95
3
+ metadata.gz: 31d7fd62cc7b0a3897e7a33e780d9ee367e1e782e5022eb5caabacd87633f98c
4
+ data.tar.gz: c639aa1e07d51c657d8b97400bf9a0696d60a697e87bb7eca94db5d7be5a9b08
5
5
  SHA512:
6
- metadata.gz: 4a899ba87fcb53cd0652e27b587ecfe2ec5d8b184e23c27f114ae08b001aa30b3f9d06497ff921738c8fe7482e212aed5bd0cef71f9e221e9169e855c43faa99
7
- data.tar.gz: 218c824c2748b155e8d2005df3e8176a6de4b9e80780cba56659ddac4987481d42c23e34b23b4c040d5f5a68d946436ae91640f43d68b94d3f5f841afd9273e8
6
+ metadata.gz: 4dad57c233fc530511b8385d3aedac7a8884957ca57472b56befcbd19561c193f9001c1c26442d99ef43e60ae4e452b2da0a2a27f1ef42a3b6f2f2a15201d533
7
+ data.tar.gz: 9d49982dabad9bb3cce57b38fa3e8bc229ce2b105671505e0a10dfe32d6fbe7c7cf0cad5e7a85d23b9d301b6981b409e49d7e039d83f4614dd9bc593ba6dcda3
data/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## Unreleased
7
+
8
+ ## 1.1.0 - 2026-10-29
9
+
10
+ ### Added
11
+
12
+ - `Classes` returns an element's distinct CSS class names without changing the element; `Classify` shares its attribute normalization.
13
+ - Generic document-local Style declarations with `Target`, `Param`, `Group`, and `Profiles`, and optional prepared PDF export through the native Mainz CLI.
14
+ - Backend-independent `fallback: false` for export failures and `F.executable` for executable path lookup.
15
+ - `F.path` for path validation and expansion, and `F.output_path` for default output names and directory targets.
16
+
17
+ ### Changed
18
+
19
+ - Moved Graphics XML validation into auxiliary helpers and kept built-in paper dimensions in `Paper`.
20
+ - Shared path validation between Graphics writers and Sundries exports while preserving output paths and error messages.
21
+
22
+ ### Fixed
23
+
24
+ - Stamp code placeholders in nested and shared PDF Forms, with cycle-safe resource traversal.
25
+
6
26
  ## 1.0.0 - 2026-09-16
7
27
 
8
28
  ### Added
data/README.md CHANGED
@@ -28,6 +28,41 @@ grid.canvas.margin.to_a # => [5.0, 5.0, 5.0, 5.0]
28
28
  Rulers, grids, and tiles are inspectable Ruby values. They create no SVG elements by themselves. Pass their geometry
29
29
  to Sevgi Graphics when the document needs it.
30
30
 
31
+ ## Prepared PDF export
32
+
33
+ Sevgi uses the native Mainz CLI for SVG sources with a Style declaration.
34
+ Mainz receives private temporary files through argument arrays and publishes one prepared PDF.
35
+ Successful preparation replaces the destination atomically.
36
+ Parallel calls use separate temporary directories and share no producer state.
37
+
38
+ ```ruby
39
+ require "sevgi/sundries"
40
+
41
+ Sevgi::Sundries::Export.call(svg, "drawing.pdf", style: declaration)
42
+ Sevgi::Sundries::Export.call(svg, "prepared.pdf", style: declaration, fallback: false)
43
+ Sevgi::Sundries::Mainz.produce(svg, "prepared.pdf", declaration: declaration)
44
+ Sevgi::Sundries::Mainz.available?
45
+ ```
46
+
47
+ `declaration` is a JSON-compatible Hash. `drawing.Style` returns its deeply frozen Hash representation.
48
+ The default `fallback: true` warns and uses ordinary PDF export if Mainz cannot prepare the document.
49
+ `fallback: false` raises `Sevgi::Sundries::Mainz::Error` if preparation fails. An unstyled document uses ordinary export.
50
+ `Mainz.produce` always requires preparation and uses the same export validation.
51
+ Invalid arguments and filesystem failures remain errors in both modes.
52
+ Interrupts cancel export without fallback.
53
+ CSS insertion and the source callback run once, including after a fallback.
54
+
55
+ Mainz is optional. Install its native `mainz` executable on `PATH` to enable preparation.
56
+ Install [Mainz 1.0.0](https://github.com/roktas/mainz/releases/tag/v1.0.0) through Homebrew:
57
+
58
+ ```sh
59
+ brew install roktas/tap/mainz
60
+ ```
61
+
62
+ Prepared export invokes the CLI without the `cairo`, `rsvg2`, and `hexapdf` Ruby gems.
63
+ Ordinary PDF/PNG export and PDF stamping retain their optional Ruby dependencies.
64
+ `Mainz.fingerprint` returns the executable digest and native dependency report for output caches, or nil when Mainz is absent.
65
+
31
66
  ## Ruby compatibility
32
67
 
33
68
  Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
@@ -37,7 +72,9 @@ Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the d
37
72
  Basic ruler, grid, and tile helpers need only Ruby dependencies. Installing `sevgi-sundries` does not install native
38
73
  export gems.
39
74
 
40
- PDF/PNG export helpers load the optional Ruby gems `cairo`, `rsvg2`, and `hexapdf` only when export is used. Install
75
+ The `a5_on_a4` helpers require the `pdfcpu` command. They use its built-in defaults without personal pdfcpu configuration.
76
+
77
+ Ordinary PDF/PNG export helpers load the optional Ruby gems `cairo`, `rsvg2`, and `hexapdf` only when export is used. Install
41
78
  their system libraries and gems separately:
42
79
 
43
80
  ```sh
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sevgi
6
+ module Sundries
7
+ module Export
8
+ # Validated inputs shared by ordinary and prepared export.
9
+ # @api private
10
+ class Input
11
+ attr_reader :dimensions, :fallback, :style, :svg
12
+
13
+ def initialize(svg, options, &block)
14
+ @fallback = options.fetch(:fallback)
15
+ ArgumentError.("Export fallback must be true or false") unless [true, false].any? { it.equal?(fallback) }
16
+ @dimensions = %i[width height dpi].to_h do |key|
17
+ [key, dimension(options[key], key.to_s, optional: key != :dpi)]
18
+ end
19
+ @style = declaration(options[:style])
20
+ @svg = source(svg, options[:css], &block).dup.freeze
21
+ end
22
+
23
+ private
24
+
25
+ def declaration(value)
26
+ return if value.nil?
27
+ ArgumentError.("Export Style must be a Hash") unless value.is_a?(::Hash)
28
+ JSON.generate(value, strict: true, max_nesting: 32).freeze
29
+ rescue JSON::JSONError => e
30
+ ArgumentError.("Invalid Style JSON: #{e.message}")
31
+ end
32
+
33
+ def source(svg, css)
34
+ ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
35
+ css = stylesheet(css)
36
+ svg = Export.send(:styled, svg, css) if css
37
+ svg = yield(svg) if block_given?
38
+ ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
39
+ svg
40
+ end
41
+
42
+ def stylesheet(css)
43
+ return if css.nil?
44
+ ArgumentError.("Export CSS must be a String") unless css.is_a?(String)
45
+ ArgumentError.("Export CSS must be valid text") unless css.valid_encoding?
46
+ css unless css.strip.empty?
47
+ end
48
+
49
+ # Numeric coercion and finite positive admission are one boundary check.
50
+ # rubocop:disable-next Metrics/CyclomaticComplexity
51
+ def dimension(value, field, optional: true)
52
+ return if value.nil? && optional
53
+ ExportError.(dimension_error(field)) unless value.is_a?(::Numeric)
54
+ number = begin
55
+ value.to_f
56
+ rescue ::StandardError => e
57
+ ExportError.(dimension_error(field, e.message))
58
+ end
59
+ ExportError.(dimension_error(field)) unless number.is_a?(::Float) && number.finite? && number.positive?
60
+ number
61
+ end
62
+
63
+ def dimension_error(field, detail = nil)
64
+ message = [
65
+ (%w[width height].include?(field) ? "Invalid export dimensions" : "Invalid export #{field}"),
66
+ detail
67
+ ]
68
+ message.compact.join(": ")
69
+ end
70
+ end
71
+
72
+ private_constant :Input
73
+ end
74
+ end
75
+ end
@@ -13,43 +13,13 @@ module Sevgi
13
13
  module Sundries
14
14
  module Export
15
15
  %i[
16
- call
17
16
  stamp
18
17
  stamp!
19
18
  ].each { remove_method(it) if method_defined?(it) }
20
19
 
21
- # Exports SVG source to a PDF or PNG file using librsvg and Cairo.
22
- # @param svg [String] SVG source content
23
- # Relative paths are expanded, missing parent directories are created after all render inputs validate, and an
24
- # existing output file is replaced. Directory paths are not expanded to a default file name.
25
- # @param output [String, #to_path] output file path
26
- # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
27
- # @param width [Numeric, nil] finite positive target width in output pixels for PNG, or CSS pixels before PDF point conversion
28
- # @param height [Numeric, nil] finite positive target height in output pixels for PNG, or CSS pixels before PDF point conversion
29
- # @param dpi [Numeric] finite positive CSS pixel density. Omission uses {DEFAULT_DPI}, but explicit nil is invalid
30
- # @param css [String, nil] CSS inserted before the closing svg tag before rendering
31
- # @yield [svg] optional source transformation applied before rendering
32
- # @yieldparam svg [String] SVG source after optional CSS injection
33
- # @yieldreturn [String] SVG source to render
34
- # @return [String] expanded output path
35
- # @raise [Sevgi::ArgumentError] when output is blank, invalid, or a directory, or CSS/transformed SVG has an
36
- # invalid type
37
- # @raise [Sevgi::Sundries::Export::ExportError] when format, numeric options, CSS insertion, SVG parsing, SVG dimensions, or render dimensions are invalid
38
- # @raise [SystemCallError] when the output directory or file cannot be created or written
39
- def call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
40
- ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
41
- output = output_path(output)
42
- format = format_for(format, output)
43
- width = dimension(width, "width")
44
- height = dimension(height, "height")
45
- dpi = dimension(dpi, "dpi", optional: false)
46
- ArgumentError.("Export CSS must be a String") unless css.nil? || css.is_a?(String)
47
- ArgumentError.("Export CSS must be valid text") if css && !css.valid_encoding?
48
-
49
- svg = styled(svg, css) if css && !css.strip.empty?
50
- svg = block.call(svg) if block
51
- ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
52
-
20
+ # Renders validated, transformed SVG through librsvg and Cairo.
21
+ # @api private
22
+ def render(svg, output, format:, width:, height:, dpi:)
53
23
  renderer = Renderer.method(format)
54
24
 
55
25
  begin
@@ -82,6 +52,30 @@ module Sevgi
82
52
 
83
53
  output
84
54
  end
55
+ private :render
56
+
57
+ # @api private
58
+ def stamp_forms(document, stamp:, placeholder:)
59
+ pending = document.pages.flat_map { |page| page[:Resources][:XObject]&.each&.map { |_name, value| value } || [] }
60
+ seen = {}
61
+ replacements = 0
62
+ until pending.empty?
63
+ form = pending.pop
64
+ next unless form[:Subtype] == :Form
65
+ identity = [form.oid, form.gen]
66
+ next if seen[identity]
67
+ seen[identity] = true
68
+ objects = form[:Resources]&.[](:XObject)
69
+ pending.concat(objects ? objects.each.map { |_name, value| value } : [])
70
+ data, count = stamp_stream(form.stream, stamp:, placeholder:)
71
+ next if count.zero?
72
+ replacements += count
73
+ form.stream = data
74
+ form.set_filter(:FlateDecode)
75
+ end
76
+ replacements
77
+ end
78
+ private :stamp_forms
85
79
 
86
80
  # Replaces exact placeholder text objects in PDF streams.
87
81
  # The placeholder must appear as a PDF literal string in a white text object matching the export stamp pattern.
@@ -107,6 +101,7 @@ module Sevgi
107
101
  doc.deref(page[:Contents]).set_filter(:FlateDecode)
108
102
  end
109
103
 
104
+ replacements += stamp_forms(doc, stamp:, placeholder:)
110
105
  doc.write(outfile, optimize: true) if replacements.positive?
111
106
  replacements.positive?
112
107
  rescue HexaPDF::Error, ::SystemCallError => e
@@ -317,45 +312,6 @@ module Sevgi
317
312
  class << self
318
313
  private
319
314
 
320
- def output_path(output)
321
- ArgumentError.("Export output must be provided") if output.nil?
322
-
323
- path = output.respond_to?(:to_path) ? output.to_path : output
324
- ArgumentError.("Export output must be a String or path-like object") unless path.is_a?(::String)
325
- ArgumentError.("Export output must be provided") if path.strip.empty?
326
-
327
- path = ::File.expand_path(path)
328
- ArgumentError.("Export output must name a file") if ::File.directory?(path)
329
- path
330
- rescue ::StandardError => e
331
- raise if e.is_a?(::Sevgi::ArgumentError)
332
-
333
- ArgumentError.("Export output must be a String or path-like object: #{e.message}")
334
- end
335
-
336
- def dimension(value, field, optional: true)
337
- return if value.nil? && optional
338
- ExportError.(dimension_error(field)) unless value.is_a?(::Numeric)
339
-
340
- number = begin
341
- value.to_f
342
- rescue ::StandardError => e
343
- ExportError.(dimension_error(field, e.message))
344
- end
345
-
346
- ExportError.(dimension_error(field)) unless number.is_a?(::Float) && number.finite? && number.positive?
347
-
348
- number
349
- end
350
-
351
- def dimension_error(field, detail = nil)
352
- message = [
353
- (%w[width height].include?(field) ? "Invalid export dimensions" : "Invalid export #{field}"),
354
- detail
355
- ]
356
- message.compact.join(": ")
357
- end
358
-
359
315
  def intrinsic_size(handle)
360
316
  if handle.respond_to?(:intrinsic_dimensions)
361
317
  has_width, width, has_height, height, has_viewbox, viewbox = handle.intrinsic_dimensions
@@ -13,7 +13,9 @@ module Sevgi
13
13
  # @raise [Sevgi::Error] when pdfcpu is missing or the command fails
14
14
  # @raise [Errno::ENOENT] when the executable cannot be spawned
15
15
  # @see https://pdfcpu.io/ pdfcpu
16
- def a5_on_a4(infile, outfile) = F.sh!("pdfcpu", "nup", "--", "form:A4L, border:off", outfile, "2", infile)
16
+ def a5_on_a4(infile, outfile)
17
+ F.sh!("pdfcpu", "--conf", "disable", "nup", "--", "form:A4L, border:off", outfile, "2", infile)
18
+ end
17
19
 
18
20
  # Replaces a PDF file with an A5-on-A4 layout generated by pdfcpu.
19
21
  # @param infile [String] PDF file path to modify
@@ -3,6 +3,8 @@
3
3
  require "sevgi/function"
4
4
 
5
5
  require_relative "export/system"
6
+ require_relative "export/input"
7
+ require_relative "mainz"
6
8
 
7
9
  module Sevgi
8
10
  module Sundries
@@ -16,7 +18,7 @@ module Sevgi
16
18
  # Export-only CSS is a last-minute adjustment, not a replacement for document styles or validation. Insertion
17
19
  # requires well-formed SVG ending in an unprefixed `</svg>` followed only by XML whitespace. Self-closing roots,
18
20
  # prefixed roots, and trailing comments are unsupported with CSS. CSS is XML-escaped but not parsed or validated.
19
- # The native source callback runs after insertion and before conversion. External backends have no such callback.
21
+ # The source callback runs after insertion and before ordinary or prepared conversion.
20
22
  #
21
23
  # @example Export SVG source to a sized PNG
22
24
  # svg = Sevgi::Graphics.SVG(width: 10, height: 10) { circle cx: 5, cy: 5, r: 4 }.Render
@@ -51,27 +53,40 @@ module Sevgi
51
53
 
52
54
  private_constant :NATIVE_COMPONENTS
53
55
 
54
- # @overload call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil)
55
- # Exports SVG source to a PDF or PNG file using the optional native export gems.
56
- # @param svg [String] SVG source content
57
- # Relative paths are expanded, missing parent directories are created after all render inputs validate, and an
58
- # existing output file is replaced. Directory paths are not expanded to a default file name.
59
- # @param output [String, #to_path] output file path
60
- # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
61
- # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
62
- # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
63
- # @param dpi [Numeric] finite positive CSS pixel density. Omission uses {DEFAULT_DPI}, but explicit nil is invalid
64
- # @param css [String, nil] CSS inserted before the closing svg tag before rendering
65
- # @yield [svg] optional source transformation applied before rendering
66
- # @yieldparam svg [String] SVG source after optional CSS injection
67
- # @yieldreturn [String] SVG source to render
68
- # @return [String] expanded output path
69
- # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank, invalid, or a directory
70
- # @raise [Sevgi::MissingComponentError] when cairo, hexapdf, or rsvg2 is unavailable
71
- # @raise [Sevgi::Sundries::Export::ExportError] when format, CSS insertion, SVG parsing, SVG dimensions, or
72
- # render dimensions are invalid
73
- # @raise [SystemCallError] when the output directory or file cannot be created or written
74
- def call(*args, **kwargs, &block) = native!.call(*args, **kwargs, &block)
56
+ # rubocop:disable Metrics/ParameterLists
57
+
58
+ # Exports SVG source through ordinary rendering or optional Mainz preparation.
59
+ # Style selects Mainz automatically for PDF output. Unstyled documents use ordinary export.
60
+ # Fallback warns and uses ordinary PDF export after a preparation failure.
61
+ # CSS and the source callback run once before either backend receives the source.
62
+ # @param svg [String] SVG source content
63
+ # @param output [String, #to_path] output file path
64
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
65
+ # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
66
+ # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
67
+ # @param dpi [Numeric] finite positive CSS pixel density. Omission uses {DEFAULT_DPI}, but explicit nil is invalid
68
+ # @param css [String, nil] CSS inserted before the closing svg tag before rendering
69
+ # @param style [Hash, nil] optional JSON authoring declaration
70
+ # @param fallback [Boolean] permit ordinary export after prepared export fails
71
+ # @yield [svg] optional source transformation before rendering
72
+ # @yieldparam svg [String] SVG source after optional CSS insertion
73
+ # @yieldreturn [String] SVG source to render
74
+ # @return [String] expanded output path
75
+ # @raise [Sevgi::ArgumentError] when the source, path, declaration, or fallback value is invalid
76
+ # @raise [Sevgi::MissingComponentError] when ordinary export requires unavailable native gems
77
+ # @raise [Sevgi::Sundries::Export::ExportError] when format, dimensions, CSS insertion, or ordinary rendering fails
78
+ # @raise [Sevgi::Sundries::Mainz::Error] when preparation fails and fallback is false
79
+ # @raise [SystemCallError] when the output directory or file cannot be written
80
+ def call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil,
81
+ style: nil, fallback: true, &block)
82
+ output = output_path(output)
83
+ format = format_for(format, output)
84
+ input = Input.new(svg, {width:, height:, dpi:, css:, style:, fallback:}, &block)
85
+ return output if format == :pdf && prepared(input, output)
86
+ native!
87
+ render(input.svg, output, format:, **input.dimensions)
88
+ end
89
+ # rubocop:enable Metrics/ParameterLists
75
90
 
76
91
  def format_for(format, output)
77
92
  if format
@@ -135,6 +150,26 @@ module Sevgi
135
150
  class << self
136
151
  private
137
152
 
153
+ def prepared(input, output)
154
+ return false unless input.style
155
+ Mainz.send(:write, input, output)
156
+ true
157
+ rescue Mainz::Error => e
158
+ raise unless input.fallback
159
+ warn("#{e.message}; exporting an ordinary PDF without editable styles")
160
+ false
161
+ end
162
+
163
+ def output_path(output)
164
+ ArgumentError.("Export output must be provided") if output.nil?
165
+ path = F.path(output, context: "Export output")
166
+ ArgumentError.("Export output must name a file") if ::File.directory?(path)
167
+ path
168
+ rescue ::StandardError => e
169
+ raise if e.is_a?(::Sevgi::ArgumentError)
170
+ ArgumentError.("Export output must be a String or path-like object: #{e.message}")
171
+ end
172
+
138
173
  def native!
139
174
  require_relative "export/native"
140
175
 
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tmpdir"
7
+
8
+ module Sevgi
9
+ module Sundries
10
+ # Optional CLI boundary for Mainz prepared PDF production.
11
+ module Mainz
12
+ COMMAND = "mainz"
13
+ private_constant :COMMAND
14
+
15
+ # A missing or unsuccessful Mainz producer.
16
+ Error = Class.new(::Sevgi::Error)
17
+
18
+ # Reports whether the Mainz executable is installed on PATH.
19
+ # @return [Boolean]
20
+ def available? = F.executable?(COMMAND)
21
+
22
+ # Returns the selected executable's content identity for output caches.
23
+ # PATH is resolved on each call. Absence returns nil.
24
+ # @return [Hash{String => Object}, nil] executable SHA-256 and native dependency report
25
+ # @raise [SystemCallError] when the executable cannot be read
26
+ # @raise [Sevgi::Sundries::Mainz::Error] when the version command fails or its report is invalid
27
+ def fingerprint
28
+ path = F.executable(COMMAND)
29
+ return unless path
30
+ process = run(path, "--version")
31
+ report = JSON.parse(process.out)
32
+ Error.("Invalid Mainz version report") unless report.is_a?(Hash) && report["contract"] == 1
33
+ {"sha256" => Digest::SHA256.file(path).hexdigest, "runtime" => report}
34
+ rescue JSON::ParserError => e
35
+ Error.("Invalid Mainz version report: #{e.message}")
36
+ end
37
+
38
+ # Produces and atomically replaces one PDF through the native CLI.
39
+ # Input paths and output staging remain private to this call.
40
+ # @param svg [String] SVG source
41
+ # @param output [String, #to_path] destination path
42
+ # @param declaration [Hash] JSON authoring declaration
43
+ # @param options [Hash] ordinary export options
44
+ # @option options [Numeric, nil] :width output width in CSS pixels
45
+ # @option options [Numeric, nil] :height output height in CSS pixels
46
+ # @option options [Numeric] :dpi (96.0) positive CSS pixel density
47
+ # @option options [String, nil] :css export-only CSS
48
+ # @yield [svg] transforms the source once before production
49
+ # @yieldparam svg [String] SVG after optional CSS insertion
50
+ # @yieldreturn [String] source to render
51
+ # @return [String] expanded destination path
52
+ # @raise [Sevgi::ArgumentError] when the source, path, or declaration is invalid
53
+ # @raise [Sevgi::Sundries::Mainz::Error] when Mainz is missing or preparation fails
54
+ # @raise [SystemCallError] when staging or installation fails
55
+ def produce(svg, output, declaration:, **options, &block)
56
+ ArgumentError.("Mainz declaration must be a Hash") unless declaration.is_a?(::Hash)
57
+ unknown = options.keys - %i[css dpi height width]
58
+ ArgumentError.("Unknown Mainz option: #{unknown.first}") unless unknown.empty?
59
+ Export.call(svg, output, format: :pdf, style: declaration, fallback: false, **options, &block)
60
+ end
61
+
62
+ extend self
63
+
64
+ class << self
65
+ private
66
+
67
+ def write(input, output)
68
+ program = F.executable(COMMAND) || Error.("Mainz executable is unavailable")
69
+ parent = ::File.dirname(output)
70
+ parent = ::File.dirname(parent) until ::File.directory?(parent)
71
+ dimensions = input.dimensions.flat_map { |key, value| value.nil? ? [] : ["--#{key}", value.to_s] }
72
+ ::Dir.mktmpdir(".sevgi-mainz-", parent) do |directory|
73
+ result = prepare(program, directory, input.svg, input.style, dimensions)
74
+ install(result, output)
75
+ end
76
+ output
77
+ end
78
+
79
+ def prepare(program, directory, svg, declaration, dimensions)
80
+ source, definitions, result = %w[source.svg style.json output.pdf].map { ::File.join(directory, it) }
81
+ ::File.binwrite(source, svg)
82
+ ::File.write(definitions, declaration)
83
+ run(program, "produce", source, "--declaration", definitions, "--output", result, *dimensions)
84
+ result
85
+ end
86
+
87
+ def run(*arguments)
88
+ process = F.sh(*arguments)
89
+ raise ::Interrupt if Signal.list.values_at("INT", "TERM", "KILL").include?(process.signal) || process.exit_code == 130
90
+ Error.("Mainz preparation failed: #{process.err}") unless process.ok?
91
+ process
92
+ rescue Errno::ENOENT => e
93
+ Error.("Mainz executable is unavailable: #{e.message}")
94
+ end
95
+
96
+ def install(result, output)
97
+ unless ::File.file?(result) && !::File.symlink?(result) && ::File.size(result).positive?
98
+ Error.("Mainz produced no PDF")
99
+ end
100
+ ::FileUtils.mkdir_p(::File.dirname(output))
101
+ ::File.rename(result, output)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Sundries
5
5
  # Component version.
6
- VERSION = "1.0.0"
6
+ VERSION = "1.1.0"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-sundries
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -9,48 +9,62 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: sevgi-function
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - '='
17
31
  - !ruby/object:Gem::Version
18
- version: 1.0.0
32
+ version: 1.1.0
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
37
  - - '='
24
38
  - !ruby/object:Gem::Version
25
- version: 1.0.0
39
+ version: 1.1.0
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: sevgi-geometry
28
42
  requirement: !ruby/object:Gem::Requirement
29
43
  requirements:
30
44
  - - '='
31
45
  - !ruby/object:Gem::Version
32
- version: 1.0.0
46
+ version: 1.1.0
33
47
  type: :runtime
34
48
  prerelease: false
35
49
  version_requirements: !ruby/object:Gem::Requirement
36
50
  requirements:
37
51
  - - '='
38
52
  - !ruby/object:Gem::Version
39
- version: 1.0.0
53
+ version: 1.1.0
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: sevgi-graphics
42
56
  requirement: !ruby/object:Gem::Requirement
43
57
  requirements:
44
58
  - - '='
45
59
  - !ruby/object:Gem::Version
46
- version: 1.0.0
60
+ version: 1.1.0
47
61
  type: :runtime
48
62
  prerelease: false
49
63
  version_requirements: !ruby/object:Gem::Requirement
50
64
  requirements:
51
65
  - - '='
52
66
  - !ruby/object:Gem::Version
53
- version: 1.0.0
67
+ version: 1.1.0
54
68
  description: Includes grid, ruler, tile, and optional PDF or PNG export support.
55
69
  email: roktas@gmail.com
56
70
  executables: []
@@ -62,9 +76,11 @@ files:
62
76
  - README.md
63
77
  - lib/sevgi/sundries.rb
64
78
  - lib/sevgi/sundries/export.rb
79
+ - lib/sevgi/sundries/export/input.rb
65
80
  - lib/sevgi/sundries/export/native.rb
66
81
  - lib/sevgi/sundries/export/system.rb
67
82
  - lib/sevgi/sundries/grid.rb
83
+ - lib/sevgi/sundries/mainz.rb
68
84
  - lib/sevgi/sundries/ruler.rb
69
85
  - lib/sevgi/sundries/tile.rb
70
86
  - lib/sevgi/sundries/version.rb