postsvg 0.1.0 → 0.2.1

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +533 -49
  3. data/CHANGELOG.md +105 -0
  4. data/CLAUDE.md +173 -0
  5. data/Gemfile +2 -3
  6. data/README.adoc +456 -179
  7. data/Rakefile +100 -0
  8. data/TODO.roadmap/00-architecture.md +139 -0
  9. data/TODO.roadmap/01-autoload-migration.md +39 -0
  10. data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
  11. data/TODO.roadmap/03-domain-model.md +66 -0
  12. data/TODO.roadmap/04-lexer.md +40 -0
  13. data/TODO.roadmap/05-parser.md +43 -0
  14. data/TODO.roadmap/06-graphics-state.md +45 -0
  15. data/TODO.roadmap/07-matrix-and-color.md +37 -0
  16. data/TODO.roadmap/08-svg-builder.md +51 -0
  17. data/TODO.roadmap/09-renderer.md +52 -0
  18. data/TODO.roadmap/10-visitor.md +58 -0
  19. data/TODO.roadmap/11-operator-coverage.md +103 -0
  20. data/TODO.roadmap/12-svg-domain.md +62 -0
  21. data/TODO.roadmap/13-translation-handlers.md +69 -0
  22. data/TODO.roadmap/14-ps-serializer.md +49 -0
  23. data/TODO.roadmap/15-cli-and-public-api.md +47 -0
  24. data/TODO.roadmap/16-specs.md +84 -0
  25. data/TODO.roadmap/17-docs-sync.md +47 -0
  26. data/TODO.roadmap/18-performance-and-determinism.md +47 -0
  27. data/TODO.roadmap/19-error-model.md +45 -0
  28. data/TODO.roadmap/20-font-and-text.md +46 -0
  29. data/TODO.roadmap/21-images.md +45 -0
  30. data/TODO.roadmap/22-forms-and-resources.md +25 -0
  31. data/TODO.roadmap/23-level2-level3.md +52 -0
  32. data/TODO.roadmap/24-ci-and-release.md +42 -0
  33. data/TODO.roadmap/README.md +77 -0
  34. data/docs/.gitignore +29 -0
  35. data/docs/CHANGELOG.md +114 -0
  36. data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
  37. data/docs/DEPLOYMENT.md +456 -0
  38. data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
  39. data/docs/DOCUMENTATION_PLAN.md +425 -0
  40. data/docs/FINAL_SUMMARY.md +657 -0
  41. data/docs/Gemfile +15 -0
  42. data/docs/README.md +327 -0
  43. data/docs/_config.yml +99 -0
  44. data/docs/advanced-topics.adoc +370 -0
  45. data/docs/api-reference/colors.adoc +705 -0
  46. data/docs/api-reference/converter.adoc +699 -0
  47. data/docs/api-reference/execution-context.adoc +1210 -0
  48. data/docs/api-reference/graphics-state.adoc +1070 -0
  49. data/docs/api-reference/interpreter.adoc +810 -0
  50. data/docs/api-reference/matrix.adoc +1179 -0
  51. data/docs/api-reference/path-builder.adoc +1284 -0
  52. data/docs/api-reference/postsvg-module.adoc +388 -0
  53. data/docs/api-reference/svg-generator.adoc +891 -0
  54. data/docs/api-reference/tokenizer.adoc +925 -0
  55. data/docs/api-reference.adoc +221 -0
  56. data/docs/architecture/command-registry.adoc +1191 -0
  57. data/docs/architecture/conversion-pipeline.adoc +746 -0
  58. data/docs/architecture/design-decisions.adoc +999 -0
  59. data/docs/architecture/generator-stage.adoc +1115 -0
  60. data/docs/architecture/graphics-state-model.adoc +1089 -0
  61. data/docs/architecture/interpreter-stage.adoc +1125 -0
  62. data/docs/architecture/parser-stage.adoc +1051 -0
  63. data/docs/architecture.adoc +354 -0
  64. data/docs/cli-reference/batch-command.adoc +616 -0
  65. data/docs/cli-reference/check-command.adoc +677 -0
  66. data/docs/cli-reference/cli-options.adoc +802 -0
  67. data/docs/cli-reference/convert-command.adoc +462 -0
  68. data/docs/cli-reference/version-command.adoc +296 -0
  69. data/docs/cli-reference.adoc +317 -0
  70. data/docs/concepts/conversion-pipeline.adoc +903 -0
  71. data/docs/concepts/coordinate-systems.adoc +836 -0
  72. data/docs/concepts/graphics-state.adoc +861 -0
  73. data/docs/concepts/path-operations.adoc +1076 -0
  74. data/docs/concepts/postscript-language.adoc +859 -0
  75. data/docs/concepts/svg-generation.adoc +937 -0
  76. data/docs/concepts.adoc +198 -0
  77. data/docs/contributing.adoc +443 -0
  78. data/docs/development.adoc +420 -0
  79. data/docs/faq.adoc +493 -0
  80. data/docs/getting-started/basic-usage.adoc +538 -0
  81. data/docs/getting-started/common-workflows.adoc +577 -0
  82. data/docs/getting-started/first-conversion.adoc +492 -0
  83. data/docs/getting-started/installation.adoc +534 -0
  84. data/docs/getting-started.adoc +94 -0
  85. data/docs/index.adoc +248 -0
  86. data/docs/optimization.adoc +196 -0
  87. data/docs/ps2svg_compatibility.adoc +149 -0
  88. data/docs/quick-reference.adoc +453 -0
  89. data/docs/sitemap.adoc +337 -0
  90. data/docs/troubleshooting.adoc +486 -0
  91. data/docs/validation.adoc +772 -0
  92. data/exe/postsvg +1 -0
  93. data/lib/postsvg/cli.rb +109 -57
  94. data/lib/postsvg/errors.rb +68 -3
  95. data/lib/postsvg/graphics_context.rb +81 -0
  96. data/lib/postsvg/graphics_stack.rb +43 -0
  97. data/lib/postsvg/options.rb +29 -0
  98. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  99. data/lib/postsvg/renderer.rb +89 -0
  100. data/lib/postsvg/svg/attribute_parser.rb +45 -0
  101. data/lib/postsvg/svg/clip_path_registry.rb +46 -0
  102. data/lib/postsvg/svg/document.rb +22 -0
  103. data/lib/postsvg/svg/element.rb +86 -0
  104. data/lib/postsvg/svg/elements/circle.rb +36 -0
  105. data/lib/postsvg/svg/elements/clip_path.rb +26 -0
  106. data/lib/postsvg/svg/elements/defs.rb +24 -0
  107. data/lib/postsvg/svg/elements/ellipse.rb +39 -0
  108. data/lib/postsvg/svg/elements/group.rb +37 -0
  109. data/lib/postsvg/svg/elements/image.rb +35 -0
  110. data/lib/postsvg/svg/elements/line.rb +36 -0
  111. data/lib/postsvg/svg/elements/path.rb +32 -0
  112. data/lib/postsvg/svg/elements/polygon.rb +12 -0
  113. data/lib/postsvg/svg/elements/polyline.rb +32 -0
  114. data/lib/postsvg/svg/elements/rect.rb +44 -0
  115. data/lib/postsvg/svg/elements/svg.rb +40 -0
  116. data/lib/postsvg/svg/elements/text.rb +42 -0
  117. data/lib/postsvg/svg/elements.rb +31 -0
  118. data/lib/postsvg/svg/paint.rb +34 -0
  119. data/lib/postsvg/svg/parser.rb +39 -0
  120. data/lib/postsvg/svg/path_data/command.rb +27 -0
  121. data/lib/postsvg/svg/path_data/parser.rb +86 -0
  122. data/lib/postsvg/svg/path_data.rb +17 -0
  123. data/lib/postsvg/svg/stroke.rb +33 -0
  124. data/lib/postsvg/svg/transform_list.rb +59 -0
  125. data/lib/postsvg/svg.rb +22 -0
  126. data/lib/postsvg/svg_builder.rb +253 -0
  127. data/lib/postsvg/translation/arc_converter.rb +96 -0
  128. data/lib/postsvg/translation/bounding_box.rb +62 -0
  129. data/lib/postsvg/translation/context.rb +34 -0
  130. data/lib/postsvg/translation/handler_registry.rb +38 -0
  131. data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
  132. data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
  133. data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
  134. data/lib/postsvg/translation/handlers/ellipse_handler.rb +36 -0
  135. data/lib/postsvg/translation/handlers/group_handler.rb +22 -0
  136. data/lib/postsvg/translation/handlers/image_handler.rb +21 -0
  137. data/lib/postsvg/translation/handlers/line_handler.rb +27 -0
  138. data/lib/postsvg/translation/handlers/open_handler.rb +19 -0
  139. data/lib/postsvg/translation/handlers/path_handler.rb +372 -0
  140. data/lib/postsvg/translation/handlers/polygon_handler.rb +35 -0
  141. data/lib/postsvg/translation/handlers/polyline_handler.rb +33 -0
  142. data/lib/postsvg/translation/handlers/rect_handler.rb +32 -0
  143. data/lib/postsvg/translation/handlers/shared.rb +110 -0
  144. data/lib/postsvg/translation/handlers/svg_handler.rb +26 -0
  145. data/lib/postsvg/translation/handlers/text_handler.rb +57 -0
  146. data/lib/postsvg/translation/handlers.rb +26 -0
  147. data/lib/postsvg/translation/ps_renderer.rb +106 -0
  148. data/lib/postsvg/translation/record_emitter.rb +35 -0
  149. data/lib/postsvg/translation.rb +17 -0
  150. data/lib/postsvg/version.rb +1 -1
  151. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
  152. data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
  153. data/lib/postsvg/visitors/ps_visitor/color.rb +56 -0
  154. data/lib/postsvg/visitors/ps_visitor/common.rb +65 -0
  155. data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
  156. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
  157. data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
  158. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +93 -0
  159. data/lib/postsvg/visitors/ps_visitor/font.rb +103 -0
  160. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
  161. data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
  162. data/lib/postsvg/visitors/ps_visitor/path.rb +117 -0
  163. data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
  164. data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
  165. data/lib/postsvg/visitors/ps_visitor.rb +208 -0
  166. data/lib/postsvg/visitors.rb +9 -0
  167. data/lib/postsvg.rb +96 -57
  168. data/lychee.toml +86 -0
  169. metadata +197 -12
  170. data/lib/postsvg/matrix.rb +0 -106
  171. data/postsvg.gemspec +0 -38
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Color operator handlers. Each updates the current
7
+ # GraphicsContext; the next paint operator reads the new colors.
8
+ module Color
9
+ def visit_setgray(op, _ctx)
10
+ color = Postscript::Color.gray(op.gray.to_f)
11
+ @graphics.update(fill_color: color, stroke_color: color)
12
+ end
13
+
14
+ def visit_setrgbcolor(op, _ctx)
15
+ color = Postscript::Color.rgb(op.red.to_f, op.green.to_f,
16
+ op.blue.to_f)
17
+ @graphics.update(fill_color: color, stroke_color: color)
18
+ end
19
+
20
+ def visit_setcmykcolor(op, _ctx)
21
+ color = Postscript::Color.cmyk(op.cyan.to_f, op.magenta.to_f,
22
+ op.yellow.to_f, op.key.to_f)
23
+ @graphics.update(fill_color: color, stroke_color: color)
24
+ end
25
+
26
+ def visit_sethsbcolor(op, _ctx)
27
+ color = hsb_to_color(op.hue.to_f, op.saturation.to_f,
28
+ op.brightness.to_f)
29
+ @graphics.update(fill_color: color, stroke_color: color)
30
+ end
31
+
32
+ private
33
+
34
+ # HSB to RGB. Standard algorithm.
35
+ def hsb_to_color(h, s, v)
36
+ h = h % 1.0
37
+ i = (h * 6).floor
38
+ f = (h * 6) - i
39
+ p = v * (1 - s)
40
+ q = v * (1 - (f * s))
41
+ t = v * (1 - ((1 - f) * s))
42
+ r, g, b =
43
+ case (i % 6)
44
+ when 0 then [v, t, p]
45
+ when 1 then [q, v, p]
46
+ when 2 then [p, v, t]
47
+ when 3 then [p, q, v]
48
+ when 4 then [t, p, v]
49
+ else [v, p, q]
50
+ end
51
+ Postscript::Color.rgb(r, g, b)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Helpers shared across multiple visitor modules. Mixed into
7
+ # PsVisitor so every module sees the same helpers without
8
+ # re-defining them. DRY: previously +normalize_key+ existed
9
+ # in both Dictionary and Container.
10
+ module Common
11
+ # Convert a key from any literal-typed value to the canonical
12
+ # string form used in dictionary lookups.
13
+ def normalize_key(key)
14
+ case key
15
+ when Model::Literals::Name then key.value
16
+ when String then key.to_s.delete_prefix("/")
17
+ else key.to_s
18
+ end
19
+ end
20
+
21
+ # Coerce a runtime value to its underlying string content
22
+ # (handles StringLiteral / HexLiteral / plain String).
23
+ def string_value(node)
24
+ case node
25
+ when String then node
26
+ when Model::Literals::StringLiteral then node.value
27
+ when Model::Literals::HexLiteral then node.bytes
28
+ end
29
+ end
30
+
31
+ # Resolve a value to a number, looking up names in the dict
32
+ # stack when possible. Falls back to 0 for unknown values.
33
+ def numeric_value(value)
34
+ case value
35
+ when Numeric then value
36
+ when Model::Literals::Number then value.value
37
+ when Model::Literals::Name
38
+ entry = lookup_dict(value.value)
39
+ entry ? numeric_value(entry) : 0
40
+ when Model::Computed then 0
41
+ else 0
42
+ end
43
+ end
44
+
45
+ def lookup_dict(name)
46
+ @dict_stack.reverse_each do |dict|
47
+ return dict[name] if dict.key?(name)
48
+ end
49
+ nil
50
+ end
51
+
52
+ # PS truthiness: false, nil, and 0 are falsy. Everything else
53
+ # (including the integer 1 and any non-zero Numeric, plus any
54
+ # object) is truthy.
55
+ def truthy?(value)
56
+ return false if [false, nil].include?(value)
57
+
58
+ return value != 0 if value.is_a?(Numeric)
59
+
60
+ true
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Operators that operate on any collection (string, array,
7
+ # dictionary). The visitor dispatches by +is_a?+ on the
8
+ # operand at runtime.
9
+ module Container
10
+ def visit_length(op, _ctx)
11
+ operand = op.operand
12
+ value =
13
+ case operand
14
+ when String then operand.length
15
+ when Array then operand.length
16
+ when Hash then operand.length
17
+ when Model::Literals::StringLiteral then operand.value.length
18
+ when Model::Literals::ArrayLiteral then operand.elements.length
19
+ when Model::Literals::Dictionary then operand.entries.length
20
+ when Model::Literals::HexLiteral then operand.value.length / 2
21
+ else 0
22
+ end
23
+ @stack << value
24
+ end
25
+
26
+ def visit_get(op, _ctx)
27
+ operand = op.operand
28
+ key = op.key
29
+ value =
30
+ case operand
31
+ when Hash
32
+ operand[normalize_key(key)]
33
+ when Array
34
+ operand[key.to_i]
35
+ when String
36
+ operand.getbyte(key.to_i)
37
+ when Model::Literals::Dictionary
38
+ operand.entries[normalize_key(key)]
39
+ when Model::Literals::ArrayLiteral
40
+ operand.elements[key.to_i]
41
+ when Model::Literals::StringLiteral
42
+ operand.value.getbyte(key.to_i)
43
+ end
44
+ @stack << value
45
+ end
46
+
47
+ def visit_put(_op, _ctx)
48
+ # PS put mutates in place. Our value objects are immutable.
49
+ # Marked as a comment in the output so the gap is visible.
50
+ @builder.comment("put: in-place mutation not supported")
51
+ end
52
+
53
+ def visit_getinterval(op, _ctx)
54
+ @stack << case op.operand
55
+ when String
56
+ op.operand[op.start, op.count]
57
+ when Array
58
+ op.operand[op.start, op.count]
59
+ when Model::Literals::StringLiteral
60
+ op.operand.value[op.start, op.count]
61
+ when Model::Literals::ArrayLiteral
62
+ op.operand.elements[op.start, op.count]
63
+ else
64
+ ""
65
+ end
66
+ end
67
+
68
+ def visit_putinterval(_op, _ctx)
69
+ @builder.comment("putinterval: in-place mutation not supported")
70
+ end
71
+
72
+ def visit_forall(op, _ctx)
73
+ return unless op.body.is_a?(Model::Literals::Procedure)
74
+
75
+ case op.collection
76
+ when Array
77
+ op.collection.each do |item|
78
+ @stack << item
79
+ op.body.body.each { |node| node.accept(self, nil) }
80
+ end
81
+ when Hash
82
+ op.collection.each do |key, value|
83
+ @stack << value
84
+ @stack << key
85
+ op.body.body.each { |node| node.accept(self, nil) }
86
+ end
87
+ when String
88
+ op.collection.each_byte do |byte|
89
+ @stack << byte
90
+ op.body.body.each { |node| node.accept(self, nil) }
91
+ end
92
+ end
93
+ end
94
+
95
+ def visit_astore(op, _ctx)
96
+ # astore pops N items and builds an array. The parser
97
+ # already gave us the array via from_operands; just push
98
+ # it back. The original semantics (`arr astore` arr is on
99
+ # bottom, n items on top) are preserved.
100
+ @stack << (op.array.is_a?(Array) ? op.array : [])
101
+ end
102
+
103
+ def visit_search(op, _ctx)
104
+ haystack = string_value(op.target)
105
+ needle = string_value(op.pattern)
106
+ if haystack && needle && (idx = haystack.index(needle))
107
+ post = haystack[(idx + needle.length)..]
108
+ @stack << post
109
+ @stack << needle
110
+ @stack << haystack[0, idx]
111
+ @stack << true
112
+ else
113
+ @stack << haystack
114
+ @stack << false
115
+ end
116
+ end
117
+
118
+ def visit_anchorsearch(op, _ctx)
119
+ haystack = string_value(op.target)
120
+ needle = string_value(op.pattern)
121
+ if haystack && needle && haystack.start_with?(needle)
122
+ @stack << haystack[needle.length..]
123
+ @stack << needle
124
+ @stack << true
125
+ else
126
+ @stack << haystack
127
+ @stack << false
128
+ end
129
+ end
130
+
131
+ def visit_token(op, _ctx)
132
+ text = string_value(op.target)
133
+ return @stack << false unless text
134
+
135
+ # PS token splits on whitespace and parses the next token.
136
+ if (match = text.match(/\A\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/))
137
+ @stack << match[1].to_f
138
+ @stack << text[match[0].length..]
139
+ @stack << true
140
+ else
141
+ @stack << false
142
+ end
143
+ end
144
+
145
+ def visit_string(op, _ctx)
146
+ @stack << ("\0" * op.count.to_i)
147
+ end
148
+
149
+ def visit_cvs(op, _ctx)
150
+ text =
151
+ case op.value
152
+ when Numeric then op.value.to_s
153
+ when String then op.value
154
+ when Model::Literals::StringLiteral then op.value.value
155
+ when Model::Literals::Name then op.value.value
156
+ else op.value.to_s
157
+ end
158
+ @stack << text
159
+ end
160
+ # normalize_key and string_value are provided by PsVisitor::Common.
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Control flow operator handlers. Each pops operands from the
7
+ # RUNTIME stack (not the AST), so chained execution behaves
8
+ # correctly. Bodies are +Procedure+ values that +descend_into_procedure+
9
+ # walks in the current visitor context.
10
+ module ControlFlow
11
+ LOOP_LIMIT = 10_000
12
+
13
+ def visit_if(_op, _ctx)
14
+ body = @stack.pop
15
+ condition = @stack.pop
16
+ return unless truthy?(condition)
17
+
18
+ descend_into_procedure(body)
19
+ end
20
+
21
+ def visit_ifelse(_op, _ctx)
22
+ else_body = @stack.pop
23
+ if_body = @stack.pop
24
+ condition = @stack.pop
25
+ if truthy?(condition)
26
+ descend_into_procedure(if_body)
27
+ else
28
+ descend_into_procedure(else_body)
29
+ end
30
+ end
31
+
32
+ def visit_repeat(_op, _ctx)
33
+ body = @stack.pop
34
+ count = pop_runtime_number.to_i.clamp(0, LOOP_LIMIT)
35
+ count.times { descend_into_procedure(body) }
36
+ end
37
+
38
+ def visit_loop(_op, _ctx)
39
+ body = @stack.pop
40
+ LOOP_LIMIT.times do
41
+ descend_into_procedure(body)
42
+ rescue Postscript::ExitSignal
43
+ break
44
+ end
45
+ end
46
+
47
+ def visit_for(_op, _ctx)
48
+ body = @stack.pop
49
+ limit = pop_runtime_number.to_f
50
+ increment = pop_runtime_number.to_f
51
+ initial = pop_runtime_number.to_f
52
+ iterations = 0
53
+ if increment.positive?
54
+ value = initial
55
+ while value <= limit
56
+ @stack << value
57
+ descend_into_procedure(body)
58
+ value += increment
59
+ iterations += 1
60
+ break if iterations > LOOP_LIMIT
61
+ end
62
+ elsif increment.negative?
63
+ value = initial
64
+ while value >= limit
65
+ @stack << value
66
+ descend_into_procedure(body)
67
+ value += increment
68
+ iterations += 1
69
+ break if iterations > LOOP_LIMIT
70
+ end
71
+ else
72
+ raise RenderError, "for: zero increment"
73
+ end
74
+ end
75
+
76
+ def visit_exit(_op, _ctx)
77
+ raise Postscript::ExitSignal
78
+ end
79
+
80
+ def visit_quit(_op, _ctx)
81
+ raise Postscript::QuitSignal
82
+ end
83
+
84
+ def visit_exec(_op, _ctx)
85
+ operand = @stack.pop
86
+ case operand
87
+ when Model::Literals::Procedure
88
+ descend_into_procedure(operand)
89
+ else
90
+ @stack << operand
91
+ end
92
+ end
93
+
94
+ def visit_stopped(_op, _ctx)
95
+ body = @stack.pop
96
+ descend_into_procedure(body)
97
+ @stack << false
98
+ end
99
+
100
+ def descend_into_procedure(procedure)
101
+ return unless procedure.is_a?(Model::Literals::Procedure)
102
+
103
+ procedure.body.each { |node| node.accept(self, nil) }
104
+ end
105
+ # truthy? and pop_runtime_number are provided by Common /
106
+ # Arithmetic respectively.
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Device / page operator handlers. In a single-page SVG render
7
+ # these are largely no-ops; they exist so programs that end
8
+ # with +showpage+ parse cleanly.
9
+ module Device
10
+ def visit_showpage(_op, _ctx)
11
+ # No-op: SVG is single-page; the renderer finalises on close.
12
+ end
13
+
14
+ def visit_copypage(_op, _ctx); end
15
+
16
+ def visit_nulldevice(_op, _ctx); end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Dictionary operator handlers (dict-specific only). Generic
7
+ # collection operators (get / put / length / getinterval /
8
+ # putinterval) live in the +Container+ module because they
9
+ # dispatch on operand type at runtime.
10
+ module Dictionary
11
+ # The base PsVisitor class initializes +@dict_stack+ as a
12
+ # single-element array (the global dictionary). Modules assume
13
+ # it exists; they do not re-initialize.
14
+
15
+ def visit_dict(_op, _ctx)
16
+ @stack << {}
17
+ end
18
+
19
+ def visit_begin(_op, _ctx)
20
+ value = @stack.pop
21
+ @dict_stack << (value.is_a?(Hash) ? value : {})
22
+ end
23
+
24
+ def visit_end(_op, _ctx)
25
+ @dict_stack.pop if @dict_stack.length > 1
26
+ end
27
+
28
+ def visit_def(op, _ctx)
29
+ key = normalize_key(op.key)
30
+ @dict_stack.last[key] = op.value
31
+ end
32
+
33
+ def visit_load(op, _ctx)
34
+ key = normalize_key(op.key)
35
+ @dict_stack.reverse_each do |dict|
36
+ if dict.key?(key)
37
+ @stack << dict[key]
38
+ return
39
+ end
40
+ end
41
+ raise UndefinedOperatorError, "no value bound to /#{key}"
42
+ end
43
+
44
+ def visit_store(op, _ctx)
45
+ key = normalize_key(op.key)
46
+ @dict_stack.reverse_each do |dict|
47
+ if dict.key?(key)
48
+ dict[key] = op.value
49
+ return
50
+ end
51
+ end
52
+ @dict_stack.last[key] = op.value
53
+ end
54
+
55
+ def visit_known(op, _ctx)
56
+ key = normalize_key(op.key)
57
+ dict = op.dict.is_a?(Hash) ? op.dict : {}
58
+ @stack << dict.key?(key)
59
+ end
60
+
61
+ def visit_currentdict(_op, _ctx)
62
+ @stack << @dict_stack.last
63
+ end
64
+
65
+ def visit_countdictstack(_op, _ctx)
66
+ @stack << @dict_stack.length
67
+ end
68
+
69
+ def visit_dictstack(_op, _ctx)
70
+ @stack << @dict_stack.dup
71
+ end
72
+
73
+ def visit_maxlength(op, _ctx)
74
+ # PS dictionaries can grow dynamically; report current size.
75
+ @stack << dict_size(op.operand)
76
+ end
77
+
78
+ def dict_size(operand)
79
+ case operand
80
+ when Hash then operand.length
81
+ when Model::Literals::Dictionary then operand.entries.length
82
+ else 0
83
+ end
84
+ end
85
+
86
+ def dict_stack
87
+ @dict_stack ||= [{}]
88
+ end
89
+ # normalize_key is provided by PsVisitor::Common.
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Font and text operator handlers. Each pops from the RUNTIME
7
+ # stack (not the AST) so chained font setup works:
8
+ #
9
+ # /Helvetica findfont 18 scalefont setfont
10
+ #
11
+ # pushes a FontRef after findfont, scales it after scalefont,
12
+ # installs it after setfont.
13
+ module Font
14
+ # Tracks the current font for later +show+. The visitor
15
+ # pushes FontRef value objects onto the stack.
16
+ FontRef = Struct.new(:name, :size, keyword_init: true) do
17
+ def initialize(name:, size:)
18
+ super
19
+ freeze
20
+ end
21
+ end
22
+
23
+ def visit_findfont(op, _ctx)
24
+ name =
25
+ case op.name
26
+ when Model::Literals::Name then op.name.value
27
+ when Model::Literals::StringLiteral then op.name.value
28
+ else op.name.to_s
29
+ end
30
+ @stack << FontRef.new(name: name, size: nil)
31
+ end
32
+
33
+ def visit_scalefont(op, _ctx)
34
+ size = op.size.to_i
35
+ font = @stack.pop
36
+ base = if font.is_a?(FontRef)
37
+ font
38
+ else
39
+ FontRef.new(name: "Helvetica",
40
+ size: nil)
41
+ end
42
+ @stack << FontRef.new(name: base.name, size: size.to_f)
43
+ end
44
+
45
+ def visit_setfont(_op, _ctx)
46
+ font = @stack.pop
47
+ ref = if font.is_a?(FontRef)
48
+ font
49
+ else
50
+ FontRef.new(name: "Helvetica",
51
+ size: 12.0)
52
+ end
53
+ @graphics.update(font_name: ref.name || "Helvetica",
54
+ font_size: ref.size || 12.0)
55
+ end
56
+
57
+ def visit_show(_op, _ctx)
58
+ text = @stack.pop
59
+ text_str =
60
+ case text
61
+ when Model::Literals::StringLiteral then text.value
62
+ when String then text
63
+ else text.to_s
64
+ end
65
+ pos = @graphics.current.last_text_position
66
+ return unless pos
67
+
68
+ ctm = @graphics.current.ctm
69
+ screen_pos = ctm.apply_point(pos[:x], pos[:y])
70
+ color = @graphics.current.fill_color
71
+ # SVG y is inverted relative to PS — the Y-flip wrapper
72
+ # group already flips the whole body, so we emit a
73
+ # scale(1 -1) per <text> to undo the flip for text only.
74
+ @builder.text(
75
+ content: text_str,
76
+ x: screen_pos[:x],
77
+ y: -screen_pos[:y],
78
+ font_family: @graphics.current.font_name,
79
+ font_size: @graphics.current.font_size,
80
+ color: color || Postscript::Color::BLACK,
81
+ transform: "scale(1 -1)",
82
+ )
83
+ @path.reset
84
+ @graphics.update(last_text_position: nil)
85
+ end
86
+
87
+ def visit_xyshow(_op, _ctx)
88
+ @builder.comment("xyshow: not yet implemented")
89
+ end
90
+
91
+ def visit_stringwidth(_op, _ctx)
92
+ # Without real font metrics, push zero advance.
93
+ @stack << 0
94
+ @stack << 0
95
+ end
96
+
97
+ def visit_charpath(_op, _ctx)
98
+ @builder.comment("charpath: requires font metrics")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Graphics state operator handlers: gsave/grestore and the line/
7
+ # dash attribute setters.
8
+ module GraphicsState
9
+ def visit_gsave(_op, _ctx)
10
+ @graphics.gsave
11
+ end
12
+
13
+ def visit_grestore(_op, _ctx)
14
+ @graphics.grestore
15
+ end
16
+
17
+ def visit_grestoreall(_op, _ctx)
18
+ @graphics.grestore while @graphics.depth > 1
19
+ end
20
+
21
+ def visit_setlinewidth(op, _ctx)
22
+ @graphics.update(stroke_width: op.width.to_f)
23
+ end
24
+
25
+ def visit_setlinecap(op, _ctx)
26
+ name = { 0 => :butt, 1 => :round, 2 => :square }.fetch(op.cap_code,
27
+ :butt)
28
+ @graphics.update(line_cap: name)
29
+ end
30
+
31
+ def visit_setlinejoin(op, _ctx)
32
+ name = { 0 => :miter, 1 => :round, 2 => :bevel }.fetch(op.join_code,
33
+ :miter)
34
+ @graphics.update(line_join: name)
35
+ end
36
+
37
+ def visit_setmiterlimit(op, _ctx)
38
+ @graphics.update(miter_limit: op.limit.to_f)
39
+ end
40
+
41
+ def visit_setdash(op, _ctx)
42
+ pattern = op.pattern
43
+ dash_str =
44
+ case pattern
45
+ when Array
46
+ pattern.map { |v| FormatNumber.call(v.to_f) }.join(" ")
47
+ when Numeric
48
+ FormatNumber.call(pattern.to_f)
49
+ end
50
+ @graphics.update(dash: dash_str)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end