postsvg 0.2.6 → 0.3.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +49 -533
  3. data/CLAUDE.md +3 -27
  4. data/Gemfile +2 -2
  5. data/README.adoc +4 -62
  6. data/Rakefile +0 -17
  7. data/lib/postsvg/cli.rb +5 -10
  8. data/lib/postsvg/color.rb +132 -0
  9. data/lib/postsvg/format_number.rb +22 -0
  10. data/lib/postsvg/graphics_context.rb +1 -2
  11. data/lib/postsvg/interpreter.rb +2 -1
  12. data/lib/postsvg/matrix.rb +106 -0
  13. data/lib/postsvg/model/literals/array.rb +41 -0
  14. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  15. data/lib/postsvg/model/literals/hex.rb +37 -0
  16. data/lib/postsvg/model/literals/name.rb +40 -0
  17. data/lib/postsvg/model/literals/number.rb +36 -0
  18. data/lib/postsvg/model/literals/procedure.rb +41 -0
  19. data/lib/postsvg/model/literals/string.rb +30 -0
  20. data/lib/postsvg/model/literals.rb +19 -0
  21. data/lib/postsvg/model/operator.rb +58 -0
  22. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  23. data/lib/postsvg/model/operators/boolean.rb +182 -0
  24. data/lib/postsvg/model/operators/color.rb +74 -0
  25. data/lib/postsvg/model/operators/container.rb +186 -0
  26. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  27. data/lib/postsvg/model/operators/device.rb +21 -0
  28. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  29. data/lib/postsvg/model/operators/font.rb +121 -0
  30. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  31. data/lib/postsvg/model/operators/painting.rb +29 -0
  32. data/lib/postsvg/model/operators/path.rb +169 -0
  33. data/lib/postsvg/model/operators/stack.rb +72 -0
  34. data/lib/postsvg/model/operators/transformations.rb +103 -0
  35. data/lib/postsvg/model/operators.rb +89 -0
  36. data/lib/postsvg/model/program.rb +68 -0
  37. data/lib/postsvg/model/token.rb +43 -0
  38. data/lib/postsvg/model.rb +17 -0
  39. data/lib/postsvg/options.rb +1 -1
  40. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  41. data/lib/postsvg/renderer.rb +3 -7
  42. data/lib/postsvg/serializer.rb +325 -0
  43. data/lib/postsvg/source/ast_builder.rb +308 -0
  44. data/lib/postsvg/source/lexer.rb +322 -0
  45. data/lib/postsvg/source/operand_stack.rb +55 -0
  46. data/lib/postsvg/source.rb +21 -0
  47. data/lib/postsvg/svg/clip_path_registry.rb +1 -3
  48. data/lib/postsvg/svg/element.rb +2 -4
  49. data/lib/postsvg/svg/elements/ellipse.rb +1 -2
  50. data/lib/postsvg/svg/elements/svg.rb +1 -2
  51. data/lib/postsvg/svg/paint.rb +1 -1
  52. data/lib/postsvg/svg/parser.rb +3 -3
  53. data/lib/postsvg/svg/path_data/parser.rb +6 -10
  54. data/lib/postsvg/svg/stroke.rb +2 -4
  55. data/lib/postsvg/svg/transform_list.rb +1 -1
  56. data/lib/postsvg/svg.rb +1 -1
  57. data/lib/postsvg/svg_builder.rb +11 -15
  58. data/lib/postsvg/translation/arc_converter.rb +14 -23
  59. data/lib/postsvg/translation/bounding_box.rb +4 -7
  60. data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
  61. data/lib/postsvg/translation/handlers/ellipse_handler.rb +3 -8
  62. data/lib/postsvg/translation/handlers/group_handler.rb +1 -2
  63. data/lib/postsvg/translation/handlers/image_handler.rb +2 -3
  64. data/lib/postsvg/translation/handlers/line_handler.rb +2 -6
  65. data/lib/postsvg/translation/handlers/open_handler.rb +2 -3
  66. data/lib/postsvg/translation/handlers/path_handler.rb +60 -82
  67. data/lib/postsvg/translation/handlers/polygon_handler.rb +2 -4
  68. data/lib/postsvg/translation/handlers/polyline_handler.rb +4 -5
  69. data/lib/postsvg/translation/handlers/rect_handler.rb +5 -10
  70. data/lib/postsvg/translation/handlers/shared.rb +11 -11
  71. data/lib/postsvg/translation/handlers/svg_handler.rb +2 -3
  72. data/lib/postsvg/translation/handlers/text_handler.rb +8 -17
  73. data/lib/postsvg/translation/handlers.rb +1 -2
  74. data/lib/postsvg/translation/ps_renderer.rb +4 -18
  75. data/lib/postsvg/translation/record_emitter.rb +0 -54
  76. data/lib/postsvg/version.rb +1 -1
  77. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
  78. data/lib/postsvg/visitors/ps_visitor/color.rb +5 -8
  79. data/lib/postsvg/visitors/ps_visitor/common.rb +2 -1
  80. data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
  81. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
  82. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +4 -8
  83. data/lib/postsvg/visitors/ps_visitor/font.rb +4 -15
  84. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +5 -7
  85. data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
  86. data/lib/postsvg/visitors/ps_visitor/path.rb +33 -52
  87. data/lib/postsvg/visitors/ps_visitor/transformations.rb +7 -45
  88. data/lib/postsvg/visitors/ps_visitor.rb +2 -18
  89. data/lib/postsvg.rb +38 -43
  90. metadata +39 -20
  91. data/gemfiles/native-parity/Gemfile +0 -8
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 leptris. 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 Nokogiri. 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,30 +27,6 @@ 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
-
54
30
  ## Architecture
