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
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ module Source
5
+ # Comment-aware PS lexer.
6
+ #
7
+ # Distinguishes +#+ line comments from +%+ inside string literals,
8
+ # captures DSC comments (lines beginning with +%%+), and tags every
9
+ # token with its source position (line, column).
10
+ #
11
+ # The legacy Tokenizer stripped comments with a global
12
+ # +gsub(/%[^\n\r]*/, " ")+ before lexing, which corrupted +%+ chars
13
+ # inside string literals. This implementation walks the source as a
14
+ # state machine so +%+ inside (...) or <...> survives.
15
+ class Lexer
16
+ attr_reader :source, :position, :line, :column
17
+
18
+ def initialize(source)
19
+ @source = source.to_s
20
+ @position = 0
21
+ @line = 1
22
+ @column = 1
23
+ @tokens = []
24
+ @state = :top
25
+ end
26
+
27
+ def tokenize
28
+ until eos?
29
+ case @state
30
+ when :top then scan_top
31
+ when :string then scan_string_body
32
+ when :hexstring then scan_hexstring_body
33
+ when :comment then scan_comment_body
34
+ when :dsc then scan_dsc_body
35
+ end
36
+ end
37
+ @tokens.freeze
38
+ end
39
+
40
+ def self.tokenize(source)
41
+ new(source).tokenize
42
+ end
43
+
44
+ private
45
+
46
+ def eos? = @position >= @source.bytesize
47
+
48
+ def peek(offset = 0)
49
+ @source.getbyte(@position + offset)
50
+ end
51
+
52
+ def advance
53
+ byte = @source.getbyte(@position)
54
+ @position += 1
55
+ if byte == 10 # \n
56
+ @line += 1
57
+ @column = 1
58
+ else
59
+ @column += 1
60
+ end
61
+ byte
62
+ end
63
+
64
+ def scan_top
65
+ skip_whitespace and return
66
+ byte = peek
67
+ return if byte.nil?
68
+
69
+ char = byte.chr
70
+
71
+ case char
72
+ when "%" then start_dsc_or_comment
73
+ when "(" then start_string
74
+ when "<"
75
+ if peek(1) == 60 # <<
76
+ advance
77
+ advance
78
+ emit(:dict_open, "<<")
79
+ else
80
+ start_hexstring
81
+ end
82
+ when ">" then maybe_close_dict
83
+ when "{" then advance and emit(:proc_open, "{")
84
+ when "}" then advance and emit(:proc_close, "}")
85
+ when "[" then advance and emit(:array_open, "[")
86
+ when "]" then advance and emit(:array_close, "]")
87
+ when "/", "\\"
88
+ start_name_with_slash(char)
89
+ else
90
+ scan_word_or_number
91
+ end
92
+ end
93
+
94
+ def skip_whitespace
95
+ consumed = false
96
+ until eos?
97
+ byte = peek
98
+ break unless whitespace_byte?(byte)
99
+
100
+ advance
101
+ consumed = true
102
+ end
103
+ consumed
104
+ end
105
+
106
+ def whitespace_byte?(byte)
107
+ byte && [32, 9, 10, 13, 12, 11, 0].include?(byte)
108
+ end
109
+
110
+ def start_dsc_or_comment
111
+ advance # consume first %
112
+ if peek == 37 # second %
113
+ advance # consume second %
114
+ @state = :dsc
115
+ else
116
+ @state = :comment
117
+ end
118
+ end
119
+
120
+ def scan_dsc_body
121
+ start_pos = @position
122
+ start_line = @line
123
+ start_col = @column
124
+ until eos? || @source.getbyte(@position) == 10
125
+ advance
126
+ end
127
+ text = @source.byteslice(start_pos, @position - start_pos)
128
+ @tokens << Model::Token.new(:dsc, text.chomp, line: start_line, column: start_col)
129
+ @state = :top
130
+ end
131
+
132
+ def scan_comment_body
133
+ until eos? || @source.getbyte(@position) == 10
134
+ advance
135
+ end
136
+ @state = :top
137
+ end
138
+
139
+ def start_string
140
+ start_line = @line
141
+ start_col = @column
142
+ advance # consume (
143
+ @string_start = { line: start_line, column: start_col, bytes: +"" }
144
+ @string_depth = 1
145
+ @state = :string
146
+ end
147
+
148
+ def scan_string_body
149
+ until eos?
150
+ byte = @source.getbyte(@position)
151
+ case byte
152
+ when 92 # backslash
153
+ advance
154
+ handle_escape
155
+ when 40 # (
156
+ advance
157
+ @string_depth += 1
158
+ @string_start[:bytes] << "("
159
+ when 41 # )
160
+ advance
161
+ @string_depth -= 1
162
+ if @string_depth.zero?
163
+ emit_string
164
+ return
165
+ end
166
+ @string_start[:bytes] << ")"
167
+ else
168
+ advance
169
+ @string_start[:bytes] << byte.chr
170
+ end
171
+ end
172
+ raise LexError.new("unterminated string literal",
173
+ source_position: [@string_start[:line], @string_start[:column]])
174
+ end
175
+
176
+ def handle_escape
177
+ byte = peek
178
+ return if byte.nil?
179
+
180
+ char = byte.chr
181
+ replacement =
182
+ case char
183
+ when "n" then "\n"
184
+ when "r" then "\r"
185
+ when "t" then "\t"
186
+ when "b" then "\b"
187
+ when "f" then "\f"
188
+ when "\\" then "\\"
189
+ when "(" then "("
190
+ when ")" then ")"
191
+ when "0".."7"
192
+ consume_octal_escape
193
+ else
194
+ char
195
+ end
196
+ if replacement.is_a?(String)
197
+ advance
198
+ @string_start[:bytes] << replacement
199
+ end
200
+ end
201
+
202
+ def consume_octal_escape
203
+ digits = +""
204
+ 3.times do
205
+ byte = peek
206
+ break unless byte && (byte >= 48 && byte <= 55) # 0..7
207
+
208
+ digits << byte.chr
209
+ advance
210
+ end
211
+ code = digits.to_i(8) & 0xFF
212
+ @string_start[:bytes] << code.chr
213
+ nil
214
+ end
215
+
216
+ def emit_string
217
+ @tokens << Model::Token.new(:string, @string_start[:bytes],
218
+ line: @string_start[:line], column: @string_start[:column])
219
+ @string_start = nil
220
+ @state = :top
221
+ end
222
+
223
+ def start_hexstring
224
+ start_line = @line
225
+ start_col = @column
226
+ advance # consume <
227
+ @hex_start = { line: start_line, column: start_col, bytes: +"" }
228
+ @state = :hexstring
229
+ end
230
+
231
+ def scan_hexstring_body
232
+ until eos?
233
+ byte = @source.getbyte(@position)
234
+ case byte
235
+ when 62 # >
236
+ advance
237
+ emit_hexstring
238
+ return
239
+ when 32, 9, 10, 13, 12
240
+ advance
241
+ else
242
+ advance
243
+ @hex_start[:bytes] << byte.chr
244
+ end
245
+ end
246
+ raise LexError.new("unterminated hex string literal",
247
+ source_position: [@hex_start[:line], @hex_start[:column]])
248
+ end
249
+
250
+ def emit_hexstring
251
+ hex = @hex_start[:bytes].delete("^0-9a-fA-F")
252
+ @tokens << Model::Token.new(:hexstring, hex,
253
+ line: @hex_start[:line], column: @hex_start[:column])
254
+ @hex_start = nil
255
+ @state = :top
256
+ end
257
+
258
+ def maybe_close_dict
259
+ if peek(1) == 62 # >>
260
+ advance
261
+ advance
262
+ emit(:dict_close, ">>")
263
+ else
264
+ # Lone '>' is not a valid token in PS; skip and warn via token.
265
+ advance
266
+ end
267
+ end
268
+
269
+ def start_name_with_slash(prefix)
270
+ start_line = @line
271
+ start_col = @column
272
+ advance
273
+ bytes = +""
274
+ until eos?
275
+ byte = peek
276
+ break if byte.nil? || whitespace_byte?(byte) || delimiter_byte?(byte)
277
+
278
+ bytes << byte.chr
279
+ advance
280
+ end
281
+ @tokens << Model::Token.new(:name, bytes, line: start_line, column: start_col, literal: true)
282
+ end
283
+
284
+ def delimiter_byte?(byte)
285
+ return false unless byte
286
+
287
+ char = byte.chr
288
+ "%()<>{}/[]".include?(char)
289
+ end
290
+
291
+ def scan_word_or_number
292
+ start_line = @line
293
+ start_col = @column
294
+ bytes = +""
295
+ until eos?
296
+ byte = peek
297
+ break if byte.nil? || whitespace_byte?(byte) || delimiter_byte?(byte)
298
+
299
+ bytes << byte.chr
300
+ advance
301
+ end
302
+ emit_word(bytes, start_line, start_col)
303
+ end
304
+
305
+ def emit_word(bytes, line, col)
306
+ type =
307
+ if NUMBER_RE.match?(bytes)
308
+ :number
309
+ else
310
+ :operator
311
+ end
312
+ @tokens << Model::Token.new(type, bytes, line: line, column: col)
313
+ end
314
+
315
+ NUMBER_RE = /\A[-+]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?\z/.freeze
316
+
317
+ def emit(type, value)
318
+ @tokens << Model::Token.new(type, value, line: @line, column: @column)
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ module Source
5
+ # Parse-time operand stack. Wraps an Array with type-aware pops.
6
+ class OperandStack
7
+ def initialize
8
+ @items = []
9
+ end
10
+
11
+ def push(value)
12
+ @items.push(value)
13
+ self
14
+ end
15
+ alias << push
16
+
17
+ def pop
18
+ return Model::Computed.new(operator_keyword: "(missing)") if @items.empty?
19
+
20
+ @items.pop
21
+ end
22
+
23
+ def pop_number(default_on_empty: nil)
24
+ return default_on_empty if @items.empty? && !default_on_empty.nil?
25
+
26
+ value = pop
27
+ return value.to_f if value.is_a?(Model::Literals::Number)
28
+ return value.to_f if value.is_a?(Numeric)
29
+
30
+ # Parse-time fallback: a Name (e.g. /x defined via def) or a
31
+ # Computed sentinel (e.g. result of an arithmetic op) cannot
32
+ # be evaluated to a number yet. Return 0 so the operator
33
+ # instance can be built; the visitor will re-resolve at
34
+ # runtime if the program is executed.
35
+ 0.0
36
+ end
37
+
38
+ def peek(offset = 0)
39
+ @items[-1 - offset]
40
+ end
41
+
42
+ def length
43
+ @items.length
44
+ end
45
+
46
+ def empty?
47
+ @items.empty?
48
+ end
49
+
50
+ def to_a
51
+ @items.dup
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ # Source-reading layer: PS source text -> Model::Program.
5
+ #
6
+ # Lexer produces an array of Model::Token; AstBuilder walks tokens and
7
+ # emits a typed Model::Program. Errors are raised as Postscript::LexError
8
+ # and Postscript::SyntaxError respectively.
9
+ module Source
10
+ autoload :Lexer, "postscript/source/lexer"
11
+ autoload :AstBuilder, "postscript/source/ast_builder"
12
+ autoload :OperandStack, "postscript/source/operand_stack"
13
+
14
+ module_function
15
+
16
+ # One-shot convenience: PS source -> Model::Program.
17
+ def parse(source)
18
+ AstBuilder.build(Lexer.tokenize(source))
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ VERSION = "0.1.0"
5
+ end
data/lib/postscript.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ autoload :VERSION, "postscript/version"
5
+
6
+ # Errors
7
+ autoload :Error, "postscript/errors"
8
+ autoload :ParseError, "postscript/errors"
9
+ autoload :LexError, "postscript/errors"
10
+ autoload :SyntaxError, "postscript/errors"
11
+ autoload :RenderError, "postscript/errors"
12
+ autoload :StackUnderflowError, "postscript/errors"
13
+ autoload :UndefinedOperatorError, "postscript/errors"
14
+ autoload :RecursionLimitError, "postscript/errors"
15
+ autoload :SizeLimitError, "postscript/errors"
16
+ autoload :SerializeError, "postscript/errors"
17
+ autoload :ExitSignal, "postscript/errors"
18
+ autoload :QuitSignal, "postscript/errors"
19
+
20
+ # General-purpose value types
21
+ autoload :FormatNumber, "postscript/format_number"
22
+ autoload :Matrix, "postscript/matrix"
23
+ autoload :Color, "postscript/color"
24
+
25
+ # PS source-reading layer
26
+ autoload :Source, "postscript/source"
27
+
28
+ # Typed PS domain model
29
+ autoload :Model, "postscript/model"
30
+
31
+ # PS source serializer
32
+ autoload :Serializer, "postscript/serializer"
33
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/postscript/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "postscript"
7
+ spec.version = Postscript::VERSION
8
+ spec.authors = ["Ribose Inc."]
9
+ spec.email = ["open.source@ribose.com"]
10
+
11
+ spec.summary = "Pure Ruby PostScript (PS) / EPS parser, domain model, and serializer"
12
+ spec.description = <<~HEREDOC
13
+ Postscript is a pure-Ruby PostScript (PS) / EPS parser, typed
14
+ domain model, and serializer. It is the lower layer of the
15
+ postsvg converter and is independently reusable for any tool
16
+ that needs to read, write, or transform PostScript source.
17
+ HEREDOC
18
+
19
+ spec.homepage = "https://github.com/claricle/postscript"
20
+ spec.license = "BSD-2-Clause"
21
+ spec.required_ruby_version = ">= 3.0.0"
22
+
23
+ spec.metadata["homepage_uri"] = spec.homepage
24
+ spec.metadata["source_code_uri"] = "https://github.com/claricle/postscript"
25
+ spec.metadata["changelog_uri"] = "https://github.com/claricle/postscript/blob/main/CHANGELOG.md"
26
+ spec.metadata["bug_tracker_uri"] = "https://github.com/claricle/postscript/issues"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
28
+
29
+ spec.files = Dir.chdir(__dir__) do
30
+ `git ls-files -z`.split("\x0").reject do |f|
31
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
32
+ end
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: postscript
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ribose Inc.
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Postscript is a pure-Ruby PostScript (PS) / EPS parser, typed
14
+ domain model, and serializer. It is the lower layer of the
15
+ postsvg converter and is independently reusable for any tool
16
+ that needs to read, write, or transform PostScript source.
17
+ email:
18
+ - open.source@ribose.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - ".rspec"
24
+ - Gemfile
25
+ - README.adoc
26
+ - Rakefile
27
+ - lib/postscript.rb
28
+ - lib/postscript/color.rb
29
+ - lib/postscript/errors.rb
30
+ - lib/postscript/format_number.rb
31
+ - lib/postscript/matrix.rb
32
+ - lib/postscript/model.rb
33
+ - lib/postscript/model/literals.rb
34
+ - lib/postscript/model/literals/array.rb
35
+ - lib/postscript/model/literals/dictionary.rb
36
+ - lib/postscript/model/literals/hex.rb
37
+ - lib/postscript/model/literals/name.rb
38
+ - lib/postscript/model/literals/number.rb
39
+ - lib/postscript/model/literals/procedure.rb
40
+ - lib/postscript/model/literals/string.rb
41
+ - lib/postscript/model/operator.rb
42
+ - lib/postscript/model/operators.rb
43
+ - lib/postscript/model/operators/arithmetic.rb
44
+ - lib/postscript/model/operators/boolean.rb
45
+ - lib/postscript/model/operators/color.rb
46
+ - lib/postscript/model/operators/container.rb
47
+ - lib/postscript/model/operators/control_flow.rb
48
+ - lib/postscript/model/operators/device.rb
49
+ - lib/postscript/model/operators/dictionary.rb
50
+ - lib/postscript/model/operators/font.rb
51
+ - lib/postscript/model/operators/graphics_state.rb
52
+ - lib/postscript/model/operators/painting.rb
53
+ - lib/postscript/model/operators/path.rb
54
+ - lib/postscript/model/operators/stack.rb
55
+ - lib/postscript/model/operators/transformations.rb
56
+ - lib/postscript/model/program.rb
57
+ - lib/postscript/model/token.rb
58
+ - lib/postscript/serializer.rb
59
+ - lib/postscript/source.rb
60
+ - lib/postscript/source/ast_builder.rb
61
+ - lib/postscript/source/lexer.rb
62
+ - lib/postscript/source/operand_stack.rb
63
+ - lib/postscript/version.rb
64
+ - postscript.gemspec
65
+ homepage: https://github.com/claricle/postscript
66
+ licenses:
67
+ - BSD-2-Clause
68
+ metadata:
69
+ homepage_uri: https://github.com/claricle/postscript
70
+ source_code_uri: https://github.com/claricle/postscript
71
+ changelog_uri: https://github.com/claricle/postscript/blob/main/CHANGELOG.md
72
+ bug_tracker_uri: https://github.com/claricle/postscript/issues
73
+ rubygems_mfa_required: 'true'
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.0.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.16
89
+ specification_version: 4
90
+ summary: Pure Ruby PostScript (PS) / EPS parser, domain model, and serializer
91
+ test_files: []