postscript 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/Gemfile +12 -0
  4. data/README.adoc +72 -0
  5. data/Rakefile +10 -0
  6. data/lib/postscript/color.rb +132 -0
  7. data/lib/postscript/errors.rb +56 -0
  8. data/lib/postscript/format_number.rb +22 -0
  9. data/lib/postscript/matrix.rb +109 -0
  10. data/lib/postscript/model/literals/array.rb +41 -0
  11. data/lib/postscript/model/literals/dictionary.rb +34 -0
  12. data/lib/postscript/model/literals/hex.rb +37 -0
  13. data/lib/postscript/model/literals/name.rb +40 -0
  14. data/lib/postscript/model/literals/number.rb +36 -0
  15. data/lib/postscript/model/literals/procedure.rb +41 -0
  16. data/lib/postscript/model/literals/string.rb +30 -0
  17. data/lib/postscript/model/literals.rb +19 -0
  18. data/lib/postscript/model/operator.rb +58 -0
  19. data/lib/postscript/model/operators/arithmetic.rb +264 -0
  20. data/lib/postscript/model/operators/boolean.rb +182 -0
  21. data/lib/postscript/model/operators/color.rb +74 -0
  22. data/lib/postscript/model/operators/container.rb +186 -0
  23. data/lib/postscript/model/operators/control_flow.rb +119 -0
  24. data/lib/postscript/model/operators/device.rb +21 -0
  25. data/lib/postscript/model/operators/dictionary.rb +118 -0
  26. data/lib/postscript/model/operators/font.rb +121 -0
  27. data/lib/postscript/model/operators/graphics_state.rb +84 -0
  28. data/lib/postscript/model/operators/painting.rb +29 -0
  29. data/lib/postscript/model/operators/path.rb +169 -0
  30. data/lib/postscript/model/operators/stack.rb +72 -0
  31. data/lib/postscript/model/operators/transformations.rb +103 -0
  32. data/lib/postscript/model/operators.rb +89 -0
  33. data/lib/postscript/model/program.rb +68 -0
  34. data/lib/postscript/model/token.rb +43 -0
  35. data/lib/postscript/model.rb +17 -0
  36. data/lib/postscript/serializer.rb +325 -0
  37. data/lib/postscript/source/ast_builder.rb +308 -0
  38. data/lib/postscript/source/lexer.rb +322 -0
  39. data/lib/postscript/source/operand_stack.rb +55 -0
  40. data/lib/postscript/source.rb +21 -0
  41. data/lib/postscript/version.rb +5 -0
  42. data/lib/postscript.rb +33 -0
  43. data/postscript.gemspec +37 -0
  44. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 657b74cfdae6702c2d5aa7bc8f6fcc4d80c4319f38806b2d1a4c0f48d9d050e9