55
31
 
56
32
  ```
@@ -78,7 +54,7 @@ PS/EPS bytes ──▶│ Postsvg::Lexer │
78
54
  │ │
79
55
  ▼ │
80
56
  SVG string │
81
- │ leptris │
57
+ │ Nokogiri │
82
58
  ▼ │
83
59
  ┌─────────────────┐ │
84
60
  │ Postsvg:: │ │
@@ -187,7 +163,7 @@ Errors: `Postsvg::ParseError`, `LexError`, `SyntaxError`, `RenderError`, `StackU
187
163
 
188
164
  ## Dependencies
189
165
 
190
- - `parslet`, `thor`, `leptris`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
166
+ - `parslet`, `thor`, `nokogiri`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
191
167
 
192
168
  ## Limitations
193
169
 
data/Gemfile CHANGED
@@ -4,8 +4,8 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
- gem "canon"
8
- gem "leptris", ">= 1.9.399.0"
7
+ gem "canon", github: "lutaml/canon", branch: "main"
8
+ gem "nokogiri"
9
9
  gem "rake"
10
10
  gem "rspec"
11
11
  gem "rubocop"
data/README.adoc CHANGED
@@ -225,9 +225,8 @@ equivalent PS `arc` operator.
225
225
 
226
226
  === Round-trip
227
227
 
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:
228
+ The same `Model::Program` value object sits behind both directions, so
229
+ round-trip is a function composition:
231
230
 
232
231
  [source,ruby]
233
232
  ----
@@ -237,67 +236,10 @@ original_svg = File.read("input.svg")
237
236
  ps = Postsvg.to_ps(original_svg)
238
237
  round_tripped_svg = Postsvg.to_svg(ps)
239
238
 
239
+ # round_tripped_svg preserves the geometry of original_svg for the
240
+ # supported subset of SVG elements.
240
241
  ----
241
242
 
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
-
301
243
  == Command-line interface
302
244
 
303
245
  [[cli]]
data/Rakefile CHANGED
@@ -3,29 +3,12 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
  require "rubocop/rake_task"
6
- require "yaml"
7
6
 
8
7
  RSpec::Core::RakeTask.new(:spec)
9
8
  RuboCop::RakeTask.new
10
9
 
11
10
  task default: %i[spec rubocop]
12
11
 
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
-
29
12
  # Link checking tasks
30
13
  namespace :check do
31
14
  desc "Check internal documentation links only (recommended for CI)"
