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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Visitors
5
+ class PsVisitor
6
+ # Boolean / comparison operator handlers. Pop from RUNTIME stack
7
+ # so chained comparisons behave correctly.
8
+ module Boolean
9
+ def visit_true(_op, _ctx)
10
+ @stack << true
11
+ end
12
+
13
+ def visit_false(_op, _ctx)
14
+ @stack << false
15
+ end
16
+
17
+ def visit_eq(_op, _ctx)
18
+ b = @stack.pop
19
+ a = @stack.pop
20
+ @stack << (a == b)
21
+ end
22
+
23
+ def visit_ne(_op, _ctx)
24
+ b = @stack.pop
25
+ a = @stack.pop
26
+ @stack << (a != b)
27
+ end
28
+
29
+ def visit_gt(_op, _ctx)
30
+ b = pop_runtime_number
31
+ a = pop_runtime_number
32
+ @stack << (a > b)
33
+ end
34
+
35
+ def visit_ge(_op, _ctx)
36
+ b = pop_runtime_number
37
+ a = pop_runtime_number
38
+ @stack << (a >= b)
39
+ end
40
+
41
+ def visit_lt(_op, _ctx)
42
+ b = pop_runtime_number
43
+ a = pop_runtime_number
44
+ @stack << (a < b)
45
+ end
46
+
47
+ def visit_le(_op, _ctx)
48
+ b = pop_runtime_number
49
+ a = pop_runtime_number
50
+ @stack << (a <= b)
51
+ end
52
+
53
+ def visit_and(_op, _ctx)
54
+ b = @stack.pop
55
+ a = @stack.pop
56
+ result =
57
+ if a.is_a?(Integer) && b.is_a?(Integer)
58
+ a & b
59
+ else
60
+ truthy?(a) && truthy?(b)
61
+ end
62
+ @stack << result
63
+ end
64
+
65
+ def visit_or(_op, _ctx)
66
+ b = @stack.pop
67
+ a = @stack.pop
68
+ result =
69
+ if a.is_a?(Integer) && b.is_a?(Integer)
70
+ a | b
71
+ else
72
+ truthy?(a) || truthy?(b)
73
+ end
74
+ @stack << result
75
+ end
76
+
77
+ def visit_xor(_op, _ctx)
78
+ b = @stack.pop
79
+ a = @stack.pop
80
+ result =
81
+ if a.is_a?(Integer) && b.is_a?(Integer)
82
+ a ^ b
83
+ else
84
+ truthy?(a) != truthy?(b)
85
+ end
86
+ @stack << result
87
+ end
88
+
89
+ def visit_not(_op, _ctx)
90
+ v = @stack.pop
91
+ result = v.is_a?(Integer) ? ~v : !truthy?(v)
92
+ @stack << result
93
+ end
94
+
95
+ def visit_bitshift(_op, _ctx)
96
+ shift = pop_runtime_number.to_i
97
+ v = pop_runtime_number.to_i
98
+ @stack << (shift.negative? ? (v >> -shift) : (v << shift))
99
+ end
100
+ # truthy? and pop_runtime_number are provided by Common /
101
+ # Arithmetic respectively.
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,53 @@
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 = Postsvg::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 = Postsvg::Color.rgb(op.red.to_f, op.green.to_f, op.blue.to_f)
16
+ @graphics.update(fill_color: color, stroke_color: color)
17
+ end
18
+
19
+ def visit_setcmykcolor(op, _ctx)
20
+ color = Postsvg::Color.cmyk(op.cyan.to_f, op.magenta.to_f, op.yellow.to_f, op.key.to_f)
21
+ @graphics.update(fill_color: color, stroke_color: color)
22
+ end
23
+
24
+ def visit_sethsbcolor(op, _ctx)
25
+ color = hsb_to_color(op.hue.to_f, op.saturation.to_f, op.brightness.to_f)
26
+ @graphics.update(fill_color: color, stroke_color: color)
27
+ end
28
+
29
+ private
30
+
31
+ # HSB to RGB. Standard algorithm.
32
+ def hsb_to_color(h, s, v)
33
+ h = h % 1.0
34
+ i = (h * 6).floor
35
+ f = (h * 6) - i
36
+ p = v * (1 - s)
37
+ q = v * (1 - (f * s))
38
+ t = v * (1 - ((1 - f) * s))
39
+ r, g, b =
40
+ case (i % 6)
41
+ when 0 then [v, t, p]
42
+ when 1 then [q, v, p]
43
+ when 2 then [p, v, t]
44
+ when 3 then [p, q, v]
45
+ when 4 then [t, p, v]
46
+ else [v, p, q]
47
+ end
48
+ Postsvg::Color.rgb(r, g, b)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
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.sub(/\A\//, "")
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
+ else nil
29
+ end
30
+ end
31
+
32
+ # Resolve a value to a number, looking up names in the dict
33
+ # stack when possible. Falls back to 0 for unknown values.
34
+ def numeric_value(value)
35
+ case value
36
+ when Numeric then value
37
+ when Model::Literals::Number then value.value
38
+ when Model::Literals::Name
39
+ entry = lookup_dict(value.value)
40
+ entry ? numeric_value(entry) : 0
41
+ when Model::Computed then 0
42
+ else 0
43
+ end
44
+ end
45
+
46
+ def lookup_dict(name)
47
+ @dict_stack.reverse_each do |dict|
48
+ return dict[name] if dict.key?(name)
49
+ end
50
+ nil
51
+ end
52
+
53
+ # PS truthiness: false, nil, and 0 are falsy. Everything else
54
+ # (including the integer 1 and any non-zero Numeric, plus any
55
+ # object) is truthy.
56
+ def truthy?(value)
57
+ return false if [false, nil].include?(value)
58
+
59
+ return value != 0 if value.is_a?(Numeric)
60
+
61
+ true
62
+ end
63
+ end
64
+ end
65
+ end
66
+ 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
+ case op.operand
55
+ when String
56
+ @stack << op.operand[op.start, op.count]
57
+ when Array
58
+ @stack << op.operand[op.start, op.count]
59
+ when Model::Literals::StringLiteral
60
+ @stack << op.operand.value[op.start, op.count]
61
+ when Model::Literals::ArrayLiteral
62
+ @stack << op.operand.elements[op.start, op.count]
63
+ else
64
+ @stack << ""
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 Postsvg::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 Postsvg::ExitSignal
78
+ end
79
+
80
+ def visit_quit(_op, _ctx)
81
+ raise Postsvg::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,89 @@
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
+ case op.operand
76
+ when Hash then @stack << op.operand.length
77
+ when Model::Literals::Dictionary then @stack << op.operand.entries.length
78
+ else @stack << 0
79
+ end
80
+ end
81
+
82
+ def dict_stack
83
+ @dict_stack ||= [{}]
84
+ end
85
+ # normalize_key is provided by PsVisitor::Common.
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,93 @@
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 || op.size.to_f
35
+ font = @stack.pop
36
+ base = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: nil)
37
+ @stack << FontRef.new(name: base.name, size: size.to_f)
38
+ end
39
+
40
+ def visit_setfont(_op, _ctx)
41
+ font = @stack.pop
42
+ ref = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: 12.0)
43
+ @graphics.update(font_name: ref.name || "Helvetica",
44
+ font_size: ref.size || 12.0)
45
+ end
46
+
47
+ def visit_show(_op, _ctx)
48
+ text = @stack.pop
49
+ text_str =
50
+ case text
51
+ when Model::Literals::StringLiteral then text.value
52
+ when String then text
53
+ else text.to_s
54
+ end
55
+ pos = @graphics.current.last_text_position
56
+ return unless pos
57
+
58
+ ctm = @graphics.current.ctm
59
+ screen_pos = ctm.apply_point(pos[:x], pos[:y])
60
+ color = @graphics.current.fill_color
61
+ # SVG y is inverted relative to PS — the Y-flip wrapper
62
+ # group already flips the whole body, so we emit a
63
+ # scale(1 -1) per <text> to undo the flip for text only.
64
+ @builder.text(
65
+ content: text_str,
66
+ x: screen_pos[:x],
67
+ y: -screen_pos[:y],
68
+ font_family: @graphics.current.font_name,
69
+ font_size: @graphics.current.font_size,
70
+ color: color || Postsvg::Color::BLACK,
71
+ transform: "scale(1 -1)",
72
+ )
73
+ @path.reset
74
+ @graphics.update(last_text_position: nil)
75
+ end
76
+
77
+ def visit_xyshow(_op, _ctx)
78
+ @builder.comment("xyshow: not yet implemented")
79
+ end
80
+
81
+ def visit_stringwidth(_op, _ctx)
82
+ # Without real font metrics, push zero advance.
83
+ @stack << 0
84
+ @stack << 0
85
+ end
86
+
87
+ def visit_charpath(_op, _ctx)
88
+ @builder.comment("charpath: requires font metrics")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end