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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ # Accumulates the SVG content's geometric bounds as the SVG tree
6
+ # is walked. Used to write the +%%BoundingBox+ DSC comment in the
7
+ # serialized output.
8
+ class BoundingBox
9
+ attr_reader :min_x, :min_y, :max_x, :max_y
10
+
11
+ def initialize(min_x:, min_y:, max_x:, max_y:)
12
+ @min_x = min_x
13
+ @min_y = min_y
14
+ @max_x = max_x
15
+ @max_y = max_y
16
+ freeze
17
+ end
18
+
19
+ def self.empty
20
+ new(min_x: nil, min_y: nil, max_x: nil, max_y: nil)
21
+ end
22
+
23
+ def empty?
24
+ @min_x.nil?
25
+ end
26
+
27
+ def expand(x_range, y_range)
28
+ return self if x_range.nil? || y_range.nil?
29
+
30
+ new_min_x = [(empty? ? x_range.begin : @min_x), x_range.begin].compact.min
31
+ new_max_x = [(empty? ? x_range.end : @max_x), x_range.end].compact.max
32
+ new_min_y = [(empty? ? y_range.begin : @min_y), y_range.begin].compact.min
33
+ new_max_y = [(empty? ? y_range.end : @max_y), y_range.end].compact.max
34
+ BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x, max_y: new_max_y)
35
+ end
36
+
37
+ def to_a
38
+ empty? ? [] : [@min_x, @min_y, @max_x, @max_y]
39
+ end
40
+
41
+ def width
42
+ empty? ? 0 : @max_x - @min_x
43
+ end
44
+
45
+ def height
46
+ empty? ? 0 : @max_y - @min_y
47
+ end
48
+
49
+ def to_dsc_comment
50
+ return nil if empty?
51
+
52
+ "%s %s %s %s" % [
53
+ FormatNumber.call(@min_x), FormatNumber.call(@min_y),
54
+ FormatNumber.call(@max_x), FormatNumber.call(@max_y),
55
+ ]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ # Mutable translation context carried through the SVG tree walk.
6
+ # Holds the emitter, the graphics state stack (so handlers can
7
+ # emit gsave/grestore around transforms), and the bounding box
8
+ # accumulator.
9
+ class Context
10
+ attr_reader :emitter, :graphics, :bounding_box, :options,
11
+ :clip_path_registry
12
+
13
+ def initialize(emitter: RecordEmitter.new,
14
+ graphics: GraphicsStack.new,
15
+ bounding_box: BoundingBox.empty,
16
+ clip_path_registry: ::Postsvg::Svg::ClipPathRegistry.empty,
17
+ options: {})
18
+ @emitter = emitter
19
+ @graphics = graphics
20
+ @bounding_box = bounding_box
21
+ @clip_path_registry = clip_path_registry
22
+ @options = options.freeze
23
+ end
24
+
25
+ def expand_bbox!(x_range, y_range)
26
+ @bounding_box = @bounding_box.expand(x_range, y_range)
27
+ end
28
+
29
+ def eps?
30
+ options.fetch(:eps, false)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ # Maps SVG element class -> handler class. Handlers implement
6
+ # +.call(element, context)+ which appends Model records to the
7
+ # context's emitter.
8
+ #
9
+ # OCP: adding a new SVG element type means writing a new handler
10
+ # class and registering it. No existing handler code changes.
11
+ class HandlerRegistry
12
+ def initialize
13
+ @handlers = {}
14
+ end
15
+
16
+ def register(element_class, handler)
17
+ @handlers[element_class] = handler
18
+ end
19
+
20
+ def handler_for(element)
21
+ klass = element.class
22
+ while klass
23
+ return @handlers[klass] if @handlers.key?(klass)
24
+
25
+ klass = klass.superclass
26
+ end
27
+ nil
28
+ end
29
+
30
+ def translate(element, context)
31
+ handler = handler_for(element)
32
+ return unless handler
33
+
34
+ handler.call(element, context)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class CircleHandler
7
+ extend Shared
8
+
9
+ def self.call(element, context)
10
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
11
+ emit_transform(element, context)
12
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
13
+ r = element.r
14
+ cx = element.cx
15
+ cy = element.cy
16
+ if r.to_f.positive?
17
+ context.emitter.emit(Model::Operators::Path::Arc.new(
18
+ x: cx, y: cy, radius: r, angle1: 0, angle2: 360,
19
+ ))
20
+ end
21
+ emit_paint(element, context)
22
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
23
+ expand_bbox(context, cx - r, cy - r, cx + r, cy + r)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class ClipPathHandler
7
+ # clipPath definitions are read at parse time into
8
+ # Svg::ClipPathRegistry. Nothing to emit here.
9
+ def self.call(_element, _context); end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class DefsHandler
7
+ # <defs> children are referenced; we don't paint them at the
8
+ # point of definition. The ClipPathRegistry already indexed
9
+ # clipPaths from the document; gradients / patterns are
10
+ # future work.
11
+ def self.call(_element, _context); end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class EllipseHandler
7
+ extend Shared
8
+
9
+ def self.call(element, context)
10
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
11
+ emit_transform(element, context)
12
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
13
+ cx = element.cx
14
+ cy = element.cy
15
+ rx = element.rx
16
+ ry = element.ry
17
+ # Approximate ellipse with scale + arc.
18
+ if rx.to_f.positive? && ry.to_f.positive?
19
+ scale = rx / ry
20
+ context.emitter.emit(Model::Operators::Transformations::Translate.new(tx: cx, ty: cy))
21
+ context.emitter.emit(Model::Operators::Transformations::Scale.new(sx: scale, sy: 1))
22
+ context.emitter.emit(Model::Operators::Path::Arc.new(x: 0, y: 0, radius: ry, angle1: 0, angle2: 360))
23
+ end
24
+ emit_paint(element, context)
25
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
26
+ expand_bbox(context, cx - rx, cy - ry, cx + rx, cy + ry)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class GroupHandler
7
+ extend Shared
8
+
9
+ def self.call(element, context)
10
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
11
+ emit_transform(element, context)
12
+ emit_paint_setup(element, context)
13
+ element.children.each do |child|
14
+ Postsvg::Translation::PsRenderer::DEFAULT_REGISTRY.translate(child, context)
15
+ end
16
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class ImageHandler
7
+ extend Shared
8
+
9
+ def self.call(element, context)
10
+ # Raster image support is tracked in TODO.roadmap/21-images.md.
11
+ # For now, emit a comment so the serialized PS file has a
12
+ # visible marker of the elided content.
13
+ href = element.href || "(none)"
14
+ context.emitter.emit(Model::UnknownOperator.new(keyword: "image"))
15
+ expand_bbox(context, element.x, element.y, element.x + element.width, element.y + element.height)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class LineHandler
7
+ extend Shared
8
+
9
+ def self.call(element, context)
10
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
11
+ emit_transform(element, context)
12
+ emit_stroke(element, context)
13
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
14
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: element.x1, y: element.y1))
15
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: element.x2, y: element.y2))
16
+ context.emitter.emit(Model::Operators::Painting::Stroke.new)
17
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
18
+ expand_bbox(context, element.x1, element.y1, element.x2, element.y2)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class OpenHandler
7
+ # Unknown SVG element. Descend into children so known subtrees
8
+ # still emit. Element base class guarantees a +children+
9
+ # getter, so no capability check is needed.
10
+ def self.call(element, context)
11
+ element.children.each do |child|
12
+ Postsvg::Translation::PsRenderer.default_registry.translate(child, context)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ # SVG `<path d="...">`. Walks the PathData command list and
7
+ # emits equivalent PS path construction operators. Full SVG
8
+ # path grammar support:
9
+ #
10
+ # - M/m moveto (relative repeats as l)
11
+ # - L/l lineto
12
+ # - H/h vertical line
13
+ # - V/v horizontal line
14
+ # - C/c cubic bezier
15
+ # - S/s smooth cubic (reflects previous control point)
16
+ # - Q/q quadratic bezier (converted to cubic)
17
+ # - T/t smooth quadratic (converted to cubic)
18
+ # - A/a arc (converted to PS arc via center-point parametrization)
19
+ # - Z/z closepath
20
+ class PathHandler
21
+ extend Shared
22
+
23
+ # Control-point reflection state for smooth curves. +nil+ when
24
+ # the previous command wasn't a smooth-eligible curve.
25
+ SmoothState = Struct.new(:control_x, :control_y, :smoothable, keyword_init: true)
26
+
27
+ def self.call(element, context)
28
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
29
+ emit_transform(element, context)
30
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
31
+
32
+ state = PathState.new
33
+ smooth = SmoothState.new(control_x: nil, control_y: nil, smoothable: false)
34
+ element.commands.each do |cmd|
35
+ dispatch_command(cmd, state, smooth, context)
36
+ end
37
+ emit_paint(element, context)
38
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
39
+ expand_bbox(context, *state.bbox_points) if state.bbox_points.any?
40
+ end
41
+
42
+ def self.dispatch_command(cmd, state, smooth, context)
43
+ case cmd.opcode
44
+ when "M", "m"
45
+ handle_moveto(cmd, state, smooth, context)
46
+ when "L", "l"
47
+ handle_lineto(cmd, state, context)
48
+ when "H", "h"
49
+ handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "H")
50
+ when "V", "v"
51
+ handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "V")
52
+ when "C", "c"
53
+ handle_cubic(cmd, state, smooth, context)
54
+ when "S", "s"
55
+ handle_smooth_cubic(cmd, state, smooth, context)
56
+ when "Q", "q"
57
+ handle_quadratic(cmd, state, smooth, context)
58
+ when "T", "t"
59
+ handle_smooth_quadratic(cmd, state, smooth, context)
60
+ when "A", "a"
61
+ handle_arc(cmd, state, context)
62
+ when "Z", "z"
63
+ context.emitter.emit(Model::Operators::Path::Closepath.new)
64
+ state.close!
65
+ end
66
+ end
67
+
68
+ # ---- per-command handlers ----
69
+
70
+ def self.handle_moveto(cmd, state, smooth, context)
71
+ x, y = cmd.args
72
+ if cmd.opcode == "M"
73
+ state.move_to(x, y)
74
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: x, y: y))
75
+ else
76
+ state.move_to_rel(x, y)
77
+ context.emitter.emit(Model::Operators::Path::Rmoveto.new(dx: x, dy: y))
78
+ end
79
+ smooth.smoothable = false
80
+ end
81
+
82
+ def self.handle_lineto(cmd, state, context)
83
+ x, y = cmd.args
84
+ if cmd.opcode == "L"
85
+ state.line_to(x, y)
86
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: x, y: y))
87
+ else
88
+ state.line_to_rel(x, y)
89
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: x, dy: y))
90
+ end
91
+ end
92
+
93
+ def self.handle_hv(cmd, state, context, horizontal:)
94
+ v = cmd.args[0]
95
+ if cmd.opcode.upcase == cmd.opcode # absolute
96
+ if horizontal
97
+ new_x = v
98
+ state.line_to(new_x, state.current_y)
99
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: new_x, y: state.current_y))
100
+ else
101
+ new_y = v
102
+ state.line_to(state.current_x, new_y)
103
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: state.current_x, y: new_y))
104
+ end
105
+ elsif horizontal
106
+ state.line_to_rel(v, 0)
107
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: v, dy: 0))
108
+ else
109
+ state.line_to_rel(0, v)
110
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: 0, dy: v))
111
+ end
112
+ end
113
+
114
+ def self.handle_cubic(cmd, state, smooth, context)
115
+ args = cmd.args
116
+ x1, y1, x2, y2, x3, y3 = adapt_curve_args(args, cmd.opcode, state)
117
+ state.curve_to(x3, y3)
118
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
119
+ x1: x1, y1: y1, x2: x2, y2: y2, x3: x3, y3: y3,
120
+ ))
121
+ smooth.control_x = x2
122
+ smooth.control_y = y2
123
+ smooth.smoothable = true
124
+ end
125
+
126
+ def self.handle_smooth_cubic(cmd, state, smooth, context)
127
+ x2, y2, x3, y3 = cmd.args
128
+ if cmd.opcode == "S"
129
+ x1, y1 = reflect_control(smooth, state)
130
+ state.curve_to(x3, y3)
131
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
132
+ x1: x1, y1: y1, x2: x2, y2: y2, x3: x3, y3: y3,
133
+ ))
134
+ else
135
+ dx1, dy1 = reflect_control_rel(smooth, state)
136
+ state.curve_to_rel(dx2: x2, dy2: y2, dx3: x3, dy3: y3, dx1: dx1, dy1: dy1)
137
+ context.emitter.emit(Model::Operators::Path::Rcurveto.new(
138
+ dx1: dx1, dy1: dy1, dx2: x2, dy2: y2, dx3: x3, dy3: y3,
139
+ ))
140
+ x2 = state.current_x - x2
141
+ y2 = state.current_y - y2
142
+ end
143
+ smooth.control_x = state.current_x + (x2 - state.current_x)
144
+ smooth.control_y = state.current_y + (y2 - state.current_y)
145
+ smooth.smoothable = true
146
+ end
147
+
148
+ def self.handle_quadratic(cmd, state, smooth, context)
149
+ args = cmd.args
150
+ qx, qy, x3, y3 = adapt_quadratic_args(args, cmd.opcode, state)
151
+ # Convert quadratic to cubic: c1 = p0 + 2/3*(q-p0),
152
+ # c2 = p3 + 2/3*(q-p3).
153
+ p0x, p0y = state.current_x, state.current_y
154
+ c1x = p0x + (2.0 / 3.0) * (qx - p0x)
155
+ c1y = p0y + (2.0 / 3.0) * (qy - p0y)
156
+ c2x = x3 + (2.0 / 3.0) * (qx - x3)
157
+ c2y = y3 + (2.0 / 3.0) * (qy - y3)
158
+ state.curve_to(x3, y3)
159
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
160
+ x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: x3, y3: y3,
161
+ ))
162
+ smooth.control_x = qx
163
+ smooth.control_y = qy
164
+ smooth.smoothable = true
165
+ end
166
+
167
+ def self.handle_smooth_quadratic(cmd, state, smooth, context)
168
+ x3, y3 = cmd.args
169
+ qx, qy =
170
+ if smooth.smoothable
171
+ [reflect_x(smooth.control_x, state), reflect_y(smooth.control_y, state)]
172
+ else
173
+ [state.current_x, state.current_y]
174
+ end
175
+ p0x, p0y = state.current_x, state.current_y
176
+ c1x = p0x + (2.0 / 3.0) * (qx - p0x)
177
+ c1y = p0y + (2.0 / 3.0) * (qy - p0y)
178
+ c2x = x3 + (2.0 / 3.0) * (qx - x3)
179
+ c2y = y3 + (2.0 / 3.0) * (qy - y3)
180
+ if cmd.opcode == "T"
181
+ state.curve_to(x3, y3)
182
+ else
183
+ state.curve_to_rel(x3 - p0x, y3 - p0y) # treat as rel target
184
+ # Re-anchor to absolute target for emission below.
185
+ state.current_x = p0x + x3
186
+ state.current_y = p0y + y3
187
+ end
188
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
189
+ x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: state.current_x, y3: state.current_y,
190
+ ))
191
+ smooth.control_x = qx
192
+ smooth.control_y = qy
193
+ smooth.smoothable = true
194
+ end
195
+
196
+ # Convert SVG elliptical arc to PS arc(s). SVG arc parametrizes
197
+ # by endpoint + flags; PS arc is center-based. Use the
198
+ # W3C-endorsed endpoint-to-center conversion.
199
+ def self.handle_arc(cmd, state, context)
200
+ rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y = cmd.args
201
+ cx, cy, normalized_rx, normalized_ry, psi, theta1, theta2 =
202
+ Postsvg::Translation::ArcConverter.endpoint_to_center(
203
+ x1: state.current_x, y1: state.current_y,
204
+ rx: rx, ry: ry, x_axis_rotation: x_axis_rotation,
205
+ large_arc: large_arc_flag != 0, sweep: sweep_flag != 0,
206
+ x2: x, y2: y,
207
+ )
208
+ # Emit arc with center, radius, angle range. For elliptical
209
+ # arcs (rx != ry or non-zero rotation), emit scale/rotate
210
+ # transforms around the arc.
211
+ if x_axis_rotation.abs > 1e-6 || (rx - ry).abs > 1e-6
212
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
213
+ context.emitter.emit(Model::Operators::Transformations::Concat.new(matrix: [
214
+ Math.cos(psi), Math.sin(psi), -Math.sin(psi), Math.cos(psi), cx, cy,
215
+ ]))
216
+ context.emitter.emit(Model::Operators::Transformations::Scale.new(sx: normalized_rx, sy: normalized_ry))
217
+ context.emitter.emit(Model::Operators::Path::Arc.new(
218
+ x: 0, y: 0, radius: 1.0, angle1: theta1, angle2: theta2,
219
+ ))
220
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
221
+ else
222
+ context.emitter.emit(Model::Operators::Path::Arc.new(
223
+ x: cx, y: cy, radius: normalized_rx, angle1: theta1, angle2: theta2,
224
+ ))
225
+ end
226
+ state.move_to(x, y)
227
+ end
228
+
229
+ # ---- helpers ----
230
+
231
+ def self.adapt_curve_args(args, opcode, state)
232
+ return args if opcode.upcase == opcode
233
+
234
+ # Relative: dxN/dyN become xN = current + dxN
235
+ x1 = state.current_x + args[0]
236
+ y1 = state.current_y + args[1]
237
+ x2 = state.current_x + args[2]
238
+ y2 = state.current_y + args[3]
239
+ x3 = state.current_x + args[4]
240
+ y3 = state.current_y + args[5]
241
+ [x1, y1, x2, y2, x3, y3]
242
+ end
243
+
244
+ def self.adapt_quadratic_args(args, opcode, state)
245
+ return args if opcode.upcase == opcode
246
+
247
+ qx = state.current_x + args[0]
248
+ qy = state.current_y + args[1]
249
+ x3 = state.current_x + args[2]
250
+ y3 = state.current_y + args[3]
251
+ [qx, qy, x3, y3]
252
+ end
253
+
254
+ def self.reflect_control(smooth, state)
255
+ return [state.current_x, state.current_y] unless smooth.smoothable
256
+
257
+ [reflect_x(smooth.control_x, state), reflect_y(smooth.control_y, state)]
258
+ end
259
+
260
+ def self.reflect_control_rel(smooth, state)
261
+ abs_x, abs_y = reflect_control(smooth, state)
262
+ [abs_x - state.current_x, abs_y - state.current_y]
263
+ end
264
+
265
+ def self.reflect_x(control_x, state)
266
+ 2 * state.current_x - control_x
267
+ end
268
+
269
+ def self.reflect_y(control_y, state)
270
+ 2 * state.current_y - control_y
271
+ end
272
+
273
+ # Mutable path-tracking state shared across command handlers.
274
+ # Tracks current pen position + start-of-subpath (for Z) +
275
+ # accumulated bbox vertices.
276
+ class PathState
277
+ attr_reader :current_x, :current_y, :start_x, :start_y, :bbox_points
278
+
279
+ def initialize
280
+ @current_x = 0.0
281
+ @current_y = 0.0
282
+ @start_x = nil
283
+ @start_y = nil
284
+ @bbox_points = []
285
+ end
286
+
287
+ def move_to(x, y)
288
+ @current_x = x.to_f
289
+ @current_y = y.to_f
290
+ @start_x ||= @current_x
291
+ @start_y ||= @current_y
292
+ track(x, y)
293
+ end
294
+
295
+ def move_to_rel(dx, dy)
296
+ @current_x += dx.to_f
297
+ @current_y += dy.to_f
298
+ @start_x ||= @current_x
299
+ @start_y ||= @current_y
300
+ track(@current_x, @current_y)
301
+ end
302
+
303
+ def line_to(x, y)
304
+ @current_x = x.to_f
305
+ @current_y = y.to_f
306
+ track(x, y)
307
+ end
308
+
309
+ def line_to_rel(dx, dy)
310
+ @current_x += dx.to_f
311
+ @current_y += dy.to_f
312
+ track(@current_x, @current_y)
313
+ end
314
+
315
+ def curve_to(x3, y3)
316
+ @current_x = x3.to_f
317
+ @current_y = y3.to_f
318
+ track(x3, y3)
319
+ end
320
+
321
+ def curve_to_rel(dx1:, dy1:, dx2:, dy2:, dx3:, dy3:)
322
+ # We don't track c1/c2 — only the endpoint enters bbox.
323
+ @current_x += dx3.to_f
324
+ @current_y += dy3.to_f
325
+ track(@current_x, @current_y)
326
+ end
327
+
328
+ def close!
329
+ if @start_x
330
+ @current_x = @start_x
331
+ @current_y = @start_y
332
+ end
333
+ @start_x = nil
334
+ @start_y = nil
335
+ end
336
+
337
+ def current_x
338
+ @current_x
339
+ end
340
+
341
+ def current_y
342
+ @current_y
343
+ end
344
+
345
+ private
346
+
347
+ def track(x, y)
348
+ @bbox_points << x
349
+ @bbox_points << y
350
+ end
351
+ end
352
+ private_constant :PathState
353
+ end
354
+ end
355
+ end
356
+ end