data/lib/postsvg/cli.rb CHANGED
@@ -8,8 +8,7 @@ module Postsvg
8
8
  class CLI < Thor
9
9
  package_name "postsvg"
10
10
 
11
- desc "convert INPUT [OUTPUT]",
12
- "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
11
+ desc "convert INPUT [OUTPUT]", "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
13
12
  long_desc <<~DESC
14
13
  Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
15
14
 
@@ -21,8 +20,7 @@ module Postsvg
21
20
  $ postsvg convert input.eps > output.svg
22
21
  DESC
23
22
  def convert(input_path, output_path = nil)
24
- handle_input(input_path, output_path, method(:read_ps_to_svg),
25
- direction: "PS/EPS -> SVG")
23
+ handle_input(input_path, output_path, method(:read_ps_to_svg), direction: "PS/EPS -> SVG")
26
24
  end
27
25
 
28
26
  desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
@@ -44,8 +42,7 @@ module Postsvg
44
42
  $ postsvg to-ps input.svg > output.ps
45
43
  DESC
46
44
  def to_ps(input_path, output_path = nil)
47
- handle_input(input_path, output_path, method(:read_svg_to_ps),
48
- direction: "SVG -> PS")
45
+ handle_input(input_path, output_path, method(:read_svg_to_ps), direction: "SVG -> PS")
49
46
  end
50
47
 
51
48
  desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
@@ -54,12 +51,10 @@ module Postsvg
54
51
  suitable for embedding.
55
52
  DESC
56
53
  def to_eps(input_path, output_path = nil)
57
- handle_input(input_path, output_path, method(:read_svg_to_eps),
58
- direction: "SVG -> EPS")
54
+ handle_input(input_path, output_path, method(:read_svg_to_eps), direction: "SVG -> EPS")
59
55
  end
60
56
 
61
- desc "batch INPUT_DIR [OUTPUT_DIR]",
62
- "Convert all PS/EPS/SVG files in a directory"
57
+ desc "batch INPUT_DIR [OUTPUT_DIR]", "Convert all PS/EPS/SVG files in a directory"
63
58
  long_desc <<~DESC
64
59
  Auto-detects direction by file extension:
