sevgi-sundries 0.98.2 → 1.0.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: 20ba96c5308412cca2773c3678698968da83f06814ccdcd9b2d64c9ee522312c
4
+ data.tar.gz: 52b7e80ff6e5828ca83359eeeca6e593440f1dcd74943142987a1a7f149a5a95
5
5
  SHA512:
6
- metadata.gz: c2c0a0062705f0c9dd55a0cef7093965a14cb9ce5e9f55ad9ca5f72485f715220fd8e9fdd616e707bf21eb3b198cb5011068aee9fff351c4e55e1aea5b03264a
7
- data.tar.gz: 72ef749c915cc8782e313292a5da66960f5c50dce49cc071fa2492de97d2d948f3f28f05475290daf979e29247f9ae61a63e347077ceb1a923ec16746e7ddaad
6
+ metadata.gz: 4a899ba87fcb53cd0652e27b587ecfe2ec5d8b184e23c27f114ae08b001aa30b3f9d06497ff921738c8fe7482e212aed5bd0cef71f9e221e9169e855c43faa99
7
+ data.tar.gz: 218c824c2748b155e8d2005df3e8176a6de4b9e80780cba56659ddac4987481d42c23e34b23b4c040d5f5a68d946436ae91640f43d68b94d3f5f841afd9273e8
data/CHANGELOG.md CHANGED
@@ -3,7 +3,101 @@
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
6
+ ## 1.0.0 - 2026-09-16
7
+
8
+ ### Added
9
+
10
+ - Added Hatch and Orb Showcase examples and displayed both arc lengths in the Arc example.
11
+
12
+ ### Changed
13
+
14
+ - Matched documentation page widths to the homepage Showcase.
15
+ - Aligned Derender user guidance with UTF-8 normalization and safe subtree selection rules.
16
+ - Clarified the packaged skill's script invocation, subtree copying, and Derender selection and encoding rules.
17
+ - Allowed Canvas and Paper values as the first `SVG` argument when using the default document profile.
18
+ - Made element `dup` and `clone` own independent subtrees while retaining IDs and Ruby copy semantics.
19
+ - Made Segment equality compare length and direction; ordering still compares length only.
20
+ - Limited Tile cell, row, and column queries to the requested geometry and accelerated XML character validation.
21
+ - Simplified the first-run guide and aligned library constructor examples.
22
+ - Updated the packaged skill for canvas origins, alignment, Paper lookup, curved geometry, and export constraints.
23
+ - Revised DSL catalog examples and packaged Sevgi skill guidance to use statement-style calls and show inspection results.
24
+ - Refined Showcase example tabs, made Ruby and XML views fill their panels, and hid executable preambles from Ruby views.
25
+ - Documented non-rendering element data and clarified the related DSL catalog examples.
26
+ - Refined Showcase heading spacing, page-title colors, and DSL catalog cards across light and dark themes.
27
+ - Removed an obsolete ruler example and unused site code.
28
+ - Kept the Squared and Copperplate guidesheet examples together at the end of the Showcase.
29
+ - Updated the packaged skill to avoid duplicate semantic shapes and unnecessary paint passes.
30
+
31
+ ### Fixed
32
+
33
+ - Kept horizontal scrollbars visible in Showcase example code panels.
34
+ - Read Derender input in its declared XML encoding and normalized generated source, XML declarations, and markup to UTF-8.
35
+ - Preserved open-path endpoints and derived edges independently of display precision.
36
+ - Kept near-axis and near-parallel line equations and intersections independent of angle and slope rounding.
37
+ - Corrected the Showcase social image dimensions.
38
+ - Added SVG namespaces to standalone drawing examples and regenerated their outputs.
39
+ - Made failed Sevgi Rake tasks stop dependent tasks and captured required-library failures in executor results.
40
+ - Removed executor signal-handler replacement so the host and shell retain SIGINT ownership.
41
+ - Escaped late export CSS as XML text and inserted it only at a supported final root closing tag.
42
+ - Preserved Derender processing instructions and rejected custom entity references and unsupported trailing document nodes before inclusion.
43
+ - Included both box origins in `Align`, unified Tile index validation, and isolated custom numeric attribute snapshots.
44
+ - Prevented avoidable overflow and underflow in point distances.
45
+ - Corrected browser source-panel expectations and added executable documentation, YARD, and packaged-skill checks.
46
+ - Rendered inline code in DSL catalog summaries and kept linked task themes visible below the sticky header.
47
+ - Restored Showcase tab icons with embedded Font Awesome SVGs.
48
+
49
+ ## 0.100.0 - 2026-09-09
50
+
51
+ ### Added
52
+
53
+ - Added `ArcTo` and `ArcBy` for SVG-native elliptical arc paths.
54
+ - Added Geometry `Arc`, `Ellipse`, and `Circle` with bounds, length, predicates, affine transformations, and
55
+ linear/quadratic intersections. Closed ellipses and circles support sweeps and hatching.
56
+ - Added Geometry `Point.midpoint`, `Element#closed?`, lined `vertices`, open-path traversal, and `Rect#center`.
57
+ - Added Geometry aggregate bounding boxes through `Operation.box`, point collinearity through `Point.collinear?`, and
58
+ polygon `simple?`, `convex?`, and `concave?` predicates.
59
+ - Added Protractor and Arc examples and documented the drawing and calculation APIs.
60
+
61
+ ### Changed
62
+
63
+ - Removed redundant parent scans while duplicating element subtrees.
64
+ - Reused source bounds across Tile cells to reduce repeated calculations and object allocations.
65
+
66
+ ### Fixed
67
+
68
+ - Preserved Showcase card HTML through Markdown rendering so all examples and their tabs remain visible.
69
+ - Preserved XML declarations, pre-root comments, CDATA sections, inline comments, and namespace-only leaves in
70
+ Derender output and evaluation.
71
+ - Limited PDF stamp recoloring to replaced text and carried graphics state across a page's content streams.
72
+ - Corrected decimal interval fitting and made Grid point queries honor the active numeric precision.
73
+ - Prevented implicit shell interpretation of single executable arguments in `F.sh` and `F.sh!`, including paths with spaces or shell metacharacters.
74
+ - Preserved inherited namespaces on detached Derender nodes in generated source and direct evaluation, including imports through `EvaluateChildren` and `IncludeChildren`.
75
+ - Prevented sweep and Hatch lines from crossing the exterior between two tangent vertices of a concave polygon.
76
+ - Aligned Tile cells with their declared bounds for circles, rotated shapes, and polygons whose first vertex differs from their bounding-box origin.
77
+ - Limited `Load` chains to 128 active sources with `Executor::LoadDepthError`, preserving cycle detection and nested load diagnostics.
78
+ - Rejected nil and false aggregate-box inputs with the documented geometry error.
79
+ - Restored precision-aware rejection of zero-length Triangle sides.
80
+ - Made point-set collinearity independent of input order and removed eager polygon edge-pair storage.
81
+ - Applied the collinearity tolerance to every three-point subset, removing coordinate-axis-dependent baseline selection.
82
+
83
+ ## 0.99.0 - 2026-08-09
84
+
85
+ ### Added
86
+
87
+ - Added `SVG.Module` for building anonymous callable drawing modules with their contract installed before definition.
88
+ - Added responsive Mermaid diagrams for the Derender round trip and SVG validation lifecycle.
89
+
90
+ ### Changed
91
+
92
+ - Reorganized the documentation around getting started, usage, composition, documents, layout, output, and examples.
93
+ - Documented how the `igves` and `igsev` command names reflect their conversion directions.
94
+ - Normalized DSL catalog provider labels to lowercase and simplified polyfill-backed editor entries to `inkscape`.
95
+
96
+ ### Fixed
97
+
98
+ - Allowed A5-on-A4 pdfcpu conversion to replace an existing output file.
99
+ - Pre-rendered Mermaid diagrams as inline SVG instead of parsing them in site visitors' browsers.
100
+ - Removed duplicate provider and context labels from DSL catalog entries.
7
101
 
