postsvg 0.3.0 → 0.3.1

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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +533 -49
  3. data/CLAUDE.md +27 -3
  4. data/Gemfile +2 -2
  5. data/README.adoc +62 -4
  6. data/Rakefile +17 -0
  7. data/gemfiles/native-parity/Gemfile +8 -0
  8. data/lib/postsvg/cli.rb +10 -5
  9. data/lib/postsvg/graphics_context.rb +2 -1
  10. data/lib/postsvg/interpreter.rb +1 -2
  11. data/lib/postsvg/options.rb +1 -1
  12. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  13. data/lib/postsvg/renderer.rb +7 -3
  14. data/lib/postsvg/svg/clip_path_registry.rb +3 -1
  15. data/lib/postsvg/svg/element.rb +4 -2
  16. data/lib/postsvg/svg/elements/ellipse.rb +2 -1
  17. data/lib/postsvg/svg/elements/svg.rb +2 -1
  18. data/lib/postsvg/svg/paint.rb +1 -1
  19. data/lib/postsvg/svg/parser.rb +3 -3
  20. data/lib/postsvg/svg/path_data/parser.rb +10 -6
  21. data/lib/postsvg/svg/stroke.rb +4 -2
  22. data/lib/postsvg/svg/transform_list.rb +1 -1
  23. data/lib/postsvg/svg.rb +1 -1
  24. data/lib/postsvg/svg_builder.rb +15 -11
  25. data/lib/postsvg/translation/arc_converter.rb +23 -14
  26. data/lib/postsvg/translation/bounding_box.rb +7 -4
  27. data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
  28. data/lib/postsvg/translation/handlers/ellipse_handler.rb +8 -3
  29. data/lib/postsvg/translation/handlers/group_handler.rb +2 -1
  30. data/lib/postsvg/translation/handlers/image_handler.rb +3 -2
  31. data/lib/postsvg/translation/handlers/line_handler.rb +6 -2
  32. data/lib/postsvg/translation/handlers/open_handler.rb +3 -2
  33. data/lib/postsvg/translation/handlers/path_handler.rb +82 -60
  34. data/lib/postsvg/translation/handlers/polygon_handler.rb +4 -2
  35. data/lib/postsvg/translation/handlers/polyline_handler.rb +5 -4
  36. data/lib/postsvg/translation/handlers/rect_handler.rb +10 -5
  37. data/lib/postsvg/translation/handlers/shared.rb +11 -11
  38. data/lib/postsvg/translation/handlers/svg_handler.rb +3 -2
  39. data/lib/postsvg/translation/handlers/text_handler.rb +17 -8
  40. data/lib/postsvg/translation/handlers.rb +2 -1
  41. data/lib/postsvg/translation/ps_renderer.rb +18 -4
  42. data/lib/postsvg/translation/record_emitter.rb +54 -0
  43. data/lib/postsvg/version.rb +1 -1
  44. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
  45. data/lib/postsvg/visitors/ps_visitor/color.rb +8 -5
  46. data/lib/postsvg/visitors/ps_visitor/common.rb +1 -2
  47. data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
  48. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
  49. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +8 -4
  50. data/lib/postsvg/visitors/ps_visitor/font.rb +15 -4
  51. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +7 -5
  52. data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
  53. data/lib/postsvg/visitors/ps_visitor/path.rb +52 -33
  54. data/lib/postsvg/visitors/ps_visitor/transformations.rb +45 -7
  55. data/lib/postsvg/visitors/ps_visitor.rb +18 -2
  56. data/lib/postsvg.rb +43 -38
  57. metadata +20 -39
  58. data/lib/postsvg/color.rb +0 -132
  59. data/lib/postsvg/format_number.rb +0 -22
  60. data/lib/postsvg/matrix.rb +0 -106
  61. data/lib/postsvg/model/literals/array.rb +0 -41
  62. data/lib/postsvg/model/literals/dictionary.rb +0 -34
  63. data/lib/postsvg/model/literals/hex.rb +0 -37
  64. data/lib/postsvg/model/literals/name.rb +0 -40
  65. data/lib/postsvg/model/literals/number.rb +0 -36
  66. data/lib/postsvg/model/literals/procedure.rb +0 -41
  67. data/lib/postsvg/model/literals/string.rb +0 -30
  68. data/lib/postsvg/model/literals.rb +0 -19
  69. data/lib/postsvg/model/operator.rb +0 -58
  70. data/lib/postsvg/model/operators/arithmetic.rb +0 -264
  71. data/lib/postsvg/model/operators/boolean.rb +0 -182
  72. data/lib/postsvg/model/operators/color.rb +0 -74
  73. data/lib/postsvg/model/operators/container.rb +0 -186
  74. data/lib/postsvg/model/operators/control_flow.rb +0 -119
  75. data/lib/postsvg/model/operators/device.rb +0 -21
  76. data/lib/postsvg/model/operators/dictionary.rb +0 -118
  77. data/lib/postsvg/model/operators/font.rb +0 -121
  78. data/lib/postsvg/model/operators/graphics_state.rb +0 -84
  79. data/lib/postsvg/model/operators/painting.rb +0 -29
  80. data/lib/postsvg/model/operators/path.rb +0 -169
  81. data/lib/postsvg/model/operators/stack.rb +0 -72
  82. data/lib/postsvg/model/operators/transformations.rb +0 -103
  83. data/lib/postsvg/model/operators.rb +0 -89
  84. data/lib/postsvg/model/program.rb +0 -68
  85. data/lib/postsvg/model/token.rb +0 -43
  86. data/lib/postsvg/model.rb +0 -17
  87. data/lib/postsvg/serializer.rb +0 -325
  88. data/lib/postsvg/source/ast_builder.rb +0 -308
  89. data/lib/postsvg/source/lexer.rb +0 -322
  90. data/lib/postsvg/source/operand_stack.rb +0 -55
  91. data/lib/postsvg/source.rb +0 -21
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## What this is
6
6
 