65
60
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Immutable RGB color value object. Use the constructors (+.rgb+,
5
+ # +.gray+, +.cmyk+, +.parse+) instead of +new+ when constructing from
6
+ # other color models; they normalize to integer [0, 255] channels.
7
+ class Color
8
+ include Comparable
9
+
10
+ attr_reader :red, :green, :blue
11
+
12
+ def self.clamp_byte(value)
13
+ Integer === value ? value.clamp(0, 255) : value.to_i.clamp(0, 255)
14
+ end
15
+
16
+ def self.scale_unit_to_byte(value)
17
+ (value.clamp(0.0, 1.0) * 255.0).round
18
+ end
19
+
20
+ def initialize(red, green, blue)
21
+ @red = Color.clamp_byte(red)
22
+ @green = Color.clamp_byte(green)
23
+ @blue = Color.clamp_byte(blue)
24
+ freeze
25
+ end
26
+
27
+ # RGB triple in PS-native [0.0, 1.0] floats.
28
+ def self.rgb(r, g, b)
29
+ new(scale_unit_to_byte(r), scale_unit_to_byte(g), scale_unit_to_byte(b))
30
+ end
31
+
32
+ # Gray in [0.0, 1.0].
33
+ def self.gray(level)
34
+ v = scale_unit_to_byte(level)
35
+ new(v, v, v)
36
+ end
37
+
38
+ # CMYK in [0.0, 1.0]. Conversion matches PLRM §8.2.4 simplified
39
+ # formula; not colorimetrically exact.
40
+ def self.cmyk(c, m, y, k)
41
+ new(
42
+ scale_unit_to_byte((1 - c) * (1 - k)),
43
+ scale_unit_to_byte((1 - m) * (1 - k)),
44
+ scale_unit_to_byte((1 - y) * (1 - k)),
45
+ )
46
+ end
47
+
48
+ # Parse CSS / SVG color notations: #rgb, #rrggbb, rgb(r, g, b),
49
+ # rgb(r g b / a), named colors (X11 subset).
50
+ def self.parse(text)
51
+ return text if text.is_a?(Color)
52
+
53
+ case text.to_s.downcase
54
+ when /\A#([0-9a-f]{6})\z/
55
+ hex = ::Regexp.last_match(1)
56
+ new(hex[0, 2].to_i(16), hex[2, 2].to_i(16), hex[4, 2].to_i(16))
57
+ when /\A#([0-9a-f]{3})\z/
58
+ hex = ::Regexp.last_match(1)
59
+ new((hex[0] * 2).to_i(16), (hex[1] * 2).to_i(16), (hex[2] * 2).to_i(16))
60
+ when /\argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
61
+ new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
62
+ when /\argb\(\s*(\d+)\s+(\d+)\s+(\d+)/
63
+ new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
64
+ when "none", "transparent"
65
+ nil
66
+ else
67
+ rgb = NAMED_COLORS[text.to_s]
68
+ return new(*rgb) if rgb
69
+
70
+ raise ArgumentError, "unrecognized color: #{text.inspect}"
71
+ end
72
+ end
73
+
74
+ def gray?
75
+ red == green && green == blue
76
+ end
77
+
78
+ def gray_level
79
+ (red + green + blue) / 765.0
80
+ end
81
+
82
+ def to_rgb_unit
83
+ [red / 255.0, green / 255.0, blue / 255.0]
84
+ end
85
+
86
+ def to_svg
87
+ format("#%02x%02x%02x", red, green, blue)
88
+ end
89
+
90
+ def to_ps_rgb
91
+ to_rgb_unit.map { |c| FormatNumber.call(c) }.join(" ")
92
+ end
93
+
94
+ def to_ps_gray
95
+ raise ArgumentError, "color is not gray" unless gray?
96
+
97
+ FormatNumber.call(gray_level)
98
+ end
99
+
100
+ def <=>(other)
101
+ return nil unless other.is_a?(Color)
102
+
103
+ [red, green, blue] <=> [other.red, other.green, other.blue]
104
+ end
105
+
106
+ def hash
107
+ [red, green, blue].hash
108
+ end
109
+
110
+ def eql?(other)
111
+ other.is_a?(Color) && other.red == red && other.green == green && other.blue == blue
112
+ end
113
+ alias == eql?
114
+
115
+ NAMED_COLORS = {
116
+ "black" => [0, 0, 0], "white" => [255, 255, 255],
117
+ "red" => [255, 0, 0], "green" => [0, 128, 0], "blue" => [0, 0, 255],
118
+ "yellow" => [255, 255, 0], "cyan" => [0, 255, 255],
119
+ "magenta" => [255, 0, 255], "gray" => [128, 128, 128],
120
+ "grey" => [128, 128, 128], "silver" => [192, 192, 192],
121
+ "maroon" => [128, 0, 0], "olive" => [128, 128, 0],
122
+ "navy" => [0, 0, 128], "purple" => [128, 0, 128],
123
+ "teal" => [0, 128, 128], "lime" => [0, 255, 0],
124
+ "aqua" => [0, 255, 255], "fuchsia" => [255, 0, 255],
125
+ "orange" => [255, 165, 0], "pink" => [255, 192, 203],
126
+ "brown" => [165, 42, 42],
127
+ }.freeze
128
+
129
+ BLACK = Color.new(0, 0, 0).freeze
130
+ WHITE = Color.new(255, 255, 255).freeze
131
+ end
132
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Single source of truth for formatting numbers in both directions.
5
+ #
6
+ # - Integers print without a trailing ".0".
7
+ # - Floats print to 4 decimals, trailing zeros stripped.
8
+ # - -0 normalizes to 0 (so viewBoxes don't read "0 -0 100 100").
9
+ # - +Inf / -Inf / NaN raise ArgumentError; callers should validate.
10
+ module FormatNumber
11
+ module_function
12
+
13
+ def call(value)
14
+ raise ArgumentError, "non-finite number: #{value.inspect}" unless value.finite?
15
+
16
+ normalized = value.zero? ? 0.0 : value
17
+ return normalized.to_i.to_s if normalized == normalized.to_i
18
+
19
+ format("%.4f", normalized).sub(/\.?0+$/, "")
20
+ end
21
+ end
22
+ end
@@ -60,8 +60,7 @@ 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,
64
- @last_text_position),
63
+ last_text_position: overrides.fetch(:last_text_position, @last_text_position),
65
64
  fill_rule: overrides.fetch(:fill_rule, @fill_rule),
66
65
  )