4
+ data.tar.gz: f13ac7de23328c6a211cd40e915942ab4228bdb12bd0cda6f565f557547fda20
5
+ SHA512:
6
+ metadata.gz: ca8e96d0ad7ca8b75f0b41690ed71e9b0e55167f1326db5cce018ae41f49a0e104b5fd77d588d4bfe5aa0ad41ebaa7eccb166b773db5e2b1dd5312a0f90063bd
7
+ data.tar.gz: 43a30efc33221687d6658e4fd61dbe4b97b9851991a8a345ba14b68f7e01e11c164d9c60c5773d0ae796777e64f4e7998f4884eecd609b9b33aee921a515ad78
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "rake"
8
+ gem "rspec"
9
+ gem "rubocop"
10
+ gem "rubocop-performance"
11
+ gem "rubocop-rake"
12
+ gem "rubocop-rspec"
data/README.adoc ADDED
@@ -0,0 +1,72 @@
1
+ = Postscript
2
+
3
+ Pure-Ruby PostScript (PS) / EPS parser, typed domain model, and
4
+ serializer. The lower layer of the
5
+ https://github.com/claricle/postsvg[postsvg] converter. Independently
6
+ reusable for any tool that needs to read, write, or transform
7
+ PostScript source.
8
+
9
+ == Installation
10
+
11
+ [source,sh]
12
+ ----
13
+ gem install postscript
14
+ ----
15
+
16
+ == Usage
17
+
18
+ [source,ruby]
19
+ ----
20
+ require "postscript"
21
+
22
+ # Parse PS source into a typed AST
23
+ program = Postscript::Source.parse(<<~PS)
24
+ %!PS-Adobe-3.0 EPSF-3.0
25
+ %%BoundingBox: 0 0 100 100
26
+ newpath
27
+ 10 10 moveto
28
+ 90 10 lineto
29
+ 90 90 lineto
30
+ 10 90 lineto
31
+ closepath
32
+ fill
33
+ showpage
34
+ PS
35
+
36
+ # Walk the AST
37
+ program.body.each do |node|
38
+ puts node.class
39
+ end
40
+
41
+ # Re-serialize back to PS source
42
+ ps_text = Postscript::Serializer.call(program)
43
+ puts ps_text
44
+ ----
45
+
46
+ == Architecture
47
+
48
+ Three layers, each with a single responsibility:
49
+
50
+ [cols="1,4", options="header"]
51
+ |===
52
+ | Layer | Owns
53
+ | `Postscript::Source` | PS source → tokens → `Model::Program`. Lexer is comment-aware.
54
+ | `Postscript::Model` | Typed PS records: `Program`, `Literals::*`, `Operators::*` (one class per PS operator).
55
+ | `Postscript::Serializer` | `Model::Program` → PS source text.
56
+ |===
57
+
58
+ Adding a new PS operator = writing one subclass + one `visit_*`
59
+ method. No switch edits (OCP).
60
+
61
+ == Value types
62
+
63
+ This gem also hosts three general-purpose value types that
64
+ postsvg depends on:
65
+
66
+ * `Postscript::Matrix` — 2D affine transform.
67
+ * `Postscript::Color` — RGB / Gray / CMYK immutable value.
68
+ * `Postscript::FormatNumber` — float → PS-safe text.
69
+
70
+ == License
71
+
72
+ BSD-2-Clause. See `LICENSE`.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ # Base class for every Postscript gem error. rescue this to catch
5
+ # anything the gem raises.
6
+ class Error < StandardError; end
7
+
8
+ # Lexing or parsing failed. Carries +source_position+ when known.
9
+ class ParseError < Error
10
+ attr_reader :source_position
11
+
12
+ def initialize(message, source_position: nil)
13
+ @source_position = source_position
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ # Lexer hit a character or construct it could not consume.
19
+ class LexError < ParseError; end
20
+
21
+ # Tokens did not form a valid PostScript program (unbalanced braces,
22
+ # missing operands, malformed literal).
23
+ class SyntaxError < ParseError; end
24
+
25
+ # PS -> SVG / serializer execution failed.
26
+ class RenderError < Error
27
+ attr_reader :operator_name
28
+
29
+ def initialize(message, operator_name: nil)
30
+ @operator_name = operator_name
31
+ super(message)
32
+ end
33
+ end
34
+
35
+ # An operator tried to pop more values than the stack held.
36
+ class StackUnderflowError < RenderError; end
37
+
38
+ # An operator referenced a name that has no definition in any
39
+ # active dictionary.
40
+ class UndefinedOperatorError < RenderError; end
41
+
42
+ # A procedure invocation chain exceeded the depth limit.
43
+ class RecursionLimitError < RenderError; end
44
+
45
+ # Output exceeded the configured byte limit.
46
+ class SizeLimitError < RenderError; end
47
+
48
+ # Model -> PS source serialization failed.
49
+ class SerializeError < Error; end
50
+
51
+ # Internal control-flow signals used by visitors to unwind loops
52
+ # and stop program execution. NOT user-facing errors; caught by
53
+ # the orchestrator (e.g. Postscript::Renderer).
54
+ class ExitSignal < StandardError; end
55
+ class QuitSignal < StandardError; end
56
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ # 2D affine transformation matrix [a b c d e f].
5
+ #
6
+ # Used by both PS-side (CTM, transforms) and SVG-side (transform
7
+ # attribute parsing). General-purpose; lives here so both postsvg
8
+ # and a future pdf gem can share it.
9
+ class Matrix
10
+ attr_accessor :a, :b, :c, :d, :e, :f
11
+
12
+ def initialize(a: 1, b: 0, c: 0, d: 1, e: 0, f: 0)
13
+ @a = a
14
+ @b = b
15
+ @c = c
16
+ @d = d
17
+ @e = e
18
+ @f = f
19
+ end
20
+
21
+ def multiply(matrix)
22
+ result = Matrix.new
23
+ result.a = (@a * matrix.a) + (@c * matrix.b)
24
+ result.b = (@b * matrix.a) + (@d * matrix.b)
25
+ result.c = (@a * matrix.c) + (@c * matrix.d)
26
+ result.d = (@b * matrix.c) + (@d * matrix.d)
27
+ result.e = (@a * matrix.e) + (@c * matrix.f) + @e
28
+ result.f = (@b * matrix.e) + (@d * matrix.f) + @f
29
+ result
30
+ end
31
+
32
+ def translate(tx, ty)
33
+ multiply(Matrix.new(e: tx, f: ty))
34
+ end
35
+
36
+ def scale(sx, sy)
37
+ multiply(Matrix.new(a: sx, d: sy))
38
+ end
39
+
40
+ def rotate(degrees)
41
+ radians = degrees * Math::PI / 180.0
42
+ m = Matrix.new
43
+ m.a = Math.cos(radians)
44
+ m.b = Math.sin(radians)
45
+ m.c = -Math.sin(radians)
46
+ m.d = Math.cos(radians)
47
+ multiply(m)
48
+ end
49
+
50
+ def skew_x(angle)
51
+ radians = angle * Math::PI / 180.0
52
+ multiply(Matrix.new(c: Math.tan(radians)))
53
+ end
54
+
55
+ def skew_y(angle)
56
+ radians = angle * Math::PI / 180.0
57
+ multiply(Matrix.new(b: Math.tan(radians)))
58
+ end
59
+
60
+ def to_transform_string
61
+ "matrix(#{@a} #{@b} #{@c} #{@d} #{@e} #{@f})"
62
+ end
63
+
64
+ def apply_point(x, y)
65
+ {
66
+ x: (x * @a) + (y * @c) + @e,
67
+ y: (x * @b) + (y * @d) + @f,
68
+ }
69
+ end
70
+
71
+ def decompose
72
+ scale_x = Math.hypot(@a, @b)
73
+ scale_y = ((@a * @d) - (@b * @c)) / scale_x
74
+
75
+ rotation = Math.atan2(@b, @a) * (180.0 / Math::PI)
76
+
77
+ skew_x = Math.atan2((@a * @c) + (@b * @d), scale_x * scale_x)
78
+ skew_y = Math.atan2((@a * @b) + (@c * @d), scale_y * scale_y)
79
+
80
+ {
81
+ translate: { x: @e, y: @f },
82
+ scale: { x: scale_x, y: scale_y },
83
+ rotate: rotation,
84
+ skew: {
85
+ x: skew_x * (180.0 / Math::PI),
86
+ y: skew_y * (180.0 / Math::PI),
87
+ },
88
+ }
89
+ end
90
+
91
+ def invert
92
+ det = (@a * @d) - (@b * @c)
93
+ return Matrix.new if det.abs < 1e-10
94
+
95
+ inv = Matrix.new
96
+ inv.a = @d / det
97
+ inv.b = -@b / det
98
+ inv.c = -@c / det
99
+ inv.d = @a / det
100
+ inv.e = ((@c * @f) - (@d * @e)) / det
101
+ inv.f = ((@b * @e) - (@a * @f)) / det
102
+ inv
103
+ end
104
+
105
+ def identity?
106
+ @a == 1 && @b.zero? && @c.zero? && @d == 1 && @e.zero? && @f.zero?
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
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 Postscript
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 Postscript
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 Postscript
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 Postscript
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 Postscript
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 Postscript
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