7
- `postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses Nokogiri. There is no Ghostscript, Inkscape, or other external renderer.
7
+ `postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses leptris. There is no Ghostscript, Inkscape, or other external renderer.
8
8
 
9
9
  Code architecture mirrors `emfsvg`: a typed `Model::Program` value object is the single source of truth, with a Renderer / Visitor visiting it to emit SVG, and a Serializer walking it to emit PS.
10
10
 
@@ -27,6 +27,30 @@ bundle exec ruby exe/postsvg batch INPUT_DIR [OUTPUT_DIR]
27
27
  bundle exec ruby exe/postsvg version
28
28
  ```
29
29
 
30
+ ## Round-trip contract
31
+
32
+ The `round_trip` tag selects the model comparison, SVG summary comparison,
33
+ and byte-pinned emitted-PostScript golden checks:
34
+
35
+ ```bash
36
+ bundle exec rspec --tag round_trip
37
+ bundle exec rake round_trip:regenerate_goldens
38
+ ```
39
+
40
+ These specs also run in the normal CI suite. The regeneration task rewrites
41
+ the `.ps.golden` beside every SVG listed in
42
+ `spec/fixtures/round_trip/manifest.yml`. Regenerate only for intentional output
43
+ changes, inspect the diffs, and include them in a reviewed PR; never edit
44
+ goldens by hand.
45
+
46
+ Manifest `known_gap` and `non_goal` markers are **strict pending**, scoped to
47
+ the `model` or `svg` leg. They do not skip comparisons: an unexpected pass
48
+ fails the suite and requires reviewing/removing the marker. `known_gap`
49
+ identifies a bug requiring follow-up; `non_goal` records intentional loss
50
+ (currently gradient paint flattening). Unmarked legs must pass, and golden
51
+ checks apply even to marked fixtures. See the README Round-trip section for
52
+ the supported subset, comparison precision and parser-callable parity hook.
53
+
30
54
  ## Architecture
31
55
 
32
56
  ```
@@ -54,7 +78,7 @@ PS/EPS bytes ──▶│ Postsvg::Lexer │
54
78
  │ │
55
79
  ▼ │
56
80
  SVG string │
57
- │ Nokogiri │
81
+ │ leptris │
58
82
  ▼ │
59
83
  ┌─────────────────┐ │
60
84
  │ Postsvg:: │ │