67
66
  end
@@ -334,8 +334,9 @@ module Postsvg
334
334
  end
335
335
 
336
336
  def flush_path(mode)
337
- # rubocop:disable-next Style/ZeroLengthPredicate
337
+ # rubocop:disable Style/ZeroLengthPredicate
338
338
  return if @path.length.zero?
339
+ # rubocop:enable Style/ZeroLengthPredicate
339
340
 
340
341
  d = @path.to_path
341
342
  path_str = emit_svg_path(d, @g_state, mode)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Matrix transformation class for PostScript coordinate transformations
5
+ # Implements affine transformation matrix [a b c d e f]
6
+ class Matrix
7
+ attr_accessor :a, :b, :c, :d, :e, :f
8
+
9
+ def initialize(a: 1, b: 0, c: 0, d: 1, e: 0, f: 0)
10
+ @a = a
11
+ @b = b
12
+ @c = c
13
+ @d = d
14
+ @e = e
15
+ @f = f
16
+ end
17
+
18
+ def multiply(matrix)
19
+ result = Matrix.new
20
+ result.a = (@a * matrix.a) + (@c * matrix.b)
21
+ result.b = (@b * matrix.a) + (@d * matrix.b)
22
+ result.c = (@a * matrix.c) + (@c * matrix.d)
23
+ result.d = (@b * matrix.c) + (@d * matrix.d)
24
+ result.e = (@a * matrix.e) + (@c * matrix.f) + @e
25
+ result.f = (@b * matrix.e) + (@d * matrix.f) + @f
26
+ result
27
+ end
28
+
29
+ def translate(tx, ty)
30
+ multiply(Matrix.new(e: tx, f: ty))
31
+ end
32
+
33
+ def scale(sx, sy)
34
+ multiply(Matrix.new(a: sx, d: sy))
35
+ end
36
+
37
+ def rotate(degrees)
38
+ radians = degrees * Math::PI / 180.0
39
+ m = Matrix.new
40
+ m.a = Math.cos(radians)
41
+ m.b = Math.sin(radians)
42
+ m.c = -Math.sin(radians)
43
+ m.d = Math.cos(radians)
44
+ multiply(m)
45
+ end
46
+
47
+ def skew_x(angle)
48
+ radians = angle * Math::PI / 180.0
49
+ multiply(Matrix.new(c: Math.tan(radians)))
50
+ end
51
+
52
+ def skew_y(angle)
53
+ radians = angle * Math::PI / 180.0
54
+ multiply(Matrix.new(b: Math.tan(radians)))
55
+ end
56
+
57
+ def to_transform_string
58
+ "matrix(#{@a} #{@b} #{@c} #{@d} #{@e} #{@f})"
59
+ end
60
+
61
+ def apply_point(x, y)
62
+ {
63
+ x: (x * @a) + (y * @c) + @e,
64
+ y: (x * @b) + (y * @d) + @f,
65
+ }
66
+ end
67
+
68
+ def decompose
69
+ scale_x = Math.hypot(@a, @b)
70
+ scale_y = ((@a * @d) - (@b * @c)) / scale_x
71
+
72
+ rotation = Math.atan2(@b, @a) * (180.0 / Math::PI)
73
+
74
+ skew_x = Math.atan2((@a * @c) + (@b * @d), scale_x * scale_x)
75
+ skew_y = Math.atan2((@a * @b) + (@c * @d), scale_y * scale_y)
76
+
77
+ {
78
+ translate: { x: @e, y: @f },
79
+ scale: { x: scale_x, y: scale_y },
80
+ rotate: rotation,
81
+ skew: {
82
+ x: skew_x * (180.0 / Math::PI),
83
+ y: skew_y * (180.0 / Math::PI),
84
+ },
85
+ }
86
+ end
87
+
88
+ def invert
89
+ det = (@a * @d) - (@b * @c)
90
+ return Matrix.new if det.abs < 1e-10
91
+
92
+ inv = Matrix.new
93
+ inv.a = @d / det
94
+ inv.b = -@b / det
95
+ inv.c = -@c / det
96
+ inv.d = @a / det
97
+ inv.e = ((@c * @f) - (@d * @e)) / det
98
+ inv.f = ((@b * @e) - (@a * @f)) / det
99
+ inv
100
+ end
101
+
102
+ def identity?
103
+ @a == 1 && @b.zero? && @c.zero? && @d == 1 && @e.zero? && @f.zero?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # Bracketed array literal: +[1 2 3]+. Elements may be any
7
+ # literal or operator.
8
+ class ArrayLiteral
9
+ include Enumerable
10
+
11
+ attr_reader :elements
12
+
13
+ def initialize(elements = [])
14
+ @elements = elements.freeze
15
+ freeze
16
+ end
17
+
18
+ def each(&block)
19
+ @elements.each(&block)
20
+ end
21
+
22
+ def length = @elements.length
23
+ def empty? = @elements.empty?
24
+ def [](idx) = @elements[idx]
25
+
26
+ def accept(visitor, ctx)
27
+ visitor.visit_array(self, ctx)
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(ArrayLiteral) && other.elements == @elements
32
+ end
33
+ alias eql? ==
34
+
35
+ def hash
36
+ @elements.hash
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # Inline dictionary literal: +<< /Key (value) /Num 42 >>+.
7
+ class Dictionary
8
+ attr_reader :entries
9
+
10
+ def initialize(entries = {})
11
+ @entries = entries.dup.freeze
12
+ freeze
13
+ end
14
+
15
+ def [](key) = @entries[key]
16
+ def key?(key) = @entries.key?(key)
17
+ def each(&block) = @entries.each(&block)
18
+
19
+ def accept(visitor, ctx)
20
+ visitor.visit_dictionary(self, ctx)
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(Dictionary) && other.entries == @entries
25
+ end
26
+ alias eql? ==
27
+
28
+ def hash
29
+ @entries.hash
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # Hex-string literal: +<DEADBEEF>+.
7
+ class HexLiteral
8
+ attr_reader :value
9
+
10
+ def initialize(value)
11
+ @value = value.to_s
12
+ freeze
13
+ end
14
+
15
+ # Raw bytes encoded by the hex literal.
16
+ def bytes
17
+ hex = value.delete("^0-9a-fA-F")
18
+ hex << "0" if hex.length.odd?
19
+ hex.chars.each_slice(2).map { |pair| pair.join.to_i(16) }.pack("C*")
20
+ end
21
+
22
+ def accept(visitor, ctx)
23
+ visitor.visit_hex(self, ctx)
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(HexLiteral) && other.value == value
28
+ end
29
+ alias eql? ==
30
+
31
+ def hash
32
+ value.hash
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # A PostScript name. +literal:+ distinguishes +/foo+ (literal,
7
+ # pushed onto the stack) from +foo+ (executable, looked up and
8
+ # executed).
9
+ class Name
10
+ attr_reader :value, :literal
11
+
12
+ def initialize(value, literal: false)
13
+ @value = value.to_s
14
+ @literal = literal
15
+ freeze
16
+ end
17
+
18
+ def literal? = literal
19
+ def executable? = !literal
20
+
21
+ def to_s
22
+ @value
23
+ end
24
+
25
+ def accept(visitor, ctx)
26
+ visitor.visit_name(self, ctx)
27
+ end
28
+
29
+ def ==(other)
30
+ other.is_a?(Name) && other.value == value && other.literal == literal
31
+ end
32
+ alias eql? ==
33
+
34
+ def hash
35
+ [value, literal].hash
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end