postsvg 0.2.2 → 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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +49 -533
  3. data/Gemfile +1 -1
  4. data/lib/postsvg/cli.rb +5 -10
  5. data/lib/postsvg/color.rb +132 -0
  6. data/lib/postsvg/format_number.rb +22 -0
  7. data/lib/postsvg/graphics_context.rb +1 -2
  8. data/lib/postsvg/matrix.rb +106 -0
  9. data/lib/postsvg/model/literals/array.rb +41 -0
  10. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  11. data/lib/postsvg/model/literals/hex.rb +37 -0
  12. data/lib/postsvg/model/literals/name.rb +40 -0
  13. data/lib/postsvg/model/literals/number.rb +36 -0
  14. data/lib/postsvg/model/literals/procedure.rb +41 -0
  15. data/lib/postsvg/model/literals/string.rb +30 -0
  16. data/lib/postsvg/model/literals.rb +19 -0
  17. data/lib/postsvg/model/operator.rb +58 -0
  18. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  19. data/lib/postsvg/model/operators/boolean.rb +182 -0
  20. data/lib/postsvg/model/operators/color.rb +74 -0
  21. data/lib/postsvg/model/operators/container.rb +186 -0
  22. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  23. data/lib/postsvg/model/operators/device.rb +21 -0
  24. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  25. data/lib/postsvg/model/operators/font.rb +121 -0
  26. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  27. data/lib/postsvg/model/operators/painting.rb +29 -0
  28. data/lib/postsvg/model/operators/path.rb +169 -0
  29. data/lib/postsvg/model/operators/stack.rb +72 -0
  30. data/lib/postsvg/model/operators/transformations.rb +103 -0
  31. data/lib/postsvg/model/operators.rb +89 -0
  32. data/lib/postsvg/model/program.rb +68 -0
  33. data/lib/postsvg/model/token.rb +43 -0
  34. data/lib/postsvg/model.rb +17 -0
  35. data/lib/postsvg/options.rb +1 -1
  36. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  37. data/lib/postsvg/renderer.rb +3 -7
  38. data/lib/postsvg/serializer.rb +325 -0
  39. data/lib/postsvg/source/ast_builder.rb +308 -0
  40. data/lib/postsvg/source/lexer.rb +322 -0
  41. data/lib/postsvg/source/operand_stack.rb +55 -0
  42. data/lib/postsvg/source.rb +21 -0
  43. data/lib/postsvg/svg/clip_path_registry.rb +1 -3
  44. data/lib/postsvg/svg/element.rb +1 -3
  45. data/lib/postsvg/svg/elements/ellipse.rb +1 -2
  46. data/lib/postsvg/svg/elements/svg.rb +1 -2
  47. data/lib/postsvg/svg/paint.rb +1 -1
  48. data/lib/postsvg/svg/path_data/parser.rb +6 -10
  49. data/lib/postsvg/svg/stroke.rb +2 -4
  50. data/lib/postsvg/svg/transform_list.rb +1 -1
  51. data/lib/postsvg/svg_builder.rb +11 -15
  52. data/lib/postsvg/translation/arc_converter.rb +12 -22
  53. data/lib/postsvg/translation/bounding_box.rb +4 -7
  54. data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
  55. data/lib/postsvg/translation/handlers/ellipse_handler.rb +3 -8
  56. data/lib/postsvg/translation/handlers/group_handler.rb +1 -2
  57. data/lib/postsvg/translation/handlers/image_handler.rb +2 -3
  58. data/lib/postsvg/translation/handlers/line_handler.rb +2 -6
  59. data/lib/postsvg/translation/handlers/open_handler.rb +2 -3
  60. data/lib/postsvg/translation/handlers/path_handler.rb +41 -57
  61. data/lib/postsvg/translation/handlers/polygon_handler.rb +2 -4
  62. data/lib/postsvg/translation/handlers/polyline_handler.rb +2 -4
  63. data/lib/postsvg/translation/handlers/rect_handler.rb +5 -10
  64. data/lib/postsvg/translation/handlers/shared.rb +11 -11
  65. data/lib/postsvg/translation/handlers/svg_handler.rb +2 -3
  66. data/lib/postsvg/translation/handlers/text_handler.rb +7 -8
  67. data/lib/postsvg/translation/handlers.rb +1 -2
  68. data/lib/postsvg/translation/ps_renderer.rb +1 -2
  69. data/lib/postsvg/version.rb +1 -1
  70. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
  71. data/lib/postsvg/visitors/ps_visitor/color.rb +5 -8
  72. data/lib/postsvg/visitors/ps_visitor/common.rb +2 -1
  73. data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
  74. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
  75. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +4 -8
  76. data/lib/postsvg/visitors/ps_visitor/font.rb +4 -14
  77. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +4 -4
  78. data/lib/postsvg/visitors/ps_visitor/painting.rb +1 -1
  79. data/lib/postsvg/visitors/ps_visitor/path.rb +4 -9
  80. data/lib/postsvg.rb +37 -42
  81. metadata +36 -16
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
@@ -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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # A PostScript numeric literal. Stored as a Ruby Numeric so
7
+ # arithmetic Just Works; class carries the original source
8
+ # representation choice (int vs float) when known.
9
+ class Number
10
+ attr_reader :value
11
+
12
+ def initialize(value)
13
+ @value = value.is_a?(Numeric) ? value : Float(value)
14
+ freeze
15
+ end
16
+
17
+ def to_i = value.to_i
18
+ def to_f = value.to_f
19
+ def integer? = value.is_a?(Integer)
20
+
21
+ def accept(visitor, ctx)
22
+ visitor.visit_number(self, ctx)
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(Number) && other.value == value
27
+ end
28
+ alias eql? ==
29
+
30
+ def hash
31
+ value.hash
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # Procedure body: +{ ... }+. Carries the inner token stream as a
7
+ # list of literal/operator nodes. Procedures are not executed by
8
+ # the parser; the renderer descends into them on InvokeProcedure.
9
+ class Procedure
10
+ include Enumerable
11
+
12
+ attr_reader :body
13
+
14
+ def initialize(body = [])
15
+ @body = body.freeze
16
+ freeze
17
+ end
18
+
19
+ def each(&block)
20
+ @body.each(&block)
21
+ end
22
+
23
+ def length = @body.length
24
+ def empty? = @body.empty?
25
+
26
+ def accept(visitor, ctx)
27
+ visitor.visit_procedure(self, ctx)
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(Procedure) && other.body == @body
32
+ end
33
+ alias eql? ==
34
+
35
+ def hash
36
+ @body.hash
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ module Literals
6
+ # Parenthesized string literal: +(foo bar baz)+.
7
+ class StringLiteral
8
+ attr_reader :value
9
+
10
+ def initialize(value)
11
+ @value = value.to_s
12
+ freeze
13
+ end
14
+
15
+ def accept(visitor, ctx)
16
+ visitor.visit_string_literal(self, ctx)
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(StringLiteral) && other.value == value
21
+ end
22
+ alias eql? ==
23
+
24
+ def hash
25
+ value.hash
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ # PS literal value objects. Each is immutable, has value equality,
6
+ # and knows how to (a) emit itself to a serializer and (b) be
7
+ # visited by a renderer. They never carry executable behaviour —
8
+ # they are data.
9
+ module Literals
10
+ autoload :Number, "postsvg/model/literals/number"
11
+ autoload :Name, "postsvg/model/literals/name"
12
+ autoload :StringLiteral, "postsvg/model/literals/string"
13
+ autoload :HexLiteral, "postsvg/model/literals/hex"
14
+ autoload :ArrayLiteral, "postsvg/model/literals/array"
15
+ autoload :Procedure, "postsvg/model/literals/procedure"
16
+ autoload :Dictionary, "postsvg/model/literals/dictionary"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Model
5
+ # Common base for every PS operator. Subclasses live under
6
+ # Model::Operators::* grouped by PLRM chapter.
7
+ #
8
+ # Each operator declares:
9
+ # - +keyword+ (the PS source token, e.g. "moveto")
10
+ # - typed operand fields via +attr_reader+
11
+ # - +self.from_operands(stack)+ that pops operands and returns a new
12
+ # frozen instance
13
+ # - +self.visit_name+ (defaults to the keyword)
14
+ # - +self.consumes+ / +self.produces+ (stack-arity, for parser
15
+ # placeholders)
16
+ #
17
+ # The visitor calls +operator.accept(visitor, ctx)+ which routes to
18
+ # +visitor.visit_<visit_name>(operator, ctx)+ via +public_send+.
19
+ class Operator
20
+ class << self
21
+ # Register this class under +keyword+ in the global Registry.
22
+ # Called at the bottom of each subclass definition.
23
+ def register_as(keyword, consumes: 0, produces: 0)
24
+ Operators.register(keyword, self)
25
+ define_method(:keyword) { keyword }
26
+ define_method(:visit_name) { keyword }
27
+ @consumes = consumes
28
+ @produces = produces
29
+ end
30
+
31
+ attr_reader :consumes, :produces
32
+
33
+ # Default: a no-op factory that pops nothing. Subclasses
34
+ # override to consume operands off the parse stack.
35
+ def from_operands(_stack)
36
+ new
37
+ end
38
+ end
39
+
40
+ def accept(visitor, ctx)
41
+ visitor.dispatch(:"visit_#{visit_name}", self, ctx)
42
+ end
43
+
44
+ def operator? = true
45
+ end
46
+
47
+ # Sentinel value the parser pushes onto the operand stack when an
48
+ # operator's runtime result is unknown at parse time (e.g. the
49
+ # output of +findfont+, +add+, +dup+). Carries no semantic value;
50
+ # its purpose is to keep the parse-stack shape in sync with the
51
+ # runtime stack so chained operators parse cleanly.
52
+ class Computed < ::Struct.new(:operator_keyword, keyword_init: true)
53
+ def to_s
54
+ "<computed:#{operator_keyword}>"
55
+ end
56
+ end
57
+ end
58
+ end