sevgi-sundries 0.98.2 → 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: 7df3ccce8a77f50c584bd9ced3fec7fa19c3efb7c458d8dc248ca3629666fca3
4
- data.tar.gz: 690408c4e42c34eb5fe500d405a6f2532c9b7f1b6350784d176ba77f40135ea8
3
+ metadata.gz: 31d7fd62cc7b0a3897e7a33e780d9ee367e1e782e5022eb5caabacd87633f98c
4
+ data.tar.gz: c639aa1e07d51c657d8b97400bf9a0696d60a697e87bb7eca94db5d7be5a9b08
5
5
  SHA512:
6
- metadata.gz: c2c0a0062705f0c9dd55a0cef7093965a14cb9ce5e9f55ad9ca5f72485f715220fd8e9fdd616e707bf21eb3b198cb5011068aee9fff351c4e55e1aea5b03264a
7
- data.tar.gz: 72ef749c915cc8782e313292a5da66960f5c50dce49cc071fa2492de97d2d948f3f28f05475290daf979e29247f9ae61a63e347077ceb1a923ec16746e7ddaad
6
+ metadata.gz: 4dad57c233fc530511b8385d3aedac7a8884957ca57472b56befcbd19561c193f9001c1c26442d99ef43e60ae4e452b2da0a2a27f1ef42a3b6f2f2a15201d533
7
+ data.tar.gz: 9d49982dabad9bb3cce57b38fa3e8bc229ce2b105671505e0a10dfe32d6fbe7c7cf0cad5e7a85d23b9d301b6981b409e49d7e039d83f4614dd9bc593ba6dcda3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,120 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
5
5
 
6
6
  ## Unreleased
7
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
+
26
+ ## 1.0.0 - 2026-09-16
27
+
28
+ ### Added
29
+
30
+ - Added Hatch and Orb Showcase examples and displayed both arc lengths in the Arc example.
31
+
32
+ ### Changed
33
+
34
+ - Matched documentation page widths to the homepage Showcase.
35
+ - Aligned Derender user guidance with UTF-8 normalization and safe subtree selection rules.
36
+ - Clarified the packaged skill's script invocation, subtree copying, and Derender selection and encoding rules.
37
+ - Allowed Canvas and Paper values as the first `SVG` argument when using the default document profile.
38
+ - Made element `dup` and `clone` own independent subtrees while retaining IDs and Ruby copy semantics.
39
+ - Made Segment equality compare length and direction; ordering still compares length only.
40
+ - Limited Tile cell, row, and column queries to the requested geometry and accelerated XML character validation.
41
+ - Simplified the first-run guide and aligned library constructor examples.
42
+ - Updated the packaged skill for canvas origins, alignment, Paper lookup, curved geometry, and export constraints.
43
+ - Revised DSL catalog examples and packaged Sevgi skill guidance to use statement-style calls and show inspection results.
44
+ - Refined Showcase example tabs, made Ruby and XML views fill their panels, and hid executable preambles from Ruby views.
45
+ - Documented non-rendering element data and clarified the related DSL catalog examples.
46
+ - Refined Showcase heading spacing, page-title colors, and DSL catalog cards across light and dark themes.
47
+ - Removed an obsolete ruler example and unused site code.
48
+ - Kept the Squared and Copperplate guidesheet examples together at the end of the Showcase.
49
+ - Updated the packaged skill to avoid duplicate semantic shapes and unnecessary paint passes.
50
+
51
+ ### Fixed
52
+
53
+ - Kept horizontal scrollbars visible in Showcase example code panels.
54
+ - Read Derender input in its declared XML encoding and normalized generated source, XML declarations, and markup to UTF-8.
55
+ - Preserved open-path endpoints and derived edges independently of display precision.
56
+ - Kept near-axis and near-parallel line equations and intersections independent of angle and slope rounding.
57
+ - Corrected the Showcase social image dimensions.
58
+ - Added SVG namespaces to standalone drawing examples and regenerated their outputs.
59
+ - Made failed Sevgi Rake tasks stop dependent tasks and captured required-library failures in executor results.
60
+ - Removed executor signal-handler replacement so the host and shell retain SIGINT ownership.
61
+ - Escaped late export CSS as XML text and inserted it only at a supported final root closing tag.
62
+ - Preserved Derender processing instructions and rejected custom entity references and unsupported trailing document nodes before inclusion.
63
+ - Included both box origins in `Align`, unified Tile index validation, and isolated custom numeric attribute snapshots.
64
+ - Prevented avoidable overflow and underflow in point distances.
65
+ - Corrected browser source-panel expectations and added executable documentation, YARD, and packaged-skill checks.
66
+ - Rendered inline code in DSL catalog summaries and kept linked task themes visible below the sticky header.
67
+ - Restored Showcase tab icons with embedded Font Awesome SVGs.
68
+
69
+ ## 0.100.0 - 2026-09-09
70
+
71
+ ### Added
72
+
73
+ - Added `ArcTo` and `ArcBy` for SVG-native elliptical arc paths.
74
+ - Added Geometry `Arc`, `Ellipse`, and `Circle` with bounds, length, predicates, affine transformations, and
75
+ linear/quadratic intersections. Closed ellipses and circles support sweeps and hatching.
76
+ - Added Geometry `Point.midpoint`, `Element#closed?`, lined `vertices`, open-path traversal, and `Rect#center`.
77
+ - Added Geometry aggregate bounding boxes through `Operation.box`, point collinearity through `Point.collinear?`, and
78
+ polygon `simple?`, `convex?`, and `concave?` predicates.
79
+ - Added Protractor and Arc examples and documented the drawing and calculation APIs.
80
+
81
+ ### Changed
82
+
83
+ - Removed redundant parent scans while duplicating element subtrees.
84
+ - Reused source bounds across Tile cells to reduce repeated calculations and object allocations.
85
+
86
+ ### Fixed
87
+
88
+ - Preserved Showcase card HTML through Markdown rendering so all examples and their tabs remain visible.
89
+ - Preserved XML declarations, pre-root comments, CDATA sections, inline comments, and namespace-only leaves in
90
+ Derender output and evaluation.
91
+ - Limited PDF stamp recoloring to replaced text and carried graphics state across a page's content streams.
92
+ - Corrected decimal interval fitting and made Grid point queries honor the active numeric precision.
93
+ - Prevented implicit shell interpretation of single executable arguments in `F.sh` and `F.sh!`, including paths with spaces or shell metacharacters.
94
+ - Preserved inherited namespaces on detached Derender nodes in generated source and direct evaluation, including imports through `EvaluateChildren` and `IncludeChildren`.
95
+ - Prevented sweep and Hatch lines from crossing the exterior between two tangent vertices of a concave polygon.
96
+ - Aligned Tile cells with their declared bounds for circles, rotated shapes, and polygons whose first vertex differs from their bounding-box origin.
97
+ - Limited `Load` chains to 128 active sources with `Executor::LoadDepthError`, preserving cycle detection and nested load diagnostics.
98
+ - Rejected nil and false aggregate-box inputs with the documented geometry error.
99
+ - Restored precision-aware rejection of zero-length Triangle sides.
100
+ - Made point-set collinearity independent of input order and removed eager polygon edge-pair storage.
101
+ - Applied the collinearity tolerance to every three-point subset, removing coordinate-axis-dependent baseline selection.
102
+
103
+ ## 0.99.0 - 2026-08-09
104
+
105
+ ### Added
106
+
107
+ - Added `SVG.Module` for building anonymous callable drawing modules with their contract installed before definition.
108
+ - Added responsive Mermaid diagrams for the Derender round trip and SVG validation lifecycle.
109
+
110
+ ### Changed
111
+
112
+ - Reorganized the documentation around getting started, usage, composition, documents, layout, output, and examples.
113
+ - Documented how the `igves` and `igsev` command names reflect their conversion directions.
114
+ - Normalized DSL catalog provider labels to lowercase and simplified polyfill-backed editor entries to `inkscape`.
115
+
116
+ ### Fixed
117
+
118
+ - Allowed A5-on-A4 pdfcpu conversion to replace an existing output file.
119
+ - Pre-rendered Mermaid diagrams as inline SVG instead of parsing them in site visitors' browsers.
120
+ - Removed duplicate provider and context labels from DSL catalog entries.
121
+
8
122
  ## 0.98.2 - 2026-07-25
