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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +38 -1
- data/lib/sevgi/sundries/export/input.rb +75 -0
- data/lib/sevgi/sundries/export/native.rb +28 -72
- data/lib/sevgi/sundries/export/system.rb +3 -1
- data/lib/sevgi/sundries/export.rb +57 -22
- data/lib/sevgi/sundries/mainz.rb +106 -0
- data/lib/sevgi/sundries/version.rb +1 -1
- metadata +23 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31d7fd62cc7b0a3897e7a33e780d9ee367e1e782e5022eb5caabacd87633f98c
|
|
4
|
+
data.tar.gz: c639aa1e07d51c657d8b97400bf9a0696d60a697e87bb7eca94db5d7be5a9b08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
22
|
-
# @
|
|
23
|
-
|
|
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)
|
|
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
|
|
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
|
-
#
|
|
55
|
-
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
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
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|