@@ -163,7 +187,7 @@ Errors: `Postsvg::ParseError`, `LexError`, `SyntaxError`, `RenderError`, `StackU
163
187
 
164
188
  ## Dependencies
165
189
 
166
- - `parslet`, `thor`, `nokogiri`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
190
+ - `parslet`, `thor`, `leptris`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
167
191
 
168
192
  ## Limitations
169
193
 
data/Gemfile CHANGED
@@ -4,8 +4,8 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
- gem "canon", github: "lutaml/canon", branch: "main"
8
- gem "nokogiri"
7
+ gem "canon"
8
+ gem "leptris", ">= 1.9.399.0"
9
9
  gem "rake"
10
10
  gem "rspec"
11
11
  gem "rubocop"
data/README.adoc CHANGED
@@ -225,8 +225,9 @@ equivalent PS `arc` operator.
225
225
 
226
226
  === Round-trip
227
227
 
228
- The same `Model::Program` value object sits behind both directions, so
229
- round-trip is a function composition:
228
+ The round-trip contract covers a pinned SVG corpus, with per-fixture
229
+ expectations in link:spec/fixtures/round_trip/manifest.yml[manifest.yml].
230
+ The conversion is a function composition:
230
231
 
231
232
  [source,ruby]
232
233
  ----
@@ -236,10 +237,67 @@ original_svg = File.read("input.svg")
236
237
  ps = Postsvg.to_ps(original_svg)
237
238
  round_tripped_svg = Postsvg.to_svg(ps)
238
239
 
239
- # round_tripped_svg preserves the geometry of original_svg for the
240
- # supported subset of SVG elements.
241
240
  ----
242
241
 
242
+ The gate checks two legs independently:
243
+
244
+ * *Normalized model diff*: `RoundTrip::Harness` compares the program built
245
+ from SVG with the program parsed from its serialized PostScript. The
246
+ normalizer compares operators and operand values, rounding floats to the
247
+ serializer's four decimal places.
248
+ * *SVG summary diff*: `RoundTrip::SvgSummary` compares the original SVG with
249
+ the returned SVG as painted geometry in document coordinates. It checks
250
+ fill, stroke, stroke width, line caps and joins, transforms, and text
251
+ content, position, font family and size. Numbers are rounded to two decimal
252
+ places; equivalent shape/path spellings and the renderer's Y-flip wrapper
253
+ are normalized. This is not a byte-for-byte SVG or pixel-rendering test.
254
+
255
+ Covered families are shapes (rectangles, polygons, polylines, line and curve
256
+ paths), line strokes, arcs (circles, ellipses and path arcs), gray/RGB fills,
257
+ combined fill and stroke, stroke styles, text, transforms (translation,
258
+ rotation, matrices and nested groups), and gradients. Coverage does not mean
259
+ every family preserves everything: the manifest records known SVG-leg gaps
260
+ for polyline fill, path arcs and matrix shear. Text checks do not promise font
261
+ metrics or glyph fidelity. Features outside the corpus are not guaranteed by
262
+ this gate.
263
+
264
+ The manifest is authoritative for exceptions on each leg. `known_gap` marks
265
+ a bug; `non_goal` marks intentional loss. Linear and radial gradient `url()`
266
+ paint is intentionally flattened to a plain fill (currently black), so gradient
267
+ preservation is a non-goal even though the model leg passes. Both markers are
268
+ strict pending: the comparison still runs, and an unexpected pass fails so
269
+ the marker must be reviewed and removed when appropriate.
270
+
271
+ The `round_trip` spec tag selects the contract and emitted-PostScript golden
272
+ checks, which also run in the normal CI spec suite. Every manifest fixture has
273
+ a byte-pinned `.ps.golden`; even marked fixtures must match their golden.
274
+ For an intentional output change, regenerate with:
275
+
276
+ [source,sh]
277
+ ----
278
+ bundle exec rake round_trip:regenerate_goldens
279
+ ----
280
+
281
+ Review the resulting diffs and submit them with the change in a reviewed PR.
282
+ Do not edit goldens by hand or regenerate them merely to silence a regression.
283
+
284
+ For `postscript#4` parser parity, the test-support hook
285
+ `RoundTrip::Harness.call(svg, parser: candidate_parser)` accepts a callable
286
+ that receives serialized PS source and returns a compatible program. It
287
+ defaults to `Postscript::Source.parse` and returns an empty diff on a match,
288
+ or indexed expected/actual normalized records on a mismatch. This lets a
289
+ candidate parser reuse the same model contract without changing the public
290
+ conversion API.
291
+
292
+ The model contract also runs every manifest fixture with
293
+ `Postscript::Source.parse(source, engine: :native)`, comparing normalized
294
+ programs against both Ruby parsing and the emitted model. Run it with
295
+ `bundle exec rspec spec/postsvg/round_trip_contract_spec.rb --tag native`.
296
+ When the installed postscript lacks the native API or its optional parsanol
297
+ native engine is unavailable, examples skip with an explicit reason. These
298
+ skips do not establish native parity; see
299
+ `spec/fixtures/round_trip/README.adoc` for prerequisites and marker behavior.
300
+
243
301
  == Command-line interface