9
123
 
10
124
  ### Fixed
@@ -212,7 +326,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
212
326
 
213
327
  ### Changed
214
328
 
215
- - Added the checker board example to the documentation showcase flow.
329
+ - Added the checkerboard example to the documentation showcase flow.
216
330
  - Consolidated Victor Book attribution for adapted showcase examples into a single documentation note.
217
331
 
218
332
  ### Fixed
data/README.md CHANGED
@@ -17,11 +17,52 @@ require "sevgi/sundries"
17
17
  ## Example
18
18
 
19
19
  ```ruby
20
- rect = Sevgi::Geometry::Rect[3, 5]
21
- tile = Sevgi::Sundries::Tile.new(rect)
22
- tile.box.height
20
+ x = Sevgi::Sundries::Ruler.new(brut: 80, unit: 1, multiple: 10, margins: [5])
21
+ y = Sevgi::Sundries::Ruler.new(brut: 50, unit: 1, multiple: 10, margins: [5])
22
+ grid = Sevgi::Sundries::Grid[x, y]
23
+
24
+ grid.x.major.lines.size # => 5
25
+ grid.canvas.margin.to_a # => [5.0, 5.0, 5.0, 5.0]
26
+ ```
27
+
28
+ Rulers, grids, and tiles are inspectable Ruby values. They create no SVG elements by themselves. Pass their geometry
29
+ to Sevgi Graphics when the document needs it.
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
23
60
  ```
24
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
+
25
66
  ## Ruby compatibility
26
67
 
27
68
  Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
@@ -31,7 +72,9 @@ Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the d
31
72
  Basic ruler, grid, and tile helpers need only Ruby dependencies. Installing `sevgi-sundries` does not install native
32
73
  export gems.
33
74
 
34
- 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
35
78
  their system libraries and gems separately:
36
79
 
37
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,42 +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
-
48
- svg = styled(svg, css) if css && !css.strip.empty?
49
- svg = block.call(svg) if block
50
- ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
51
-
20
+ # Renders validated, transformed SVG through librsvg and Cairo.
21
+ # @api private
22
+ def render(svg, output, format:, width:, height:, dpi:)
52
23
  renderer = Renderer.method(format)
53
24
 
54
25
  begin
@@ -81,6 +52,30 @@ module Sevgi
81
52
 
82
53
  output
83
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
84
79
 
85
80
  # Replaces exact placeholder text objects in PDF streams.
86
81
  # The placeholder must appear as a PDF literal string in a white text object matching the export stamp pattern.
@@ -92,26 +87,21 @@ module Sevgi
92
87
  # @param placeholder [String] placeholder text to replace
93
88
  # @return [Boolean] true when at least one matching placeholder was replaced
94
89
  # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, or stamped
95
- # @note Streams with unbalanced graphics-state or text-object operators are left unchanged.
90
+ # @note Pages with inline images, unknown operators, or unbalanced graphics-state/text-object operators are left unchanged.
96
91
  def stamp(infile, outfile, stamp:, placeholder:)
97
92
  doc = HexaPDF::Document.open(infile)
98
93
  replacements = 0
99
94
 
100
95
  doc.pages.each do |page|
101
- Array(page[:Contents]).each do |ref|
102
- obj = doc.object(ref)
103
- next unless obj.respond_to?(:stream)
104
-
105
- data, count = stamp_stream(obj.stream, stamp:, placeholder:)
106
- next if count.zero?
96
+ data, count = stamp_stream(page.contents, stamp:, placeholder:)
97
+ next if count.zero?
107
98
 
108
- replacements += count
109
-
110
- obj.stream = data
111
- obj.set_filter(:FlateDecode)
112
- end
99
+ replacements += count
100
+ page.contents = data
101
+ doc.deref(page[:Contents]).set_filter(:FlateDecode)
113
102
  end
114
103
 
104
+ replacements += stamp_forms(doc, stamp:, placeholder:)
115
105
  doc.write(outfile, optimize: true) if replacements.positive?
116
106
  replacements.positive?
117
107
  rescue HexaPDF::Error, ::SystemCallError => e
@@ -126,7 +116,7 @@ module Sevgi
126
116
  # @param placeholder [String] placeholder text to replace
127
117
  # @return [Boolean] true when at least one matching placeholder was replaced
128
118
  # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, stamped, or replaced
129
- # @note Streams with unbalanced graphics-state or text-object operators are left unchanged.
119
+ # @note Pages with inline images, unknown operators, or unbalanced graphics-state/text-object operators are left unchanged.
130
120
  def stamp!(infile, stamp:, placeholder:)
131
121
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
132
122
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
@@ -185,7 +175,6 @@ module Sevgi
185
175
  serializer = HexaPDF::Serializer.new
186
176
  operands = []
187
177
  state = {
188
- fill_span: nil,
189
178
  fill_white: false,
190
179
  font_size: nil,
191
180
  in_text: false,
@@ -206,8 +195,12 @@ module Sevgi
206
195
  next
207
196
  end
208
197
 
198
+ # Inline image bytes are not PDF tokens; never interpret them as operators.
199
+ operator = object.to_sym
200
+ return [{}, 0] if operator == :BI || !HexaPDF::Content::Processor::OPERATOR_MESSAGE_NAME_MAP.key?(operator)
201
+
209
202
  count += process_operator(
210
- object.to_sym,
203
+ operator,
211
204
  operands,
212
205
  finish,
213
206
  data,
@@ -227,11 +220,13 @@ module Sevgi
227
220
  def process_operator(operator, operands, finish, data, stamp:, placeholder:, serializer:, state:, replacements:)
228
221
  case operator
229
222
  when :q
230
- state[:stack] << state.values_at(:fill_span, :fill_white, :font_size)
223
+ state[:stack] << state.values_at(:fill_white, :font_size)
231
224
  when :Q
232
225
  state[:stack].any? ? restore_state(state) : state[:valid] = false
233
226
  when :rg
234
- set_fill_state(state, operands, finish)
227
+ set_fill_state(state, operands)
228
+ when :g, :k, :cs, :sc, :scn
229
+ state[:fill_white] = false
235
230
  when :BT
236
231
  state[:valid] = false if state[:in_text]
237
232
  state[:in_text] = true
@@ -245,6 +240,7 @@ module Sevgi
245
240
  operator,
246
241
  operands,
247
242
  data,
243
+ operator_finish: finish,
248
244
  stamp:,
249
245
  placeholder:,
250
246
  serializer:,
@@ -257,31 +253,38 @@ module Sevgi
257
253
  end
258
254
 
259
255
  def restore_state(state)
260
- state[:fill_span], state[:fill_white], state[:font_size] = state[:stack].pop
256
+ state[:fill_white], state[:font_size] = state[:stack].pop
261
257
  end
262
258
 
263
- def set_fill_state(state, operands, finish)
259
+ def set_fill_state(state, operands)
264
260
  state[:fill_white] = operands.size == 3 && operands.all? { |value, _start, _finish| value == 1 }
265
- state[:fill_span] = [operands.first[1], finish] if state[:fill_white]
266
- state[:fill_span] = nil unless state[:fill_white]
267
261
  end
268
262
 
269
- def add_text_replacement(operator, operands, data, stamp:, placeholder:, serializer:, state:, replacements:)
263
+ def add_text_replacement(
264
+ operator,
265
+ operands,
266
+ data,
267
+ operator_finish:,
268
+ stamp:,
269
+ placeholder:,
270
+ serializer:,
271
+ state:,
272
+ replacements:
273
+ )
270
274
  replacement = text_replacement(data, operator, operands, stamp:, placeholder:, serializer:, state:)
271
275
  return 0 unless replacement
272
276
 
273
- start, finish, text = replacement
274
- replacements[[start, finish]] = text
275
- add_color_replacement(data, state, replacements)
277
+ start, text_finish, text = replacement
278
+ color_start = operands.first.fetch(1)
279
+ text = " 0.101961 0.101961 0.101961 rg#{text}" if start == color_start
280
+ replacements[[start, text_finish]] = text
281
+ add_color_replacement(color_start, replacements) unless start == color_start
282
+ replacements[[operator_finish, operator_finish]] ||= " 1 1 1 rg"
276
283
  1
277
284
  end
278
285
 
279
- def add_color_replacement(data, state, replacements)
280
- return unless state[:fill_span]
281
-
282
- color_start, color_finish = state[:fill_span]
283
- prefix = data[color_start...color_finish].to_s[/\A\s*/]
284
- replacements[[color_start, color_finish]] ||= "#{prefix}0.101961 0.101961 0.101961 rg"
286
+ def add_color_replacement(start, replacements)
287
+ replacements[[start, start]] ||= " 0.101961 0.101961 0.101961 rg"
285
288
  end
286
289
 
287
290
  def text_replacement(data, operator, operands, stamp:, placeholder:, serializer:, state:)
@@ -309,45 +312,6 @@ module Sevgi
309
312
  class << self
310
313
  private
311
314
 
312
- def output_path(output)
313
- ArgumentError.("Export output must be provided") if output.nil?
314
-
315
- path = output.respond_to?(:to_path) ? output.to_path : output
316
- ArgumentError.("Export output must be a String or path-like object") unless path.is_a?(::String)
317
- ArgumentError.("Export output must be provided") if path.strip.empty?
318
-
319
- path = ::File.expand_path(path)
320
- ArgumentError.("Export output must name a file") if ::File.directory?(path)
321
- path
322
- rescue ::StandardError => e
323
- raise if e.is_a?(::Sevgi::ArgumentError)
324
-
325
- ArgumentError.("Export output must be a String or path-like object: #{e.message}")
326
- end
327
-
328
- def dimension(value, field, optional: true)
329
- return if value.nil? && optional
330
- ExportError.(dimension_error(field)) unless value.is_a?(::Numeric)
331
-
332
- number = begin
333
- value.to_f
334
- rescue ::StandardError => e
335
- ExportError.(dimension_error(field, e.message))
336
- end
337
-
338
- ExportError.(dimension_error(field)) unless number.is_a?(::Float) && number.finite? && number.positive?
339
-
340
- number
341
- end
342
-
343
- def dimension_error(field, detail = nil)
344
- message = [
345
- (%w[width height].include?(field) ? "Invalid export dimensions" : "Invalid export #{field}"),
346
- detail
347
- ]
348
- message.compact.join(": ")
349
- end
350
-
351
315
  def intrinsic_size(handle)
352
316
  if handle.respond_to?(:intrinsic_dimensions)
353
317
  has_width, width, has_height, height, has_viewbox, viewbox = handle.intrinsic_dimensions
@@ -426,18 +390,6 @@ module Sevgi
426
390
  module Renderer
427
391
  extend self
428
392
 
429
- # Returns a renderer method for a format.
430
- # @param format [Symbol, String, nil] format name
431
- # @return [Method, nil]
432
- def [](format)
433
- case format&.to_sym
434
- when :png
435
- method(:png)
436
- when :pdf
437
- method(:pdf)
438
- end
439
- end
440
-
441
393
  # Renders SVG data to a PDF surface.
442
394
  # @param handle [Rsvg::Handle] parsed SVG handle
443
395
  # @param output [String] output file path
@@ -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
@@ -22,8 +24,10 @@ module Sevgi
22
24
  # @raise [Errno::ENOENT] when the executable cannot be spawned
23
25
  def a5_on_a4!(infile)
24
26
  temp = Tempfile.new(%w[output .pdf], File.dirname(infile))
25
- a5_on_a4(infile, temp.path)
26
- FileUtils.mv(temp.path, infile)
27
+ outfile = temp.path
28
+ temp.close!
29
+ a5_on_a4(infile, outfile)
30
+ FileUtils.mv(outfile, infile)
27
31
  ensure
28
32
  temp&.close!
29
33
  end
@@ -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
@@ -10,17 +12,21 @@ module Sevgi
10
12
  #
11
13
  # Native PDF/PNG rendering is loaded lazily so installing `sevgi-sundries` for SVG-only helpers does not require the
12
14
  # Cairo, RSVG, or HexaPDF gems. Native export entrypoints raise {Sevgi::MissingComponentError} when those optional
13
- # gems are unavailable. Omit `format:` to infer it from the output suffix;
15
+ # gems are unavailable. Omit `format:` to infer it from the output suffix.
14
16
  # width and height are output dimensions rather than changes to the SVG
15
17
  # viewBox. The return value is the expanded path that was written.
18
+ # Export-only CSS is a last-minute adjustment, not a replacement for document styles or validation. Insertion
19
+ # requires well-formed SVG ending in an unprefixed `</svg>` followed only by XML whitespace. Self-closing roots,
20
+ # prefixed roots, and trailing comments are unsupported with CSS. CSS is XML-escaped but not parsed or validated.
21
+ # The source callback runs after insertion and before ordinary or prepared conversion.
16
22
  #
17
23
  # @example Export SVG source to a sized PNG
18
- # svg = Sevgi::Graphics.SVG(:minimal) { circle cx: 5, cy: 5, r: 4 }.Render
24
+ # svg = Sevgi::Graphics.SVG(width: 10, height: 10) { circle cx: 5, cy: 5, r: 4 }.Render
19
25
  # Sevgi::Sundries::Export.call(svg, "drawing.png", width: 320)
20
26
  # @example Infer PDF output and inject export-only CSS
21
- # svg = Sevgi::Graphics.SVG(:minimal) { circle class: "accent", r: 4 }.Render
27
+ # svg = Sevgi::Graphics.SVG(width: 10, height: 10) { circle class: "accent", cx: 5, cy: 5, r: 4 }.Render
22
28
  # Sevgi::Sundries::Export.call(svg, "drawing.pdf", css: ".accent { fill: tomato; }")
23
- # @see https://sevgi.roktas.dev/sundries/#export Export guide
29
+ # @see https://sevgi.roktas.dev/output/#export Export guide
24
30
  module Export
25
31
  # File extensions mapped to export format names.
26
32
  # @api private
@@ -47,27 +53,40 @@ module Sevgi
47
53
 
48
54
  private_constant :NATIVE_COMPONENTS
49
55
 
50
- # @overload call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil)
51
- # Exports SVG source to a PDF or PNG file using the optional native export gems.
52
- # @param svg [String] SVG source content
53
- # Relative paths are expanded, missing parent directories are created after all render inputs validate, and an
54
- # existing output file is replaced. Directory paths are not expanded to a default file name.
55
- # @param output [String, #to_path] output file path
56
- # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
57
- # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
58
- # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
59
- # @param dpi [Numeric] finite positive CSS pixel density; omission uses {DEFAULT_DPI}, but explicit nil is invalid
60
- # @param css [String, nil] CSS inserted before the closing svg tag before rendering
61
- # @yield [svg] optional source transformation applied before rendering
62
- # @yieldparam svg [String] SVG source after optional CSS injection
63
- # @yieldreturn [String] SVG source to render
64
- # @return [String] expanded output path
65
- # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank, invalid, or a directory
66
- # @raise [Sevgi::MissingComponentError] when cairo, hexapdf, or rsvg2 is unavailable
67
- # @raise [Sevgi::Sundries::Export::ExportError] when format, CSS insertion, SVG parsing, SVG dimensions, or
68
- # render dimensions are invalid
69
- # @raise [SystemCallError] when the output directory or file cannot be created or written
70
- 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
71
90
 
72
91
  def format_for(format, output)
73
92
  if format
@@ -84,10 +103,12 @@ module Sevgi
84
103
  end
85
104
 
86
105
  def styled(svg, css)
87
- output = svg.sub("</svg>", "<style>#{css}</style></svg>")
88
- ExportError.("Cannot insert CSS: closing svg tag not found") if output == svg
106
+ ArgumentError.("Export CSS must be valid text") unless css.is_a?(::String) && css.valid_encoding?
107
+ closing = %r{</svg>[ \t\r\n]*\z}
108
+ ExportError.("Cannot insert CSS: expected final </svg> root closing tag") unless closing.match?(svg)
89
109
 
90
- output
110
+ text = css.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
111
+ svg.sub(closing) { |suffix| "<style>#{text}</style>#{suffix}" }
91
112
  end
92
113
 
93
114
  def normalize_format(format)
@@ -101,6 +122,8 @@ module Sevgi
101
122
  private :format_for, :normalize_format, :styled
102
123
 
103
124
  # Replaces exact placeholder text objects in PDF streams.
125
+ # The placeholder must be a PDF literal string inside a white text object that matches Sevgi's stamp pattern.
126
+ # Replacement text is escaped as a PDF literal string. When no match exists, the method writes no output file.
104
127
  # @param infile [String] source PDF file path
105
128
  # @param outfile [String] destination PDF file path
106
129
  # @param stamp [String] replacement text
@@ -112,6 +135,7 @@ module Sevgi
112
135
  def stamp(infile, outfile, stamp:, placeholder:) = native!.stamp(infile, outfile, stamp:, placeholder:)
113
136
 
114
137
  # Replaces exact placeholder text objects inside a PDF file in place.
138
+ # The input file changes only after at least one exact match produces a nonempty output file.
115
139
  # @param infile [String] PDF file path to modify
116
140
  # @param stamp [String] replacement text
117
141
  # @param placeholder [String] placeholder text to replace
@@ -126,6 +150,26 @@ module Sevgi
126
150
  class << self
127
151
  private
128
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
+
129
173
  def native!
130
174
  require_relative "export/native"
131
175
 
@@ -8,7 +8,7 @@ module Sevgi
8
8
  #
9
9
  # Axis names describe line direction, not the coordinate used to place a
10
10
  # line: `grid.x` produces horizontal lines whose y positions come from the
11
- # vertical ruler; `grid.y` produces vertical lines whose x positions come
11
+ # vertical ruler. `grid.y` produces vertical lines whose x positions come
12
12
  # from the horizontal ruler. Each query can return geometry lines, Point
13
13
  # endpoint pairs, or plain coordinate pairs for different consumers.
14
14
  # @example Query fitted horizontal and vertical lines
@@ -54,7 +54,7 @@ module Sevgi
54
54
  # Creates a grid from horizontal and vertical rulers.
55
55
  # @param x [Sevgi::Sundries::Ruler] horizontal ruler
56
56
  # @param y [Sevgi::Sundries::Ruler] vertical ruler
57
- # @param canvas [Sevgi::Graphics::Canvas, nil] source canvas whose identity should be preserved
57
+ # @param canvas [Sevgi::Graphics::Canvas, nil] optional source canvas whose identity the grid preserves
58
58
  # @return [void]
59
59
  # @raise [Sevgi::ArgumentError] when either argument is not a ruler
60
60
  # @raise [Sevgi::ArgumentError] when the source canvas does not match the ruler spans
@@ -71,7 +71,7 @@ module Sevgi
71
71
  end
72
72
 
73
73
  # Returns a graphics canvas matching the ruler spans and fitted margins.
74
- # Horizontal ruler margins become the canvas left/right margins; vertical
74
+ # Horizontal ruler margins become the canvas left/right margins. Vertical
75
75
  # ruler margins become its top/bottom margins.
76
76
  # @example Build a drawing with the fitted canvas
77
77
  # x = Sevgi::Sundries::Ruler.new(brut: 80, unit: 1, multiple: 10, margins: [5])
@@ -130,7 +130,7 @@ module Sevgi
130
130
  @minor = Minor.send(:new, self, other)
131
131
  end
132
132
 
133
- # Memoized grid line query for an axis.
133
+ # Grid line query for an axis.
134
134
  class Query
135
135
  private_class_method :new
136
136
 
@@ -141,18 +141,18 @@ module Sevgi
141
141
  # @api private
142
142
  def initialize(this, other) = (@this, @other = this, other)
143
143
 
144
- # Returns grid line endpoints as coordinate pairs.
145
- # The outer and nested collections are frozen and must be treated as immutable.
144
+ # Returns grid line endpoints as coordinate pairs at the active {Sevgi::F} precision.
145
+ # Each call reads the current thread precision. The outer and nested collections are frozen.
146
146
  # @return [Array<Array<Array<Float>>>] frozen coordinate pairs
147
- def xys = @xys ||= lines.map { it.points(true).map { |point| point.deconstruct.freeze }.freeze }.freeze
147
+ def xys = lines.map { it.points(true).map { |point| point.deconstruct.freeze }.freeze }.freeze
148
148
 
149
- # Returns grid line endpoints as points.
150
- # The outer and nested collections are frozen and must be treated as immutable.
149
+ # Returns grid line endpoints as points at the active {Sevgi::F} precision.
150
+ # Each call reads the current thread precision. The outer and nested collections are frozen.
151
151
  # @return [Array<Array<Sevgi::Geometry::Point>>] frozen point pairs
152
- def points = @points ||= lines.map { it.points(true).freeze }.freeze
152
+ def points = lines.map { it.points(true).freeze }.freeze
153
153
 
154
- # Returns generated grid lines.
155
- # The memoized collection is frozen and must be treated as immutable.
154
+ # Returns exact generated grid lines.
155
+ # The collection is computed once and frozen. Use {#points} or {#xys} for precision-rounded coordinates.
156
156
  # @return [Array<Sevgi::Geometry::Line>] frozen lines
157
157
  def lines = @lines ||= build.freeze
158
158
 
@@ -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
@@ -64,7 +64,7 @@ module Sevgi
64
64
  # @raise [Sevgi::ArgumentError] when length is not numeric
65
65
  # @raise [Sevgi::ArgumentError] when length is not finite
66
66
  # @raise [Sevgi::ArgumentError] when length is not positive
67
- def count(length) = (d / positive_number(length, "Interval count length")).to_i
67
+ def count(length) = F.count(d, positive_number(length, "Interval count length"))
68
68
 
69
69
  # Returns the total interval distance.
70
70
  # @return [Float]
@@ -157,7 +157,7 @@ module Sevgi
157
157
  # `brut` is the full available span. `unit * multiple` becomes the major
158
158
  # interval, and `sd/su/sn` describe that source subinterval. Requested
159
159
  # margins are minimums: leftover space is split between them while their
160
- # start/end difference is preserved. The fitted `d` excludes those margins;
160
+ # start/end difference is preserved. The fitted `d` excludes those margins.
161
161
  # `waste` includes them and any remainder.
162
162
  #
163
163
  # @example Ruler geometry
@@ -225,13 +225,13 @@ module Sevgi
225
225
  @start, @finish = fitted_margins(start, finish, span)
226
226
  end
227
227
 
228
- # Returns a ruler where the source subinterval is flattened into units.
228
+ # Returns a ruler where the source subinterval is flattened into units without refitting the span.
229
229
  # @example Expand major intervals into individual units
230
230
  # ruler = Sevgi::Sundries::Ruler.new(brut: 103, unit: 1, multiple: 10, margins: [5])
231
231
  # ruler.expand.n # => 90
232
232
  # ruler.ms.size # => 91
233
233
  # @return [Sevgi::Sundries::Ruler]
234
- def expand = self.class.new(unit: sub.u, multiple: 1, brut: d + waste, margins:)
234
+ def expand = dup.expand!
235
235
 
236
236
  # Returns fitted start and finish margins.
237
237
  # @return [Array<Float>] frozen margin pair
@@ -244,7 +244,9 @@ module Sevgi
244
244
 
245
245
  # Returns the unfitted distance distributed outside the fitted span.
246
246
  # @return [Float]
247
- def waste = @waste ||= brut - d
247
+ def waste
248
+ @waste ||= (brut - d).then { it.negative? && d.prev_float <= brut ? 0.0 : it }
249
+ end
248
250
 
249
251
  # Returns the source subinterval count.
250
252
  # @return [Integer]
@@ -261,7 +263,9 @@ module Sevgi
261
263
  private
262
264
 
263
265
  def fitted_margins(start, finish, span)
264
- extra = (span - d) / 2.0
266
+ extra = span - d
267
+ extra = 0.0 if extra.negative? && d.prev_float <= span
268
+ extra /= 2.0
265
269
  [start + extra, finish + extra]
266
270
  end
267
271
 
@@ -284,6 +288,16 @@ module Sevgi
284
288
 
285
289
  protected
286
290
 
291
+ # Flattens a copied ruler and clears values derived from its old interval.
292
+ # @api private
293
+ def expand!
294
+ @n *= sn
295
+ @u = su
296
+ @sub = Interval.new(@u, 1)
297
+ @d = @ds = @h = @hs = @ms = @nds = @nhs = @waste = nil
298
+ self
299
+ end
300
+
287
301
  # Computes the number of major intervals fitting in the available span.
288
302
  # @param unit [Numeric] subinterval unit length
289
303
  # @param multiple [Integer] number of subinterval units per major interval
@@ -296,7 +310,7 @@ module Sevgi
296
310
  #
297
311
  # If ordinary fitting produces an odd count, one complete major interval is
298
312
  # removed and the additional space is distributed through the margins.
299
- # @example Reserve symmetric waste when an odd count would fit
313
+ # @example Reserve symmetric waste when ordinary fitting produces an odd count
300
314
  # ruler = Sevgi::Sundries::RulerEven.new(brut: 50, unit: 1, multiple: 10)
301
315
  # ruler.n # => 4
302
316
  # ruler.margins # => [5.0, 5.0]
@@ -6,7 +6,7 @@ module Sevgi
6
6
  #
7
7
  # The source element's bounding-box width and height become the cell pitch.
8
8
  # Indexing is row-first: `tile[row][column]`. Generated cells and collection
9
- # snapshots are immutable geometry values; no SVG elements are created.
9
+ # snapshots are immutable geometry values. No SVG elements are created.
10
10
  # @example Address cells by row and column
11
11
  # cell = Sevgi::Geometry::Rect[8, 4]
12
12
  # tile = Sevgi::Sundries::Tile.new(cell, position: [10, 20], nx: 3, ny: 2)
@@ -29,7 +29,7 @@ module Sevgi
29
29
  # @return [Sevgi::Geometry::Element]
30
30
  attr_reader :element
31
31
 
32
- # Returns the tile origin.
32
+ # Returns the upper-left corner of the tile bounds.
33
33
  # @return [Sevgi::Geometry::Point]
34
34
  attr_reader :position
35
35
 
@@ -43,7 +43,7 @@ module Sevgi
43
43
 
44
44
  # Creates a tile from a source geometry element.
45
45
  # @param element [Sevgi::Geometry::Element] geometry element to repeat
46
- # @param position [Sevgi::Geometry::Point, Array<Numeric>] tile origin
46
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] upper-left corner of the tile bounds
47
47
  # @param nx [Integer] number of columns
48
48
  # @param ny [Integer] number of rows
49
49
  # @return [void]
@@ -57,6 +57,7 @@ module Sevgi
57
57
 
58
58
  @element = element
59
59
  @position = self.class.send(:position, position)
60
+ @bounds = element.box
60
61
 
61
62
  @nx = nx
62
63
  @ny = ny
@@ -65,20 +66,26 @@ module Sevgi
65
66
  # Returns a row by index.
66
67
  # @param i [Integer] row index
67
68
  # @return [Array<Sevgi::Geometry::Element>, nil]
68
- def [](i) = rows[i]
69
+ # @raise [Sevgi::ArgumentError] when the index is not an Integer
70
+ def [](i) = row(i)
69
71
 
70
72
  # Returns the bounding rectangle of the whole tile.
71
73
  # @return [Sevgi::Geometry::Rect]
72
- def box = @box ||= Geometry::Rect[nx * element.box.width, ny * element.box.height, position:]
74
+ def box = @box ||= Geometry::Rect[nx * @bounds.width, ny * @bounds.height, position:]
73
75
 
74
76
  # Returns the first cell in the tile.
75
77
  # @return [Sevgi::Geometry::Element]
76
- def cell = row.first
78
+ def cell = @cell ||= cell_at(0, 0)
77
79
 
78
80
  # Returns the bounding rectangle of a column.
79
81
  # @param i [Integer] column index
80
- # @return [Sevgi::Geometry::Rect]
81
- def colbox(i = 0) = Geometry::Rect[element.box.width, box.height, position: coordinate(0, i)]
82
+ # Negative indices count from the last column. Out-of-range indices return nil.
83
+ # @return [Sevgi::Geometry::Rect, nil]
84
+ # @raise [Sevgi::ArgumentError] when the index is not an Integer
85
+ def colbox(i = 0)
86
+ i = index(i, nx)
87
+ Geometry::Rect[@bounds.width, box.height, position: coordinate(0, i)] if i
88
+ end
82
89
 
83
90
  # Returns cells grouped by column.
84
91
  # The outer and nested collections are frozen and must be treated as immutable.
@@ -88,7 +95,13 @@ module Sevgi
88
95
  # Returns a column by index.
89
96
  # @param i [Integer] column index
90
97
  # @return [Array<Sevgi::Geometry::Element>, nil]
91
- def col(i = 0) = cols[i]
98
+ # @raise [Sevgi::ArgumentError] when the index is not an Integer
99
+ def col(i = 0)
100
+ i = index(i, nx)
101
+ return unless i
102
+
103
+ @cols ? @cols[i] : Array.new(ny) { |j| cell_at(j, i) }.freeze
104
+ end
92
105
 
93
106
  # Iterates over rows.
94
107
  # @yield [row] each row
@@ -107,17 +120,28 @@ module Sevgi
107
120
  # Returns a row by index.
108
121
  # @param i [Integer] row index
109
122
  # @return [Array<Sevgi::Geometry::Element>, nil]
110
- def row(i = 0) = rows[i]
123
+ # @raise [Sevgi::ArgumentError] when the index is not an Integer
124
+ def row(i = 0)
125
+ i = index(i, ny)
126
+ return unless i
127
+
128
+ @rows ? @rows[i] : Array.new(nx) { |j| cell_at(i, j) }.freeze
129
+ end
111
130
 
112
131
  # Returns the bounding rectangle of a row.
113
132
  # @param i [Integer] row index
114
- # @return [Sevgi::Geometry::Rect]
115
- def rowbox(i = 0) = Geometry::Rect[box.width, element.box.height, position: coordinate(i)]
133
+ # Negative indices count from the last row. Out-of-range indices return nil.
134
+ # @return [Sevgi::Geometry::Rect, nil]
135
+ # @raise [Sevgi::ArgumentError] when the index is not an Integer
136
+ def rowbox(i = 0)
137
+ i = index(i, ny)
138
+ Geometry::Rect[box.width, @bounds.height, position: coordinate(i)] if i
139
+ end
116
140
 
117
141
  # Returns cells grouped by row.
118
142
  # The outer and nested collections are frozen and must be treated as immutable.
119
143
  # @return [Array<Array<Sevgi::Geometry::Element>>] frozen rows
120
- def rows = @rows ||= (0...ny).map { |i| (0...nx).map { |j| element.at(coordinate(i, j)) }.freeze }.freeze
144
+ def rows = @rows ||= (0...ny).map { |i| (0...nx).map { |j| cell_at(i, j) }.freeze }.freeze
121
145
 
122
146
  # Iterates over rows.
123
147
  # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise rows
@@ -125,6 +149,12 @@ module Sevgi
125
149
 
126
150
  private
127
151
 
152
+ def index(value, count)
153
+ ArgumentError.("Tile index must be an Integer") unless value.is_a?(::Integer)
154
+ value += count if value.negative?
155
+ value if value.between?(0, count - 1)
156
+ end
157
+
128
158
  # Coerces a public tile position.
129
159
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] tile origin
130
160
  # @return [Sevgi::Geometry::Point]
@@ -141,7 +171,12 @@ module Sevgi
141
171
 
142
172
  private_class_method :position
143
173
 
144
- def coordinate(i, j = 0) = position.translate(j * element.box.width, i * element.box.height)
174
+ def cell_at(i, j)
175
+ point = coordinate(i, j)
176
+ element.translate(point.x - @bounds.position.x, point.y - @bounds.position.y)
177
+ end
178
+
179
+ def coordinate(i, j = 0) = position.translate(j * @bounds.width, i * @bounds.height)
145
180
  end
146
181
  end
147
182
  end
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Sundries
5
5
  # Component version.
6
- VERSION = "0.98.2"
6
+ VERSION = "1.1.0"
7
7
  end
8
8
  end
@@ -15,8 +15,8 @@ require_relative "sundries/version"
15
15
  module Sevgi
16
16
  # Layout, tiling, grid, and export helpers shared by Sevgi consumers.
17
17
  #
18
- # {Ruler} fits repeatable distances into a span; {Grid} combines two rulers
19
- # and exposes drawable lines; {Tile} repeats geometry by rows and columns.
18
+ # {Ruler} fits repeatable distances into a span. {Grid} combines two rulers
19
+ # and exposes drawable lines. {Tile} repeats geometry by rows and columns.
20
20
  # These layout values can be computed without an SVG document and then passed
21
21
  # to Graphics. Native PDF/PNG dependencies remain lazy and are loaded only by
22
22
  # {Export}.
@@ -26,7 +26,8 @@ module Sevgi
26
26
  # cell = Sevgi::Geometry::Rect[8, 4]
27
27
  # Sevgi::Sundries::Tile.new(cell, nx: 3, ny: 2).box.deconstruct
28
28
  # @see Sevgi::Graphics::Canvas
29
- # @see https://sevgi.roktas.dev/sundries/ Sundries guide
29
+ # @see https://sevgi.roktas.dev/layout/ Layout guide
30
+ # @see https://sevgi.roktas.dev/output/ Output guide
30
31
  module Sundries
31
32
  end
32
33
  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: 0.98.2
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: 0.98.2
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: 0.98.2
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: 0.98.2
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: 0.98.2
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: 0.98.2
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: 0.98.2
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
@@ -90,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
106
  - !ruby/object:Gem::Version
91
107
  version: '0'
92
108
  requirements: []
93
- rubygems_version: 4.0.16
109
+ rubygems_version: 4.0.20
94
110
  specification_version: 4
95
111
  summary: Layout helpers and export tools for Sevgi.
96
112
  test_files: []