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,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ # Model::Program -> PostScript source text. Walks the program's
5
+ # header (DSC comments) and body (Model records), emitting each as
6
+ # PS source. Used by the SVG -> PS direction and by tests.
7
+ #
8
+ # OCP: adding serialization for a new operator class means adding
9
+ # one +emit_<keyword>+ method (or falling through to the default
10
+ # keyword+operands emitter). No switch edits.
11
+ class Serializer
12
+ DEFAULT_LANGUAGE_LEVEL = 3
13
+ DEFAULT_CREATOR = "Postscript #{::Postscript::VERSION}".freeze
14
+
15
+ def self.call(program, eps: false, creator: DEFAULT_CREATOR, **_options)
16
+ new(program, eps: eps, creator: creator).call
17
+ end
18
+
19
+ attr_reader :program, :eps, :creator
20
+
21
+ def initialize(program, eps:, creator:)
22
+ raise ArgumentError, "program must be a Model::Program" unless program.is_a?(Model::Program)
23
+
24
+ @program = program
25
+ @eps = eps
26
+ @creator = creator
27
+ end
28
+
29
+ def call
30
+ buffer = String.new(capacity: 4096)
31
+ emit_header(buffer)
32
+ emit_body(buffer)
33
+ buffer << "showpage\n"
34
+ buffer << "%%EOF\n"
35
+ buffer
36
+ end
37
+
38
+ # All methods below this point are public. They are intentionally
39
+ # exposed (prefixed +emit_+) so dispatch can reach them via
40
+ # +method_defined?+ without +respond_to?+.
41
+ def emit_header(buffer)
42
+ buffer << "%!PS-Adobe-#{DEFAULT_LANGUAGE_LEVEL}.0"
43
+ buffer << " EPSF-3.0" if eps
44
+ buffer << "\n"
45
+ buffer << "%%Creator: #{creator}\n"
46
+ bbox = program.header.bounding_box
47
+ if bbox && !bbox.empty?
48
+ buffer << "%%BoundingBox: " << bbox.map { |v| FormatNumber.call(v) }.join(" ") << "\n"
49
+ end
50
+ if program.header.title
51
+ buffer << "%%Title: #{program.header.title}\n"
52
+ end
53
+ buffer << "%%LanguageLevel: #{DEFAULT_LANGUAGE_LEVEL}\n"
54
+ buffer << "%%EndComments\n"
55
+ end
56
+
57
+ def emit_body(buffer)
58
+ program.body.each do |statement|
59
+ emit_statement(statement, buffer)
60
+ end
61
+ end
62
+
63
+ def emit_statement(statement, buffer)
64
+ case statement
65
+ when Model::Literals::Number
66
+ buffer << FormatNumber.call(statement.value) << "\n"
67
+ when Model::Literals::Name
68
+ prefix = statement.literal? ? "/" : ""
69
+ buffer << prefix << statement.value << "\n"
70
+ when Model::Literals::StringLiteral
71
+ buffer << escape_string(statement.value) << "\n"
72
+ when Model::Literals::HexLiteral
73
+ buffer << "<" << statement.value << ">\n"
74
+ when Model::Literals::ArrayLiteral
75
+ buffer << "[ "
76
+ statement.elements.each { |e| emit_inline(e, buffer); buffer << " " }
77
+ buffer << "]\n"
78
+ when Model::Literals::Procedure
79
+ buffer << "{\n"
80
+ statement.body.each { |s| emit_statement(s, buffer) }
81
+ buffer << "}\n"
82
+ when Model::Literals::Dictionary
83
+ buffer << "<<\n"
84
+ statement.entries.each do |k, v|
85
+ buffer << "/#{k} "
86
+ emit_inline(v, buffer)
87
+ buffer << "\n"
88
+ end
89
+ buffer << ">>\n"
90
+ when Model::Operator
91
+ emit_operator(statement, buffer)
92
+ when Model::UnknownOperator
93
+ buffer << "% unhandled operator: #{statement.keyword}\n"
94
+ when Model::InvokeProcedure
95
+ buffer << statement.name << "\n"
96
+ end
97
+ end
98
+
99
+ def emit_inline(statement, buffer)
100
+ case statement
101
+ when Model::Literals::Number then buffer << FormatNumber.call(statement.value)
102
+ when Model::Literals::Name then buffer << (statement.literal? ? "/" : "") << statement.value
103
+ when Model::Literals::StringLiteral then buffer << escape_string(statement.value)
104
+ when Model::Literals::HexLiteral then buffer << "<" << statement.value << ">"
105
+ else buffer << statement.class.name.to_s
106
+ end
107
+ end
108
+
109
+ # Dispatch table: emit_<keyword>. Falls back to the generic
110
+ # operand+keyword form when no specific method exists.
111
+ def emit_operator(operator, buffer)
112
+ method = :"emit_#{operator.visit_name}"
113
+ if self.class.public_method_defined?(method)
114
+ public_send(method, operator, buffer)
115
+ else
116
+ emit_generic_operator(operator, buffer)
117
+ end
118
+ end
119
+
120
+ def emit_generic_operator(operator, buffer)
121
+ buffer << operator.keyword << "\n"
122
+ end
123
+
124
+ # Path
125
+ def emit_moveto(op, buf)
126
+ buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} moveto\n"
127
+ end
128
+
129
+ def emit_rmoveto(op, buf)
130
+ buf << "#{FormatNumber.call(op.dx)} #{FormatNumber.call(op.dy)} rmoveto\n"
131
+ end
132
+
133
+ def emit_lineto(op, buf)
134
+ buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} lineto\n"
135
+ end
136
+
137
+ def emit_rlineto(op, buf)
138
+ buf << "#{FormatNumber.call(op.dx)} #{FormatNumber.call(op.dy)} rlineto\n"
139
+ end
140
+
141
+ def emit_curveto(op, buf)
142
+ buf << "#{FormatNumber.call(op.x1)} #{FormatNumber.call(op.y1)} " \
143
+ "#{FormatNumber.call(op.x2)} #{FormatNumber.call(op.y2)} " \
144
+ "#{FormatNumber.call(op.x3)} #{FormatNumber.call(op.y3)} curveto\n"
145
+ end
146
+
147
+ def emit_rcurveto(op, buf)
148
+ buf << "#{FormatNumber.call(op.dx1)} #{FormatNumber.call(op.dy1)} " \
149
+ "#{FormatNumber.call(op.dx2)} #{FormatNumber.call(op.dy2)} " \
150
+ "#{FormatNumber.call(op.dx3)} #{FormatNumber.call(op.dy3)} rcurveto\n"
151
+ end
152
+
153
+ def emit_arc(op, buf)
154
+ buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} " \
155
+ "#{FormatNumber.call(op.radius)} " \
156
+ "#{FormatNumber.call(op.angle1)} #{FormatNumber.call(op.angle2)} arc\n"
157
+ end
158
+
159
+ def emit_arcn(op, buf)
160
+ buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} " \
161
+ "#{FormatNumber.call(op.radius)} " \
162
+ "#{FormatNumber.call(op.angle1)} #{FormatNumber.call(op.angle2)} arcn\n"
163
+ end
164
+
165
+ def emit_closepath(_op, buf)
166
+ buf << "closepath\n"
167
+ end
168
+
169
+ def emit_newpath(_op, buf)
170
+ buf << "newpath\n"
171
+ end
172
+
173
+ # Painting
174
+ def emit_stroke(_op, buf) = buf << "stroke\n"
175
+ def emit_fill(_op, buf) = buf << "fill\n"
176
+ def emit_eofill(_op, buf) = buf << "eofill\n"
177
+ def emit_clip(_op, buf) = buf << "clip\n"
178
+ def emit_eoclip(_op, buf) = buf << "eoclip\n"
179
+
180
+ # Color
181
+ def emit_setrgbcolor(op, buf)
182
+ buf << "#{FormatNumber.call(op.red)} #{FormatNumber.call(op.green)} #{FormatNumber.call(op.blue)} setrgbcolor\n"
183
+ end
184
+
185
+ def emit_setgray(op, buf)
186
+ buf << "#{FormatNumber.call(op.gray)} setgray\n"
187
+ end
188
+
189
+ def emit_setcmykcolor(op, buf)
190
+ buf << "#{FormatNumber.call(op.cyan)} #{FormatNumber.call(op.magenta)} " \
191
+ "#{FormatNumber.call(op.yellow)} #{FormatNumber.call(op.key)} setcmykcolor\n"
192
+ end
193
+
194
+ def emit_sethsbcolor(op, buf)
195
+ buf << "#{FormatNumber.call(op.hue)} #{FormatNumber.call(op.saturation)} " \
196
+ "#{FormatNumber.call(op.brightness)} sethsbcolor\n"
197
+ end
198
+
199
+ # Graphics state
200
+ def emit_gsave(_op, buf) = buf << "gsave\n"
201
+ def emit_grestore(_op, buf) = buf << "grestore\n"
202
+ def emit_grestoreall(_op, buf) = buf << "grestoreall\n"
203
+
204
+ def emit_setlinewidth(op, buf)
205
+ buf << "#{FormatNumber.call(op.width)} setlinewidth\n"
206
+ end
207
+
208
+ def emit_setlinecap(op, buf)
209
+ buf << "#{op.cap_code} setlinecap\n"
210
+ end
211
+
212
+ def emit_setlinejoin(op, buf)
213
+ buf << "#{op.join_code} setlinejoin\n"
214
+ end
215
+
216
+ def emit_setmiterlimit(op, buf)
217
+ buf << "#{FormatNumber.call(op.limit)} setmiterlimit\n"
218
+ end
219
+
220
+ def emit_setdash(op, buf)
221
+ pattern_str =
222
+ case op.pattern
223
+ when Array then "[#{op.pattern.map { |v| FormatNumber.call(v.to_f) }.join(' ')}]"
224
+ when Numeric then FormatNumber.call(op.pattern.to_f)
225
+ else "[]"
226
+ end
227
+ buf << "#{pattern_str} #{FormatNumber.call(op.offset)} setdash\n"
228
+ end
229
+
230
+ # Transformations
231
+ def emit_translate(op, buf)
232
+ buf << "#{FormatNumber.call(op.tx)} #{FormatNumber.call(op.ty)} translate\n"
233
+ end
234
+
235
+ def emit_scale(op, buf)
236
+ buf << "#{FormatNumber.call(op.sx)} #{FormatNumber.call(op.sy)} scale\n"
237
+ end
238
+
239
+ def emit_rotate(op, buf)
240
+ buf << "#{FormatNumber.call(op.angle)} rotate\n"
241
+ end
242
+
243
+ def emit_concat(op, buf)
244
+ m = op.matrix
245
+ arr = m.is_a?(Array) ? m : [m.a, m.b, m.c, m.d, m.e, m.f]
246
+ buf << "[#{arr.map { |v| FormatNumber.call(v.to_f) }.join(' ')}] concat\n"
247
+ end
248
+
249
+ def escape_string(text)
250
+ escaped = text.to_s.gsub(/[\(\)\\]/) { |c| "\\#{c}" }
251
+ .gsub("\n", "\\n")
252
+ .gsub("\r", "\\r")
253
+ .gsub("\t", "\\t")
254
+ "(#{escaped})"
255
+ end
256
+
257
+ # Font / text
258
+ def emit_findfont(op, buf)
259
+ name = font_name_value(op.name)
260
+ buf << "/#{name} findfont\n"
261
+ end
262
+
263
+ def emit_scalefont(op, buf)
264
+ buf << "#{FormatNumber.call(op.size)} scalefont\n"
265
+ end
266
+
267
+ def emit_setfont(_op, buf)
268
+ buf << "setfont\n"
269
+ end
270
+
271
+ def emit_show(op, buf)
272
+ buf << "#{escape_string(string_value(op.text))} show\n"
273
+ end
274
+
275
+ def emit_xyshow(_op, buf)
276
+ buf << "% xyshow: per-glyph advances not serialized\n"
277
+ end
278
+
279
+ def emit_stringwidth(_op, buf)
280
+ buf << "% stringwidth: no runtime result captured\n"
281
+ end
282
+
283
+ def emit_charpath(_op, buf)
284
+ buf << "% charpath: requires font metrics\n"
285
+ end
286
+
287
+ # Container operators
288
+ def emit_length(_op, buf) = buf << "length\n"
289
+ def emit_get(_op, buf) = buf << "get\n"
290
+ def emit_put(_op, buf) = buf << "put\n"
291
+ def emit_getinterval(_op, buf) = buf << "getinterval\n"
292
+ def emit_putinterval(_op, buf) = buf << "putinterval\n"
293
+ def emit_forall(_op, buf) = buf << "forall\n"
294
+ def emit_astore(_op, buf) = buf << "astore\n"
295
+ def emit_search(_op, buf) = buf << "search\n"
296
+ def emit_anchorsearch(_op, buf) = buf << "anchorsearch\n"
297
+ def emit_token(_op, buf) = buf << "token\n"
298
+ def emit_string(_op, buf) = buf << "string\n"
299
+ def emit_cvs(_op, buf) = buf << "cvs\n"
300
+
301
+ # Dictionary operators (currentdict etc.)
302
+ def emit_currentdict(_op, buf) = buf << "currentdict\n"
303
+ def emit_countdictstack(_op, buf) = buf << "countdictstack\n"
304
+ def emit_dictstack(_op, buf) = buf << "dictstack\n"
305
+ def emit_maxlength(_op, buf) = buf << "maxlength\n"
306
+
307
+ def font_name_value(value)
308
+ case value
309
+ when Model::Literals::Name then value.value
310
+ when Model::Literals::StringLiteral then value.value
311
+ when String then value
312
+ else value.to_s
313
+ end
314
+ end
315
+
316
+ def string_value(value)
317
+ case value
318
+ when Model::Literals::StringLiteral then value.value
319
+ when Model::Literals::HexLiteral then value.bytes
320
+ when String then value
321
+ else value.to_s
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscript
4
+ module Source
5
+ # Walks a token stream from the Lexer and produces a typed
6
+ # Model::Program. Procedures, arrays, and dictionaries are kept as
7
+ # Model literal nodes; operators are constructed via the
8
+ # Model::Operators registry.
9
+ class AstBuilder
10
+ attr_reader :tokens, :stack, :body, :header, :dict_stack, :recursion_depth
11
+
12
+ MAX_PROC_DEPTH = 64
13
+
14
+ def initialize(tokens)
15
+ @tokens = tokens
16
+ @stack = OperandStack.new
17
+ @body = []
18
+ @dict_stack = [{}] # global dictionary
19
+ @header = Model::Program::Header.new
20
+ @recursion_depth = 0
21
+ end
22
+
23
+ def self.build(tokens)
24
+ new(tokens).build
25
+ end
26
+
27
+ def build
28
+ Model::Operators.load_all!
29
+ consume_until(tokens.length)
30
+ Model::Program.new(header: @header, body: @body)
31
+ end
32
+
33
+ private
34
+
35
+ def consume_until(end_index, terminators: [], depth: 0)
36
+ raise RecursionLimitError, "procedure depth exceeded #{MAX_PROC_DEPTH}" if depth > MAX_PROC_DEPTH
37
+
38
+ i = 0
39
+ collected = []
40
+ while i < end_index
41
+ token = tokens[i]
42
+ break if terminators.include?(token&.type)
43
+
44
+ i += consume_token(token, collected)
45
+ end
46
+ collected
47
+ end
48
+
49
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
50
+ def consume_token(token, collected)
51
+ case token.type
52
+ when :number
53
+ node = build_number(token)
54
+ @stack.push(node)
55
+ append_statement(node, collected)
56
+ 1
57
+ when :string
58
+ node = Model::Literals::StringLiteral.new(token.value)
59
+ @stack.push(node)
60
+ append_statement(node, collected)
61
+ 1
62
+ when :hexstring
63
+ node = Model::Literals::HexLiteral.new(token.value)
64
+ @stack.push(node)
65
+ append_statement(node, collected)
66
+ 1
67
+ when :name
68
+ node = Model::Literals::Name.new(token.value, literal: token.literal || false)
69
+ @stack.push(node)
70
+ append_statement(node, collected)
71
+ 1
72
+ when :proc_open
73
+ consume_procedure(after: token_position(token), collected: collected)
74
+ when :array_open
75
+ consume_array(after: token_position(token), collected: collected)
76
+ when :dict_open
77
+ consume_dict(after: token_position(token), collected: collected)
78
+ when :dsc
79
+ apply_dsc(token)
80
+ 1
81
+ when :operator
82
+ consume_operator(token, collected)
83
+ else
84
+ 1
85
+ end
86
+ end
87
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
88
+
89
+ # Push a literal onto both the parse stack and the program body.
90
+ # The body is what the visitor walks; the stack is what operator
91
+ # factories pop from.
92
+ def append_statement(node, collected)
93
+ @body << node
94
+ collected << node
95
+ end
96
+
97
+ def token_position(token)
98
+ [token.line, token.column]
99
+ end
100
+
101
+ def build_number(token)
102
+ raw = token.value
103
+ value =
104
+ if raw =~ /\A[-+]?\d+\z/
105
+ raw.to_i
106
+ elsif raw =~ /\A[-+]?\d+\.\d+\z/ || raw =~ /\A[-+]?(\.\d+|\d+\.)\z/
107
+ raw.to_f
108
+ elsif raw =~ /\A[-+]?(\d+\.?\d*|\.\d+)[eE][-+]?\d+\z/
109
+ raw.to_f
110
+ else
111
+ raw.to_f
112
+ end
113
+ Model::Literals::Number.new(value)
114
+ end
115
+
116
+ # Consume a procedure body. Returns the number of tokens consumed
117
+ # (including the closing brace). The Procedure value is pushed
118
+ # onto the stack AND recorded as a statement on the program body
119
+ # so the renderer can visit it standalone.
120
+ def consume_procedure(after:, collected:)
121
+ start_index = find_index(after)
122
+ body_tokens, end_index = match_braces(start_index + 1)
123
+ body_nodes = sub_build(body_tokens)
124
+ procedure = Model::Literals::Procedure.new(body_nodes)
125
+ @stack.push(procedure)
126
+ append_statement(procedure, collected)
127
+ end_index - start_index + 1
128
+ end
129
+
130
+ def consume_array(after:, collected:)
131
+ start_index = find_index(after)
132
+ body_tokens, end_index = match_until(start_index + 1, :array_close, :array_open)
133
+ body_nodes = sub_build(body_tokens)
134
+ array = Model::Literals::ArrayLiteral.new(body_nodes.select { |n| literal?(n) })
135
+ @stack.push(array)
136
+ append_statement(array, collected)
137
+ end_index - start_index + 1
138
+ end
139
+
140
+ def consume_dict(after:, collected:)
141
+ start_index = find_index(after)
142
+ body_tokens, end_index = match_until(start_index + 1, :dict_close, :dict_open)
143
+ entries = {}
144
+ pending_key = nil
145
+ sub_nodes = sub_build(body_tokens)
146
+ sub_nodes.each do |node|
147
+ if node.is_a?(Model::Literals::Name) && node.literal?
148
+ pending_key = node.value
149
+ elsif pending_key
150
+ entries[pending_key] = node
151
+ pending_key = nil
152
+ end
153
+ end
154
+ dict = Model::Literals::Dictionary.new(entries)
155
+ @stack.push(dict)
156
+ collected << dict
157
+ end_index - start_index + 1
158
+ end
159
+
160
+ def literal?(node)
161
+ node.is_a?(Model::Literals::Number) ||
162
+ node.is_a?(Model::Literals::Name) ||
163
+ node.is_a?(Model::Literals::StringLiteral) ||
164
+ node.is_a?(Model::Literals::HexLiteral) ||
165
+ node.is_a?(Model::Literals::ArrayLiteral) ||
166
+ node.is_a?(Model::Literals::Procedure) ||
167
+ node.is_a?(Model::Literals::Dictionary)
168
+ end
169
+
170
+ def sub_build(sub_tokens)
171
+ # Build a temporary AstBuilder over a sub-token-stream and
172
+ # harvest its body. The temp builder shares no state with the
173
+ # outer one.
174
+ return [] if sub_tokens.empty?
175
+
176
+ program = AstBuilder.new(sub_tokens).build
177
+ program.body
178
+ end
179
+
180
+ def find_index(line_col)
181
+ line, col = line_col
182
+ tokens.each_with_index do |tok, idx|
183
+ return idx if tok.line == line && tok.column == col
184
+ end
185
+ tokens.length
186
+ end
187
+
188
+ def match_braces(start_index)
189
+ depth = 1
190
+ i = start_index
191
+ while i < tokens.length
192
+ tok = tokens[i]
193
+ if tok.type == :proc_open
194
+ depth += 1
195
+ elsif tok.type == :proc_close
196
+ depth -= 1
197
+ return [tokens[start_index...i], i] if depth.zero?
198
+ end
199
+ i += 1
200
+ end
201
+ raise SyntaxError, "unbalanced procedure braces"
202
+ end
203
+
204
+ def match_until(start_index, close_type, open_type)
205
+ depth = 1
206
+ i = start_index
207
+ while i < tokens.length
208
+ tok = tokens[i]
209
+ if tok.type == open_type
210
+ depth += 1
211
+ elsif tok.type == close_type
212
+ depth -= 1
213
+ return [tokens[start_index...i], i] if depth.zero?
214
+ end
215
+ i += 1
216
+ end
217
+ raise SyntaxError, "unbalanced #{close_type}"
218
+ end
219
+
220
+ def consume_operator(token, collected)
221
+ keyword = token.value
222
+
223
+ # Resolve user-defined names to InvokeProcedure.
224
+ if (proc_value = lookup_user_definition(keyword))
225
+ if proc_value.is_a?(Model::Literals::Procedure)
226
+ inv = Model::InvokeProcedure.new(name: keyword, procedure: proc_value)
227
+ @body << inv
228
+ collected << inv
229
+ return 1
230
+ end
231
+ end
232
+
233
+ operator_class = Model::Operators[keyword]
234
+ unless operator_class
235
+ unknown = Model::UnknownOperator.new(keyword: keyword)
236
+ @body << unknown
237
+ collected << unknown
238
+ return 1
239
+ end
240
+
241
+ operator = operator_class.from_operands(@stack)
242
+ return 1 if operator.nil?
243
+
244
+ # Reflect the runtime stack shape: push a +Computed+ sentinel
245
+ # for each value the operator would have produced at runtime.
246
+ # This lets chained operators (findfont ... scalefont, add +
247
+ # mul, etc.) parse cleanly even though their intermediate
248
+ # results aren't yet known.
249
+ operator.class.produces.to_i.times do
250
+ @stack.push(Model::Computed.new(operator_keyword: keyword))
251
+ end
252
+
253
+ @body << operator
254
+ collected << operator
255
+ apply_side_effects(operator)
256
+ 1
257
+ end
258
+
259
+ # When a `def` operator is constructed, capture the binding into
260
+ # the active dictionary so subsequent operator tokens can resolve
261
+ # to InvokeProcedure.
262
+ def apply_side_effects(operator)
263
+ case operator
264
+ when Model::Operators::Dictionary::Def
265
+ @dict_stack.last[operator.key.to_s.sub(/\A\//, "")] = operator.value
266
+ end
267
+ end
268
+
269
+ def lookup_user_definition(name)
270
+ @dict_stack.reverse_each do |dict|
271
+ return dict[name] if dict.key?(name)
272
+ end
273
+ nil
274
+ end
275
+
276
+ def apply_dsc(token)
277
+ text = token.value.strip
278
+ case text
279
+ when /\ABoundingBox:\s*(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\z/
280
+ @header = @header.with(bounding_box: [
281
+ ::Regexp.last_match(1).to_f, ::Regexp.last_match(2).to_f,
282
+ ::Regexp.last_match(3).to_f, ::Regexp.last_match(4).to_f,
283
+ ])
284
+ when /\AHiResBoundingBox:\s*(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\z/
285
+ @header = @header.with(hires_bounding_box: [
286
+ ::Regexp.last_match(1).to_f, ::Regexp.last_match(2).to_f,
287
+ ::Regexp.last_match(3).to_f, ::Regexp.last_match(4).to_f,
288
+ ])
289
+ when /\ATitle:\s*(.+)\z/
290
+ @header = @header.with(title: ::Regexp.last_match(1))
291
+ when /\ACreator:\s*(.+)\z/
292
+ @header = @header.with(creator: ::Regexp.last_match(1))
293
+ when /\ACreationDate:\s*(.+)\z/
294
+ @header = @header.with(creation_date: ::Regexp.last_match(1))
295
+ when /\APages:\s*(\d+)\s+(\d+)\z/
296
+ @header = @header.with(page_count: ::Regexp.last_match(1).to_i,
297
+ pages: ::Regexp.last_match(2).to_i)
298
+ when /\ALanguageLevel:\s*(\d+)\z/
299
+ @header = @header.with(language_level: ::Regexp.last_match(1).to_i)
300
+ when /\AEOF/, /\AEndComments/
301
+ nil
302
+ else
303
+ @header = @header.with(custom: @header.custom.merge(text => true))
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end