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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -0
- data/CLAUDE.md +173 -0
- data/Gemfile +2 -3
- data/README.adoc +456 -179
- data/Rakefile +100 -0
- data/TODO.roadmap/00-architecture.md +139 -0
- data/TODO.roadmap/01-autoload-migration.md +39 -0
- data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
- data/TODO.roadmap/03-domain-model.md +66 -0
- data/TODO.roadmap/04-lexer.md +40 -0
- data/TODO.roadmap/05-parser.md +43 -0
- data/TODO.roadmap/06-graphics-state.md +45 -0
- data/TODO.roadmap/07-matrix-and-color.md +37 -0
- data/TODO.roadmap/08-svg-builder.md +51 -0
- data/TODO.roadmap/09-renderer.md +52 -0
- data/TODO.roadmap/10-visitor.md +58 -0
- data/TODO.roadmap/11-operator-coverage.md +103 -0
- data/TODO.roadmap/12-svg-domain.md +62 -0
- data/TODO.roadmap/13-translation-handlers.md +69 -0
- data/TODO.roadmap/14-ps-serializer.md +49 -0
- data/TODO.roadmap/15-cli-and-public-api.md +47 -0
- data/TODO.roadmap/16-specs.md +84 -0
- data/TODO.roadmap/17-docs-sync.md +47 -0
- data/TODO.roadmap/18-performance-and-determinism.md +47 -0
- data/TODO.roadmap/19-error-model.md +45 -0
- data/TODO.roadmap/20-font-and-text.md +46 -0
- data/TODO.roadmap/21-images.md +45 -0
- data/TODO.roadmap/22-forms-and-resources.md +25 -0
- data/TODO.roadmap/23-level2-level3.md +52 -0
- data/TODO.roadmap/24-ci-and-release.md +42 -0
- data/TODO.roadmap/README.md +77 -0
- data/docs/.gitignore +29 -0
- data/docs/CHANGELOG.md +114 -0
- data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
- data/docs/DEPLOYMENT.md +456 -0
- data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
- data/docs/DOCUMENTATION_PLAN.md +425 -0
- data/docs/FINAL_SUMMARY.md +657 -0
- data/docs/Gemfile +15 -0
- data/docs/README.md +327 -0
- data/docs/_config.yml +99 -0
- data/docs/advanced-topics.adoc +370 -0
- data/docs/api-reference/colors.adoc +705 -0
- data/docs/api-reference/converter.adoc +699 -0
- data/docs/api-reference/execution-context.adoc +1210 -0
- data/docs/api-reference/graphics-state.adoc +1070 -0
- data/docs/api-reference/interpreter.adoc +810 -0
- data/docs/api-reference/matrix.adoc +1179 -0
- data/docs/api-reference/path-builder.adoc +1284 -0
- data/docs/api-reference/postsvg-module.adoc +388 -0
- data/docs/api-reference/svg-generator.adoc +891 -0
- data/docs/api-reference/tokenizer.adoc +925 -0
- data/docs/api-reference.adoc +221 -0
- data/docs/architecture/command-registry.adoc +1191 -0
- data/docs/architecture/conversion-pipeline.adoc +746 -0
- data/docs/architecture/design-decisions.adoc +999 -0
- data/docs/architecture/generator-stage.adoc +1115 -0
- data/docs/architecture/graphics-state-model.adoc +1089 -0
- data/docs/architecture/interpreter-stage.adoc +1125 -0
- data/docs/architecture/parser-stage.adoc +1051 -0
- data/docs/architecture.adoc +354 -0
- data/docs/cli-reference/batch-command.adoc +616 -0
- data/docs/cli-reference/check-command.adoc +677 -0
- data/docs/cli-reference/cli-options.adoc +802 -0
- data/docs/cli-reference/convert-command.adoc +462 -0
- data/docs/cli-reference/version-command.adoc +296 -0
- data/docs/cli-reference.adoc +317 -0
- data/docs/concepts/conversion-pipeline.adoc +903 -0
- data/docs/concepts/coordinate-systems.adoc +836 -0
- data/docs/concepts/graphics-state.adoc +861 -0
- data/docs/concepts/path-operations.adoc +1076 -0
- data/docs/concepts/postscript-language.adoc +859 -0
- data/docs/concepts/svg-generation.adoc +937 -0
- data/docs/concepts.adoc +198 -0
- data/docs/contributing.adoc +443 -0
- data/docs/development.adoc +420 -0
- data/docs/faq.adoc +493 -0
- data/docs/getting-started/basic-usage.adoc +538 -0
- data/docs/getting-started/common-workflows.adoc +577 -0
- data/docs/getting-started/first-conversion.adoc +492 -0
- data/docs/getting-started/installation.adoc +534 -0
- data/docs/getting-started.adoc +94 -0
- data/docs/index.adoc +248 -0
- data/docs/optimization.adoc +196 -0
- data/docs/ps2svg_compatibility.adoc +149 -0
- data/docs/quick-reference.adoc +453 -0
- data/docs/sitemap.adoc +337 -0
- data/docs/troubleshooting.adoc +486 -0
- data/docs/validation.adoc +772 -0
- data/exe/postsvg +1 -0
- data/lib/postsvg/cli.rb +104 -57
- data/lib/postsvg/color.rb +132 -0
- data/lib/postsvg/errors.rb +68 -3
- data/lib/postsvg/format_number.rb +22 -0
- data/lib/postsvg/graphics_context.rb +80 -0
- data/lib/postsvg/graphics_stack.rb +43 -0
- data/lib/postsvg/model/literals/array.rb +41 -0
- data/lib/postsvg/model/literals/dictionary.rb +34 -0
- data/lib/postsvg/model/literals/hex.rb +37 -0
- data/lib/postsvg/model/literals/name.rb +40 -0
- data/lib/postsvg/model/literals/number.rb +36 -0
- data/lib/postsvg/model/literals/procedure.rb +41 -0
- data/lib/postsvg/model/literals/string.rb +30 -0
- data/lib/postsvg/model/literals.rb +19 -0
- data/lib/postsvg/model/operator.rb +58 -0
- data/lib/postsvg/model/operators/arithmetic.rb +264 -0
- data/lib/postsvg/model/operators/boolean.rb +182 -0
- data/lib/postsvg/model/operators/color.rb +74 -0
- data/lib/postsvg/model/operators/container.rb +186 -0
- data/lib/postsvg/model/operators/control_flow.rb +119 -0
- data/lib/postsvg/model/operators/device.rb +21 -0
- data/lib/postsvg/model/operators/dictionary.rb +118 -0
- data/lib/postsvg/model/operators/font.rb +121 -0
- data/lib/postsvg/model/operators/graphics_state.rb +84 -0
- data/lib/postsvg/model/operators/painting.rb +29 -0
- data/lib/postsvg/model/operators/path.rb +169 -0
- data/lib/postsvg/model/operators/stack.rb +72 -0
- data/lib/postsvg/model/operators/transformations.rb +103 -0
- data/lib/postsvg/model/operators.rb +89 -0
- data/lib/postsvg/model/program.rb +68 -0
- data/lib/postsvg/model/token.rb +43 -0
- data/lib/postsvg/model.rb +17 -0
- data/lib/postsvg/options.rb +29 -0
- data/lib/postsvg/renderer.rb +85 -0
- data/lib/postsvg/serializer.rb +325 -0
- data/lib/postsvg/source/ast_builder.rb +308 -0
- data/lib/postsvg/source/lexer.rb +322 -0
- data/lib/postsvg/source/operand_stack.rb +55 -0
- data/lib/postsvg/source.rb +21 -0
- data/lib/postsvg/svg/attribute_parser.rb +45 -0
- data/lib/postsvg/svg/clip_path_registry.rb +44 -0
- data/lib/postsvg/svg/document.rb +22 -0
- data/lib/postsvg/svg/element.rb +84 -0
- data/lib/postsvg/svg/elements/circle.rb +36 -0
- data/lib/postsvg/svg/elements/clip_path.rb +26 -0
- data/lib/postsvg/svg/elements/defs.rb +24 -0
- data/lib/postsvg/svg/elements/ellipse.rb +38 -0
- data/lib/postsvg/svg/elements/group.rb +37 -0
- data/lib/postsvg/svg/elements/image.rb +35 -0
- data/lib/postsvg/svg/elements/line.rb +36 -0
- data/lib/postsvg/svg/elements/path.rb +32 -0
- data/lib/postsvg/svg/elements/polygon.rb +12 -0
- data/lib/postsvg/svg/elements/polyline.rb +32 -0
- data/lib/postsvg/svg/elements/rect.rb +44 -0
- data/lib/postsvg/svg/elements/svg.rb +39 -0
- data/lib/postsvg/svg/elements/text.rb +42 -0
- data/lib/postsvg/svg/elements.rb +31 -0
- data/lib/postsvg/svg/paint.rb +34 -0
- data/lib/postsvg/svg/parser.rb +39 -0
- data/lib/postsvg/svg/path_data/command.rb +27 -0
- data/lib/postsvg/svg/path_data/parser.rb +82 -0
- data/lib/postsvg/svg/path_data.rb +17 -0
- data/lib/postsvg/svg/stroke.rb +31 -0
- data/lib/postsvg/svg/transform_list.rb +59 -0
- data/lib/postsvg/svg.rb +22 -0
- data/lib/postsvg/svg_builder.rb +249 -0
- data/lib/postsvg/translation/arc_converter.rb +86 -0
- data/lib/postsvg/translation/bounding_box.rb +59 -0
- data/lib/postsvg/translation/context.rb +34 -0
- data/lib/postsvg/translation/handler_registry.rb +38 -0
- data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
- data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
- data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
- data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
- data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
- data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
- data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
- data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
- data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
- data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
- data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
- data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
- data/lib/postsvg/translation/handlers/shared.rb +110 -0
- data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
- data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
- data/lib/postsvg/translation/handlers.rb +25 -0
- data/lib/postsvg/translation/ps_renderer.rb +105 -0
- data/lib/postsvg/translation/record_emitter.rb +35 -0
- data/lib/postsvg/translation.rb +17 -0
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
- data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
- data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
- data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
- data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
- data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
- data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
- data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
- data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
- data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
- data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
- data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
- data/lib/postsvg/visitors/ps_visitor.rb +208 -0
- data/lib/postsvg/visitors.rb +9 -0
- data/lib/postsvg.rb +93 -59
- data/lychee.toml +86 -0
- metadata +216 -11
- 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
|