244
302
 
245
303
  [[cli]]
data/Rakefile CHANGED
@@ -3,12 +3,29 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
  require "rubocop/rake_task"
6
+ require "yaml"
6
7
 
7
8
  RSpec::Core::RakeTask.new(:spec)
8
9
  RuboCop::RakeTask.new
9
10
 
10
11
  task default: %i[spec rubocop]
11
12
 
13
+ namespace :round_trip do
14
+ desc "Regenerate pinned PostScript output for the round-trip corpus"
15
+ task :regenerate_goldens do
16
+ require "postsvg"
17
+
18
+ root = File.expand_path("spec/fixtures/round_trip", __dir__)
19
+ manifest = YAML.safe_load_file(File.join(root, "manifest.yml"))
20
+
21
+ manifest.fetch("fixtures").each do |entry|
22
+ svg_path = File.join(root, entry.fetch("file"))
23
+ golden_path = svg_path.sub(/\.svg\z/, ".ps.golden")
24
+ File.binwrite(golden_path, Postsvg.to_ps(File.read(svg_path)))
25
+ end
26
+ end
27
+ end
28
+
12
29
  # Link checking tasks
13
30
  namespace :check do
14
31
  desc "Check internal documentation links only (recommended for CI)"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile "../../Gemfile"
4
+
5
+ # Drop this CI-only overlay once postscript releases NativeEngine and
6
+ # parsanol/parsanol-ruby#354 ships a fix for previously baked grammars.
7
+ gem "parsanol", "1.3.109"
8
+ gem "postscript", git: "https://github.com/claricle/postscript.git", branch: "main"
data/lib/postsvg/cli.rb CHANGED
@@ -8,7 +8,8 @@ module Postsvg
8
8
  class CLI < Thor
9
9
  package_name "postsvg"
10
10
 
11
- desc "convert INPUT [OUTPUT]", "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
11
+ desc "convert INPUT [OUTPUT]",
12
+ "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
12
13
  long_desc <<~DESC
13
14
  Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
14
15
 
@@ -20,7 +21,8 @@ module Postsvg
20
21
  $ postsvg convert input.eps > output.svg
21
22
  DESC
22
23
  def convert(input_path, output_path = nil)
23
- handle_input(input_path, output_path, method(:read_ps_to_svg), direction: "PS/EPS -> SVG")
24
+ handle_input(input_path, output_path, method(:read_ps_to_svg),
25
+ direction: "PS/EPS -> SVG")
24
26
  end
25
27
 
26
28
  desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
@@ -42,7 +44,8 @@ module Postsvg
42
44
  $ postsvg to-ps input.svg > output.ps
43
45
  DESC
44
46
  def to_ps(input_path, output_path = nil)
45
- handle_input(input_path, output_path, method(:read_svg_to_ps), direction: "SVG -> PS")
47
+ handle_input(input_path, output_path, method(:read_svg_to_ps),
48
+ direction: "SVG -> PS")
46
49
  end
47
50
 
48
51
  desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
@@ -51,10 +54,12 @@ module Postsvg
51
54
  suitable for embedding.
52
55
  DESC
53
56
  def to_eps(input_path, output_path = nil)
54
- handle_input(input_path, output_path, method(:read_svg_to_eps), direction: "SVG -> EPS")
57
+ handle_input(input_path, output_path, method(:read_svg_to_eps),
58
+ direction: "SVG -> EPS")
55
59
  end
56
60
 
57
- desc "batch INPUT_DIR [OUTPUT_DIR]", "Convert all PS/EPS/SVG files in a directory"
61
+ desc "batch INPUT_DIR [OUTPUT_DIR]",
62
+ "Convert all PS/EPS/SVG files in a directory"
58
63
  long_desc <<~DESC
59
64
  Auto-detects direction by file extension:
60
65
 
@@ -60,7 +60,8 @@ module Postsvg
60
60
  font_name: overrides.fetch(:font_name, @font_name),
61
61
  font_size: overrides.fetch(:font_size, @font_size),
62
62
  clip_paths: overrides.fetch(:clip_paths, @clip_paths),
63
- last_text_position: overrides.fetch(:last_text_position, @last_text_position),
63
+ last_text_position: overrides.fetch(:last_text_position,
64
+ @last_text_position),
64
65
  fill_rule: overrides.fetch(:fill_rule, @fill_rule),