8
102
  ## 0.98.2 - 2026-07-25
9
103
 
@@ -212,7 +306,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
212
306
 
213
307
  ### Changed
214
308
 
215
- - Added the checker board example to the documentation showcase flow.
309
+ - Added the checkerboard example to the documentation showcase flow.
216
310
  - Consolidated Victor Book attribution for adapted showcase examples into a single documentation note.
217
311
 
218
312
  ### Fixed
data/README.md CHANGED
@@ -17,11 +17,17 @@ 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]
23
26
  ```
24
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
+
25
31
  ## Ruby compatibility
26
32
 
27
33
  Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
@@ -26,7 +26,7 @@ module Sevgi
26
26
  # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
27
27
  # @param width [Numeric, nil] finite positive target width in output pixels for PNG, or CSS pixels before PDF point conversion
28
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
29
+ # @param dpi [Numeric] finite positive CSS pixel density. Omission uses {DEFAULT_DPI}, but explicit nil is invalid
30
30
  # @param css [String, nil] CSS inserted before the closing svg tag before rendering
31
31
  # @yield [svg] optional source transformation applied before rendering
32
32
  # @yieldparam svg [String] SVG source after optional CSS injection
@@ -44,6 +44,7 @@ module Sevgi
44
44
  height = dimension(height, "height")
45
45
  dpi = dimension(dpi, "dpi", optional: false)
46
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?
47
48
 
48
49
  svg = styled(svg, css) if css && !css.strip.empty?
49
50
  svg = block.call(svg) if block
@@ -92,24 +93,18 @@ module Sevgi
92
93
  # @param placeholder [String] placeholder text to replace
93
94
  # @return [Boolean] true when at least one matching placeholder was replaced
94
95
  # @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.
96
+ # @note Pages with inline images, unknown operators, or unbalanced graphics-state/text-object operators are left unchanged.
96
97
  def stamp(infile, outfile, stamp:, placeholder:)
97
98
  doc = HexaPDF::Document.open(infile)
98
99
  replacements = 0
99
100
 
100
101
  doc.pages.each do |page|
101
- Array(page[:Contents]).each do |ref|
102
- obj = doc.object(ref)
103
- next unless obj.respond_to?(:stream)
102
+ data, count = stamp_stream(page.contents, stamp:, placeholder:)
103
+ next if count.zero?
104
104
 
105
- data, count = stamp_stream(obj.stream, stamp:, placeholder:)
106
- next if count.zero?
107
-
108
- replacements += count
109
-
110
- obj.stream = data
111
- obj.set_filter(:FlateDecode)
112
- end
105
+ replacements += count
106
+ page.contents = data
107
+ doc.deref(page[:Contents]).set_filter(:FlateDecode)
113
108
  end
114
109
 
115
110
  doc.write(outfile, optimize: true) if replacements.positive?
@@ -126,7 +121,7 @@ module Sevgi
126
121
  # @param placeholder [String] placeholder text to replace
127
122
  # @return [Boolean] true when at least one matching placeholder was replaced
128
123
  # @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.
124
+ # @note Pages with inline images, unknown operators, or unbalanced graphics-state/text-object operators are left unchanged.
130
125
  def stamp!(infile, stamp:, placeholder:)
131
126
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
132
127
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
@@ -185,7 +180,6 @@ module Sevgi
185
180
  serializer = HexaPDF::Serializer.new
186
181
  operands = []
187
182
  state = {
188
- fill_span: nil,
189
183
  fill_white: false,
190
184
  font_size: nil,
191
185
  in_text: false,
@@ -206,8 +200,12 @@ module Sevgi
206
200
  next
207
201
  end
208
202
 
203
+ # Inline image bytes are not PDF tokens; never interpret them as operators.
204
+ operator = object.to_sym
205
+ return [{}, 0] if operator == :BI || !HexaPDF::Content::Processor::OPERATOR_MESSAGE_NAME_MAP.key?(operator)
206
+
209
207
  count += process_operator(
210
- object.to_sym,
208
+ operator,
211
209
  operands,
212
210
  finish,
213
211
  data,
@@ -227,11 +225,13 @@ module Sevgi
227
225
  def process_operator(operator, operands, finish, data, stamp:, placeholder:, serializer:, state:, replacements:)
228
226
  case operator
229
227
  when :q
230
- state[:stack] << state.values_at(:fill_span, :fill_white, :font_size)
228
+ state[:stack] << state.values_at(:fill_white, :font_size)
231
229
  when :Q
232
230
  state[:stack].any? ? restore_state(state) : state[:valid] = false
233
231
  when :rg
234
- set_fill_state(state, operands, finish)
232
+ set_fill_state(state, operands)
233
+ when :g, :k, :cs, :sc, :scn
234
+ state[:fill_white] = false
235
235
  when :BT
236
236
  state[:valid] = false if state[:in_text]
237
237
  state[:in_text] = true
@@ -245,6 +245,7 @@ module Sevgi
245
245
  operator,
246
246
  operands,
247
247
  data,
248
+ operator_finish: finish,
248
249
  stamp:,
249
250
  placeholder:,
250
251
  serializer:,
@@ -257,31 +258,38 @@ module Sevgi
257
258
  end
258
259
 
259
260
  def restore_state(state)
260
- state[:fill_span], state[:fill_white], state[:font_size] = state[:stack].pop
261
+ state[:fill_white], state[:font_size] = state[:stack].pop
261
262
  end
262
263
 
263
- def set_fill_state(state, operands, finish)
264
+ def set_fill_state(state, operands)
264
265
  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
266
  end
268
267
 
269
- def add_text_replacement(operator, operands, data, stamp:, placeholder:, serializer:, state:, replacements:)
268
+ def add_text_replacement(
269
+ operator,
270
+ operands,
271
+ data,
272
+ operator_finish:,
273
+ stamp:,
274
+ placeholder:,
275
+ serializer:,
276
+ state:,
277
+ replacements:
278
+ )
270
279
  replacement = text_replacement(data, operator, operands, stamp:, placeholder:, serializer:, state:)
271
280
  return 0 unless replacement
272
281
 
273
- start, finish, text = replacement
274
- replacements[[start, finish]] = text
275
- add_color_replacement(data, state, replacements)
282
+ start, text_finish, text = replacement
283
+ color_start = operands.first.fetch(1)
284
+ text = " 0.101961 0.101961 0.101961 rg#{text}" if start == color_start
285
+ replacements[[start, text_finish]] = text
286
+ add_color_replacement(color_start, replacements) unless start == color_start
287
+ replacements[[operator_finish, operator_finish]] ||= " 1 1 1 rg"
276
288
  1
277
289
  end
278
290
 
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"
291
+ def add_color_replacement(start, replacements)
292
+ replacements[[start, start]] ||= " 0.101961 0.101961 0.101961 rg"
285
293
  end
286
294
 
287
295
  def text_replacement(data, operator, operands, stamp:, placeholder:, serializer:, state:)
@@ -426,18 +434,6 @@ module Sevgi
426
434
  module Renderer
427
435
  extend self
428
436
 
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
437
  # Renders SVG data to a PDF surface.
442
438
  # @param handle [Rsvg::Handle] parsed SVG handle
443
439
  # @param output [String] output file path
@@ -22,8 +22,10 @@ module Sevgi
22
22
  # @raise [Errno::ENOENT] when the executable cannot be spawned
23
23
  def a5_on_a4!(infile)
24
24
  temp = Tempfile.new(%w[output .pdf], File.dirname(infile))
25
- a5_on_a4(infile, temp.path)
26
- FileUtils.mv(temp.path, infile)
25
+ outfile = temp.path
26
+ temp.close!
27
+ a5_on_a4(infile, outfile)
28
+ FileUtils.mv(outfile, infile)
27
29
  ensure
28
30
  temp&.close!
29
31
  end
@@ -10,17 +10,21 @@ module Sevgi
10
10
  #
11
11
  # Native PDF/PNG rendering is loaded lazily so installing `sevgi-sundries` for SVG-only helpers does not require the
12
12
  # 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;
13
+ # gems are unavailable. Omit `format:` to infer it from the output suffix.
14
14
  # width and height are output dimensions rather than changes to the SVG
15
15
  # viewBox. The return value is the expanded path that was written.
16
+ # Export-only CSS is a last-minute adjustment, not a replacement for document styles or validation. Insertion
17
+ # requires well-formed SVG ending in an unprefixed `</svg>` followed only by XML whitespace. Self-closing roots,
18
+ # 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.
16
20
  #
17
21
  # @example Export SVG source to a sized PNG
18
- # svg = Sevgi::Graphics.SVG(:minimal) { circle cx: 5, cy: 5, r: 4 }.Render
22
+ # svg = Sevgi::Graphics.SVG(width: 10, height: 10) { circle cx: 5, cy: 5, r: 4 }.Render
19
23
  # Sevgi::Sundries::Export.call(svg, "drawing.png", width: 320)
20
24
  # @example Infer PDF output and inject export-only CSS
21
- # svg = Sevgi::Graphics.SVG(:minimal) { circle class: "accent", r: 4 }.Render
25
+ # svg = Sevgi::Graphics.SVG(width: 10, height: 10) { circle class: "accent", cx: 5, cy: 5, r: 4 }.Render
22
26
  # Sevgi::Sundries::Export.call(svg, "drawing.pdf", css: ".accent { fill: tomato; }")
23
- # @see https://sevgi.roktas.dev/sundries/#export Export guide
27
+ # @see https://sevgi.roktas.dev/output/#export Export guide
24
28
  module Export
25
29
  # File extensions mapped to export format names.
26
30
  # @api private
@@ -56,7 +60,7 @@ module Sevgi
56
60
  # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
57
61
  # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
58
62
  # @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
63
+ # @param dpi [Numeric] finite positive CSS pixel density. Omission uses {DEFAULT_DPI}, but explicit nil is invalid
60
64
  # @param css [String, nil] CSS inserted before the closing svg tag before rendering
61
65
  # @yield [svg] optional source transformation applied before rendering
62
66
  # @yieldparam svg [String] SVG source after optional CSS injection
@@ -84,10 +88,12 @@ module Sevgi
84
88
  end
85
89
 
86
90
  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
91
+ ArgumentError.("Export CSS must be valid text") unless css.is_a?(::String) && css.valid_encoding?
92
+ closing = %r{</svg>[ \t\r\n]*\z}
93
+ ExportError.("Cannot insert CSS: expected final </svg> root closing tag") unless closing.match?(svg)
89
94
 
90
- output
95
+ text = css.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
96
+ svg.sub(closing) { |suffix| "<style>#{text}</style>#{suffix}" }
91
97
  end
92
98
 
93
99
  def normalize_format(format)
@@ -101,6 +107,8 @@ module Sevgi
101
107
  private :format_for, :normalize_format, :styled
102
108
 
103
109
  # Replaces exact placeholder text objects in PDF streams.
110
+ # The placeholder must be a PDF literal string inside a white text object that matches Sevgi's stamp pattern.
111
+ # Replacement text is escaped as a PDF literal string. When no match exists, the method writes no output file.
104
112
  # @param infile [String] source PDF file path
105
113
  # @param outfile [String] destination PDF file path
106
114
  # @param stamp [String] replacement text
@@ -112,6 +120,7 @@ module Sevgi
112
120
  def stamp(infile, outfile, stamp:, placeholder:) = native!.stamp(infile, outfile, stamp:, placeholder:)
113
121
 
114
122
  # Replaces exact placeholder text objects inside a PDF file in place.
123
+ # The input file changes only after at least one exact match produces a nonempty output file.
115
124
  # @param infile [String] PDF file path to modify
116
125
  # @param stamp [String] replacement text
117
126
  # @param placeholder [String] placeholder text to replace
@@ -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
 
@@ -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.0.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.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,42 +15,42 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.98.2
18
+ version: 1.0.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.98.2
25
+ version: 1.0.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: sevgi-geometry
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.98.2
32
+ version: 1.0.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.98.2
39
+ version: 1.0.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: sevgi-graphics
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 0.98.2
46
+ version: 1.0.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 0.98.2
53
+ version: 1.0.0
54
54
  description: Includes grid, ruler, tile, and optional PDF or PNG export support.
55
55
  email: roktas@gmail.com
56
56
  executables: []
@@ -90,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
90
  - !ruby/object:Gem::Version
91
91
  version: '0'
92
92
  requirements: []
93
- rubygems_version: 4.0.16
93
+ rubygems_version: 4.0.20
94
94
  specification_version: 4
95
95
  summary: Layout helpers and export tools for Sevgi.
96
96
  test_files: []