postsvg 0.1.0 → 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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/CLAUDE.md +173 -0
  4. data/Gemfile +2 -3
  5. data/README.adoc +456 -179
  6. data/Rakefile +100 -0
  7. data/TODO.roadmap/00-architecture.md +139 -0
  8. data/TODO.roadmap/01-autoload-migration.md +39 -0
  9. data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
  10. data/TODO.roadmap/03-domain-model.md +66 -0
  11. data/TODO.roadmap/04-lexer.md +40 -0
  12. data/TODO.roadmap/05-parser.md +43 -0
  13. data/TODO.roadmap/06-graphics-state.md +45 -0
  14. data/TODO.roadmap/07-matrix-and-color.md +37 -0
  15. data/TODO.roadmap/08-svg-builder.md +51 -0
  16. data/TODO.roadmap/09-renderer.md +52 -0
  17. data/TODO.roadmap/10-visitor.md +58 -0
  18. data/TODO.roadmap/11-operator-coverage.md +103 -0
  19. data/TODO.roadmap/12-svg-domain.md +62 -0
  20. data/TODO.roadmap/13-translation-handlers.md +69 -0
  21. data/TODO.roadmap/14-ps-serializer.md +49 -0
  22. data/TODO.roadmap/15-cli-and-public-api.md +47 -0
  23. data/TODO.roadmap/16-specs.md +84 -0
  24. data/TODO.roadmap/17-docs-sync.md +47 -0
  25. data/TODO.roadmap/18-performance-and-determinism.md +47 -0
  26. data/TODO.roadmap/19-error-model.md +45 -0
  27. data/TODO.roadmap/20-font-and-text.md +46 -0
  28. data/TODO.roadmap/21-images.md +45 -0
  29. data/TODO.roadmap/22-forms-and-resources.md +25 -0
  30. data/TODO.roadmap/23-level2-level3.md +52 -0
  31. data/TODO.roadmap/24-ci-and-release.md +42 -0
  32. data/TODO.roadmap/README.md +77 -0
  33. data/docs/.gitignore +29 -0
  34. data/docs/CHANGELOG.md +114 -0
  35. data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
  36. data/docs/DEPLOYMENT.md +456 -0
  37. data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
  38. data/docs/DOCUMENTATION_PLAN.md +425 -0
  39. data/docs/FINAL_SUMMARY.md +657 -0
  40. data/docs/Gemfile +15 -0
  41. data/docs/README.md +327 -0
  42. data/docs/_config.yml +99 -0
  43. data/docs/advanced-topics.adoc +370 -0
  44. data/docs/api-reference/colors.adoc +705 -0
  45. data/docs/api-reference/converter.adoc +699 -0
  46. data/docs/api-reference/execution-context.adoc +1210 -0
  47. data/docs/api-reference/graphics-state.adoc +1070 -0
  48. data/docs/api-reference/interpreter.adoc +810 -0
  49. data/docs/api-reference/matrix.adoc +1179 -0
  50. data/docs/api-reference/path-builder.adoc +1284 -0
  51. data/docs/api-reference/postsvg-module.adoc +388 -0
  52. data/docs/api-reference/svg-generator.adoc +891 -0
  53. data/docs/api-reference/tokenizer.adoc +925 -0
  54. data/docs/api-reference.adoc +221 -0
  55. data/docs/architecture/command-registry.adoc +1191 -0
  56. data/docs/architecture/conversion-pipeline.adoc +746 -0
  57. data/docs/architecture/design-decisions.adoc +999 -0
  58. data/docs/architecture/generator-stage.adoc +1115 -0
  59. data/docs/architecture/graphics-state-model.adoc +1089 -0
  60. data/docs/architecture/interpreter-stage.adoc +1125 -0
  61. data/docs/architecture/parser-stage.adoc +1051 -0
  62. data/docs/architecture.adoc +354 -0
  63. data/docs/cli-reference/batch-command.adoc +616 -0
  64. data/docs/cli-reference/check-command.adoc +677 -0
  65. data/docs/cli-reference/cli-options.adoc +802 -0
  66. data/docs/cli-reference/convert-command.adoc +462 -0
  67. data/docs/cli-reference/version-command.adoc +296 -0
  68. data/docs/cli-reference.adoc +317 -0
  69. data/docs/concepts/conversion-pipeline.adoc +903 -0
  70. data/docs/concepts/coordinate-systems.adoc +836 -0
  71. data/docs/concepts/graphics-state.adoc +861 -0
  72. data/docs/concepts/path-operations.adoc +1076 -0
  73. data/docs/concepts/postscript-language.adoc +859 -0
  74. data/docs/concepts/svg-generation.adoc +937 -0
  75. data/docs/concepts.adoc +198 -0
  76. data/docs/contributing.adoc +443 -0
  77. data/docs/development.adoc +420 -0
  78. data/docs/faq.adoc +493 -0
  79. data/docs/getting-started/basic-usage.adoc +538 -0
  80. data/docs/getting-started/common-workflows.adoc +577 -0
  81. data/docs/getting-started/first-conversion.adoc +492 -0
  82. data/docs/getting-started/installation.adoc +534 -0
  83. data/docs/getting-started.adoc +94 -0
  84. data/docs/index.adoc +248 -0
  85. data/docs/optimization.adoc +196 -0
  86. data/docs/ps2svg_compatibility.adoc +149 -0
  87. data/docs/quick-reference.adoc +453 -0
  88. data/docs/sitemap.adoc +337 -0
  89. data/docs/troubleshooting.adoc +486 -0
  90. data/docs/validation.adoc +772 -0
  91. data/exe/postsvg +1 -0
  92. data/lib/postsvg/cli.rb +104 -57
  93. data/lib/postsvg/color.rb +132 -0
  94. data/lib/postsvg/errors.rb +68 -3
  95. data/lib/postsvg/format_number.rb +22 -0
  96. data/lib/postsvg/graphics_context.rb +80 -0
  97. data/lib/postsvg/graphics_stack.rb +43 -0
  98. data/lib/postsvg/model/literals/array.rb +41 -0
  99. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  100. data/lib/postsvg/model/literals/hex.rb +37 -0
  101. data/lib/postsvg/model/literals/name.rb +40 -0
  102. data/lib/postsvg/model/literals/number.rb +36 -0
  103. data/lib/postsvg/model/literals/procedure.rb +41 -0
  104. data/lib/postsvg/model/literals/string.rb +30 -0
  105. data/lib/postsvg/model/literals.rb +19 -0
  106. data/lib/postsvg/model/operator.rb +58 -0
  107. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  108. data/lib/postsvg/model/operators/boolean.rb +182 -0
  109. data/lib/postsvg/model/operators/color.rb +74 -0
  110. data/lib/postsvg/model/operators/container.rb +186 -0
  111. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  112. data/lib/postsvg/model/operators/device.rb +21 -0
  113. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  114. data/lib/postsvg/model/operators/font.rb +121 -0
  115. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  116. data/lib/postsvg/model/operators/painting.rb +29 -0
  117. data/lib/postsvg/model/operators/path.rb +169 -0
  118. data/lib/postsvg/model/operators/stack.rb +72 -0
  119. data/lib/postsvg/model/operators/transformations.rb +103 -0
  120. data/lib/postsvg/model/operators.rb +89 -0
  121. data/lib/postsvg/model/program.rb +68 -0
  122. data/lib/postsvg/model/token.rb +43 -0
  123. data/lib/postsvg/model.rb +17 -0
  124. data/lib/postsvg/options.rb +29 -0
  125. data/lib/postsvg/renderer.rb +85 -0
  126. data/lib/postsvg/serializer.rb +325 -0
  127. data/lib/postsvg/source/ast_builder.rb +308 -0
  128. data/lib/postsvg/source/lexer.rb +322 -0
  129. data/lib/postsvg/source/operand_stack.rb +55 -0
  130. data/lib/postsvg/source.rb +21 -0
  131. data/lib/postsvg/svg/attribute_parser.rb +45 -0
  132. data/lib/postsvg/svg/clip_path_registry.rb +44 -0
  133. data/lib/postsvg/svg/document.rb +22 -0
  134. data/lib/postsvg/svg/element.rb +84 -0
  135. data/lib/postsvg/svg/elements/circle.rb +36 -0
  136. data/lib/postsvg/svg/elements/clip_path.rb +26 -0
  137. data/lib/postsvg/svg/elements/defs.rb +24 -0
  138. data/lib/postsvg/svg/elements/ellipse.rb +38 -0
  139. data/lib/postsvg/svg/elements/group.rb +37 -0
  140. data/lib/postsvg/svg/elements/image.rb +35 -0
  141. data/lib/postsvg/svg/elements/line.rb +36 -0
  142. data/lib/postsvg/svg/elements/path.rb +32 -0
  143. data/lib/postsvg/svg/elements/polygon.rb +12 -0
  144. data/lib/postsvg/svg/elements/polyline.rb +32 -0
  145. data/lib/postsvg/svg/elements/rect.rb +44 -0
  146. data/lib/postsvg/svg/elements/svg.rb +39 -0
  147. data/lib/postsvg/svg/elements/text.rb +42 -0
  148. data/lib/postsvg/svg/elements.rb +31 -0
  149. data/lib/postsvg/svg/paint.rb +34 -0
  150. data/lib/postsvg/svg/parser.rb +39 -0
  151. data/lib/postsvg/svg/path_data/command.rb +27 -0
  152. data/lib/postsvg/svg/path_data/parser.rb +82 -0
  153. data/lib/postsvg/svg/path_data.rb +17 -0
  154. data/lib/postsvg/svg/stroke.rb +31 -0
  155. data/lib/postsvg/svg/transform_list.rb +59 -0
  156. data/lib/postsvg/svg.rb +22 -0
  157. data/lib/postsvg/svg_builder.rb +249 -0
  158. data/lib/postsvg/translation/arc_converter.rb +86 -0
  159. data/lib/postsvg/translation/bounding_box.rb +59 -0
  160. data/lib/postsvg/translation/context.rb +34 -0
  161. data/lib/postsvg/translation/handler_registry.rb +38 -0
  162. data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
  163. data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
  164. data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
  165. data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
  166. data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
  167. data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
  168. data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
  169. data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
  170. data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
  171. data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
  172. data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
  173. data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
  174. data/lib/postsvg/translation/handlers/shared.rb +110 -0
  175. data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
  176. data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
  177. data/lib/postsvg/translation/handlers.rb +25 -0
  178. data/lib/postsvg/translation/ps_renderer.rb +105 -0
  179. data/lib/postsvg/translation/record_emitter.rb +35 -0
  180. data/lib/postsvg/translation.rb +17 -0
  181. data/lib/postsvg/version.rb +1 -1
  182. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
  183. data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
  184. data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
  185. data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
  186. data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
  187. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
  188. data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
  189. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
  190. data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
  191. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
  192. data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
  193. data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
  194. data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
  195. data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
  196. data/lib/postsvg/visitors/ps_visitor.rb +208 -0
  197. data/lib/postsvg/visitors.rb +9 -0
  198. data/lib/postsvg.rb +93 -59
  199. data/lychee.toml +86 -0
  200. metadata +216 -11
  201. data/postsvg.gemspec +0 -38
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
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
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
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 Postsvg
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 Postsvg
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 Postsvg::LexError
8
+ # and Postsvg::SyntaxError respectively.
9
+ module Source
10
+ autoload :Lexer, "postsvg/source/lexer"
11
+ autoload :AstBuilder, "postsvg/source/ast_builder"
12
+ autoload :OperandStack, "postsvg/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