65
66
  )
66
67
  end
@@ -334,9 +334,8 @@ module Postsvg
334
334
  end
335
335
 
336
336
  def flush_path(mode)
337
- # rubocop:disable Style/ZeroLengthPredicate
337
+ # rubocop:disable-next Style/ZeroLengthPredicate
338
338
  return if @path.length.zero?
339
- # rubocop:enable Style/ZeroLengthPredicate
340
339
 
341
340
  d = @path.to_path
342
341
  path_str = emit_svg_path(d, @g_state, mode)
@@ -22,7 +22,7 @@ module Postsvg
22
22
  {
23
23
  eps: @eps, width: @width, height: @height,
24
24
  viewbox_override: @viewbox_override, verbose: @verbose,
25
- page_size: @page_size,
25
+ page_size: @page_size
26
26
  }
27
27
  end
28
28
  end
@@ -38,7 +38,7 @@ module Postsvg
38
38
  (str("\\") >> any) | (str(")").absent? >> any)
39
39
  end
40
40
  rule(:string) do
41
- (str("(") >> string_char.repeat.as(:string) >> str(")"))
41
+ str("(") >> string_char.repeat.as(:string) >> str(")")
42
42
  end
43
43
 
44
44
  # Operators and keywords
@@ -49,16 +49,16 @@ module Postsvg
49
49
 
50
50
  # Arrays
51
51
  rule(:array) do
52
- (str("[") >> whitespace? >>
53
- value.repeat.as(:array) >>
54
- whitespace? >> str("]"))
52
+ str("[") >> whitespace? >>
53
+ value.repeat.as(:array) >>
54
+ whitespace? >> str("]")
55
55
  end
56
56
 
57
57
  # Procedures (code blocks in curly braces)
58
58
  rule(:procedure) do
59
- (str("{") >> whitespace? >>
60
- statement.repeat.as(:procedure) >>
61
- whitespace? >> str("}"))
59
+ str("{") >> whitespace? >>
60
+ statement.repeat.as(:procedure) >>
61
+ whitespace? >> str("}")
62
62
  end
63
63
 
64
64
  # Values
@@ -13,7 +13,10 @@ module Postsvg
13
13
  attr_reader :program, :options, :builder, :visitor
14
14
 
15
15
  def initialize(program, options)
16
- raise ArgumentError, "program must be a Model::Program" unless program.is_a?(Model::Program)
16
+ unless program.is_a?(Model::Program)
17
+ raise ArgumentError,
18
+ "program must be a Model::Program"
19
+ end
17
20
 
18
21
  @program = program
19
22
  @options = options
@@ -25,7 +28,7 @@ module Postsvg
25
28
  open_svg
26
29
  wrap_y_flip do
27
30
  visitor.visit_program(program)
28
- rescue Postsvg::QuitSignal
31
+ rescue Postscript::QuitSignal
29
32
  # PS +quit+ ends program execution; suppress and finalize SVG.
30
33
  end
31
34
  builder.close_svg
@@ -62,7 +65,8 @@ module Postsvg
62
65
  [viewbox, width, height]
63
66
  else
64
67
  width, height = options.page_size
65
- ["0 0 #{FormatNumber.call(width)} #{FormatNumber.call(height)}", width, height]
68
+ ["0 0 #{FormatNumber.call(width)} #{FormatNumber.call(height)}", width,
69
+ height]
66
70
  end
67
71
  end
68
72
 
@@ -34,7 +34,9 @@ module Postsvg
34
34
  # Concatenate the d attributes of all child <path> elements
35
35
  # in document order. Other shape children are converted to
36
36
  # path data lazily; for now we only support <path>.
37
- ds = node.xpath(".//*[local-name()='path']").map { |p| p["d"] }.compact
37
+ ds = node.xpath(".//*[local-name()='path']").filter_map do |p|
38
+ p["d"]
39
+ end
38
40
  registry[id] = ds.join(" ") unless ds.empty?
39
41
  end
40
42
  new(registry)
@@ -6,7 +6,7 @@ module Postsvg
6
6
  # carry typed semantic fields; the base provides a uniform interface
7
7
  # for the translation layer's dispatch.
8
8
  #
9
- # Element instances are immutable: built once from a Nokogiri node
9
+ # Element instances are immutable: built once from a leptris node
10
10
  # via the .from_node factory. The node is not retained.
11
11
  #
12
12
  # Every element exposes `children`, `transform`, `clip_path_id`,
@@ -62,7 +62,9 @@ module Postsvg
62
62
  return subclass.from_node(node) if subclass
63
63
 
64
64
  OpenElement.new(name: node.name,
65
- children: node.element_children.map { |c| from_node(c) })
65
+ children: node.element_children.map do |c|
66
+ from_node(c)
67
+ end)
66
68
  end
67
69
  end
68
70
  end
@@ -9,7 +9,8 @@ module Postsvg
9
9
 
10
10
  attr_reader :cx, :cy, :rx, :ry, :fill, :stroke_paint, :stroke
11
11
 
12
- def initialize(cx:, cy:, rx:, ry:, fill:, stroke_paint:, stroke:, **rest)
12
+ def initialize(cx:, cy:, rx:, ry:, fill:, stroke_paint:, stroke:,
13
+ **rest)
13
14
  super(**rest)
14
15
  @cx = cx
15
16
  @cy = cy
@@ -9,7 +9,8 @@ module Postsvg
9
9
 
10
10
  attr_reader :viewbox, :width, :height, :children
11
11
 
12
- def initialize(viewbox: nil, width: nil, height: nil, children: [], **rest)
12
+ def initialize(viewbox: nil, width: nil, height: nil, children: [],
13
+ **rest)
13
14
  super(**rest)
14
15
  @viewbox = viewbox
15
16
  @width = width
@@ -15,7 +15,7 @@ module Postsvg
15
15
  end
16
16
 
17
17
  color = begin
18
- ::Postsvg::Color.parse(text.to_s)
18
+ ::Postscript::Color.parse(text.to_s)
19
19
  rescue ArgumentError
20
20
  nil
21
21
  end
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri"
3
+ require "leptris"
4
4
 
5
5
  module Postsvg
6
6
  module Svg
7
- # Parses an SVG document via Nokogiri and produces an Svg::Document.
7
+ # Parses an SVG document via leptris and produces an Svg::Document.
8
8
  # Pure: no I/O, no global state.
9
9
  class Parser
10
10
  def self.call(svg_string)
11
- doc = Nokogiri::XML(svg_string, &:noblanks)
11
+ doc = Leptris::XML.parse(svg_string, options: Leptris::XML::ParseOptions.noblanks)
12
12
  root = doc.root
13
13
  raise TranslationError, "no root element in SVG" unless root
14
14
 
@@ -10,7 +10,7 @@ module Postsvg
10
10
  module_function
11
11
 
12
12
  COMMAND_RE = /([MmLlHhVvCcSsQqTtAaZz])/
13
- ARG_RE = /(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/.freeze
13
+ ARG_RE = /(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/
14
14
 
15
15
  def parse(text)
16
16
  return [] if text.nil? || text.empty?
@@ -36,7 +36,11 @@ module Postsvg
36
36
  commands << Command.new(opcode: opcode, args: args)
37
37
  i += consumed
38
38
  # After moveto, implicit lineto for subsequent tuples.
39
- opcode = (opcode == "M") ? "L" : (opcode == "m" ? "l" : opcode)
39
+ opcode = if opcode == "M"
40
+ "L"
41
+ else
42
+ (opcode == "m" ? "l" : opcode)
43
+ end
40
44
  # For H/V/Z, no implicit repetition.
41
45
  break if %w[H V Z h v z].include?(opcode)
42
46
  end
@@ -57,12 +61,12 @@ module Postsvg
57
61
  end
58
62
 
59
63
  def tokenize(text)
60
- text.to_s.gsub(/-/, " -")
61
- .scan(/([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/)
62
- .flatten.compact
64
+ text.to_s.gsub("-", " -")
65
+ .scan(/([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/)
66
+ .flatten.compact
63
67
  end
64
68
 
65
- NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/.freeze
69
+ NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/
66
70
 
67
71
  def take_args(tokens, start, count)
68
72
  args = []
@@ -14,10 +14,12 @@ module Postsvg
14
14
  if dasharray_raw && dasharray_raw != "none"
15
15
  AttributeParser.number_list(dasharray_raw)
16
16
  end
17
- dashoffset = AttributeParser.number(node["stroke-dashoffset"], default: 0.0)
17
+ dashoffset = AttributeParser.number(node["stroke-dashoffset"],
18
+ default: 0.0)
18
19
  linecap = (node["stroke-linecap"] || "butt").to_sym
19
20
  linejoin = (node["stroke-linejoin"] || "miter").to_sym
20
- miterlimit = AttributeParser.number(node["stroke-miterlimit"], default: 10.0)
21
+ miterlimit = AttributeParser.number(node["stroke-miterlimit"],
22
+ default: 10.0)
21
23
  new(width: width, dasharray: dasharray, dashoffset: dashoffset,
22
24
  linecap: linecap, linejoin: linejoin, miterlimit: miterlimit)
23
25
  end
@@ -3,7 +3,7 @@
3
3
  module Postsvg
4
4
  module Svg
5
5
  # Parsed SVG transform attribute. Produces a list of
6
- # Postsvg::Matrix instances that, multiplied in order, give the
6
+ # Postscript::Matrix instances that, multiplied in order, give the
7
7
  # composite CTM for the element.
8
8
  class TransformList
9
9
  attr_reader :matrices
data/lib/postsvg/svg.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Postsvg
4
- # SVG domain model. Built once from a Nokogiri-parsed document;
4
+ # SVG domain model. Built once from a leptris-parsed document;
5
5
  # consumed by the Translation layer (SVG -> PS).
6
6
  #
7
7
  # Svg::* value objects know nothing about PostScript. Their job is
@@ -11,9 +11,9 @@ module Postsvg
11
11
  # - <defs> block orders clipPaths, gradients, then patterns by
12
12
  # registration order so output is reproducible.
13
13
  class SvgBuilder
14
- CLIP_PREFIX = "clip".freeze
15
- GRADIENT_PREFIX = "grad".freeze
16
- PATTERN_PREFIX = "pattern".freeze
14
+ CLIP_PREFIX = "clip"
15
+ GRADIENT_PREFIX = "grad"
16
+ PATTERN_PREFIX = "pattern"
17
17
 
18
18
  def initialize
19
19
  @buffer = String.new(capacity: 4096)
@@ -132,7 +132,7 @@ module Postsvg
132
132
  def path(d:, mode:, color: nil, stroke_color: nil,
133
133
  stroke_width: nil, line_cap: nil, line_join: nil,
134
134
  dash: nil, clip_path_id: nil, fill_id: nil)
135
- attrs = +"d=\"#{d}\""
135
+ attrs = "d=\"#{d}\""
136
136
 
137
137
  attrs << case mode
138
138
  when :fill
@@ -140,7 +140,8 @@ module Postsvg
140
140
  when :stroke
141
141
  %( fill="none" stroke="#{color_attr(stroke_color || color)}")
142
142
  when :fill_and_stroke
143
- "#{fill_attr(color, fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
143
+ "#{fill_attr(color,
144
+ fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
144
145
  else
145
146
  %( fill="none" stroke="none")
146
147
  end
@@ -184,7 +185,10 @@ module Postsvg
184
185
 
185
186
  def close_svg
186
187
  raise RenderError, "svg not open" unless @svg_open
187
- raise RenderError, "unclosed groups: #{@group_depth}" if @group_depth.positive?
188
+ if @group_depth.positive?
189
+ raise RenderError,
190
+ "unclosed groups: #{@group_depth}"
191
+ end
188
192
 
189
193
  @buffer << "<defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
190
194
  @buffer << @defs_buffer
@@ -239,11 +243,11 @@ module Postsvg
239
243
 
240
244
  def escape(text)
241
245
  text.to_s
242
- .gsub("&", "&amp;")
243
- .gsub("<", "&lt;")
244
- .gsub(">", "&gt;")
245
- .gsub('"', "&quot;")
246
- .gsub("'", "&#39;")
246
+ .gsub("&", "&amp;")
247
+ .gsub("<", "&lt;")
248
+ .gsub(">", "&gt;")
249
+ .gsub('"', "&quot;")
250
+ .gsub("'", "&#39;")
247
251
  end
248
252
  end
249
253
  end
@@ -11,7 +11,7 @@ module Postsvg
11
11
 
12
12
  module_function
13
13
 
14
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
14
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
15
15
  def endpoint_to_center(x1:, y1:, rx:, ry:, x_axis_rotation:,
16
16
  large_arc:, sweep:, x2:, y2:)
17
17
  rx = rx.abs
@@ -26,8 +26,8 @@ module Postsvg
26
26
  # Step 1: rotate and scale (x1,y1) into the unit-circle frame.
27
27
  dx = (x1 - x2) / 2.0
28
28
  dy = (y1 - y2) / 2.0
29
- x1p = cos_phi * dx + sin_phi * dy
30
- y1p = -sin_phi * dx + cos_phi * dy
29
+ x1p = (cos_phi * dx) + (sin_phi * dy)
30
+ y1p = (-sin_phi * dx) + (cos_phi * dy)
31
31
 
32
32
  # Ensure radii are large enough; if not, scale them up.
33
33
  rx_sq = rx * rx
@@ -44,17 +44,17 @@ module Postsvg
44
44
  end
45
45
 
46
46
  # Step 2: compute center (cx', cy') in the rotated frame.
47
- sign = (large_arc == sweep) ? -1 : 1
48
- num = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq
49
- den = rx_sq * y1p_sq + ry_sq * x1p_sq
47
+ sign = large_arc == sweep ? -1 : 1
48
+ num = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq)
49
+ den = (rx_sq * y1p_sq) + (ry_sq * x1p_sq)
50
50
  den = EPSILON if den.abs < EPSILON
51
51
  coef = sign * Math.sqrt([num / den, 0.0].max)
52
52
  cxp = coef * (rx * y1p) / ry
53
53
  cyp = -coef * (ry * x1p) / rx
54
54
 
55
55
  # Step 3: rotate (cx', cy') back into original frame.
56
- cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
57
- cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
56
+ cx = (cos_phi * cxp) - (sin_phi * cyp) + ((x1 + x2) / 2.0)
57
+ cy = (sin_phi * cxp) + (cos_phi * cyp) + ((y1 + y2) / 2.0)
58
58
 
59
59
  # Step 4: compute angles.
60
60
  ux = (x1p - cxp) / rx
@@ -70,16 +70,25 @@ module Postsvg
70
70
 
71
71
  [cx, cy, rx, ry, phi, theta1 * 180.0 / Math::PI, theta2 * 180.0 / Math::PI]
72
72
  end
73
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
74
73
 
75
74
  def angle_between(ux, uy, vx, vy)
76
- dot = ux * vx + uy * vy
77
- len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
75
+ len = vector_length(ux, uy, vx, vy)
78
76
  return 0.0 if len < EPSILON
79
77
 
80
- cos_val = (dot / len).clamp(-1.0, 1.0)
81
- sign = (ux * vy - uy * vx).negative? ? -1.0 : 1.0
82
- sign * Math.acos(cos_val)
78
+ cos_val = (dot_product(ux, uy, vx, vy) / len).clamp(-1.0, 1.0)
79
+ cross_sign(ux, uy, vx, vy) * Math.acos(cos_val)
80
+ end
81
+
82
+ def vector_length(ux, uy, vx, vy)
83
+ Math.sqrt(((ux * ux) + (uy * uy)) * ((vx * vx) + (vy * vy)))
84
+ end
85
+
86
+ def dot_product(ux, uy, vx, vy)
87
+ (ux * vx) + (uy * vy)
88
+ end
89
+
90
+ def cross_sign(ux, uy, vx, vy)
91
+ ((ux * vy) - (uy * vx)).negative? ? -1.0 : 1.0
83
92
  end
84
93
  end
85
94
  end
@@ -27,11 +27,14 @@ module Postsvg
27
27
  def expand(x_range, y_range)
28
28
  return self if x_range.nil? || y_range.nil?
29
29
 
30
- new_min_x = [(empty? ? x_range.begin : @min_x), x_range.begin].compact.min
30
+ new_min_x = [(empty? ? x_range.begin : @min_x),
31
+ x_range.begin].compact.min
31
32
  new_max_x = [(empty? ? x_range.end : @max_x), x_range.end].compact.max
32
- new_min_y = [(empty? ? y_range.begin : @min_y), y_range.begin].compact.min
33
+ new_min_y = [(empty? ? y_range.begin : @min_y),
34
+ y_range.begin].compact.min
33
35
  new_max_y = [(empty? ? y_range.end : @max_y), y_range.end].compact.max
34
- BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x, max_y: new_max_y)
36
+ BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x,
37
+ max_y: new_max_y)
35
38
  end
36
39
 
37
40
  def to_a
@@ -51,7 +54,7 @@ module Postsvg
51
54
 
52
55
  "%s %s %s %s" % [
53
56
  FormatNumber.call(@min_x), FormatNumber.call(@min_y),
54
- FormatNumber.call(@max_x), FormatNumber.call(@max_y),
57
+ FormatNumber.call(@max_x), FormatNumber.call(@max_y)
55
58
  ]
56
59
  end
57
60
  end