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,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,36 @@
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(
21
+ tx: cx, ty: cy,
22
+ ))
23
+ context.emitter.emit(Model::Operators::Transformations::Scale.new(
24
+ sx: scale, sy: 1,
25
+ ))
26
+ context.emitter.emit(Model::Operators::Path::Arc.new(x: 0, y: 0,
27
+ radius: ry, angle1: 0, angle2: 360))
28
+ end
29
+ emit_paint(element, context)
30
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
31
+ expand_bbox(context, cx - rx, cy - ry, cx + rx, cy + ry)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
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,
15
+ context)
16
+ end
17
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
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
+ element.href || "(none)"
14
+ context.emitter.emit(Model::UnknownOperator.new(keyword: "image"))
15
+ expand_bbox(context, element.x, element.y, element.x + element.width,
16
+ element.y + element.height)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
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(
15
+ x: element.x1, y: element.y1,
16
+ ))
17
+ context.emitter.emit(Model::Operators::Path::Lineto.new(
18
+ x: element.x2, y: element.y2,
19
+ ))
20
+ context.emitter.emit(Model::Operators::Painting::Stroke.new)
21
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
22
+ expand_bbox(context, element.x1, element.y1, element.x2, element.y2)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
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,
13
+ context)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,372 @@
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,
26
+ keyword_init: true)
27
+
28
+ def self.call(element, context)
29
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
30
+ emit_transform(element, context)
31
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
32
+
33
+ state = PathState.new
34
+ smooth = SmoothState.new(control_x: nil, control_y: nil,
35
+ smoothable: false)
36
+ element.commands.each do |cmd|
37
+ dispatch_command(cmd, state, smooth, context)
38
+ end
39
+ emit_paint(element, context)
40
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
41
+ expand_bbox(context, *state.bbox_points) if state.bbox_points.any?
42
+ end
43
+
44
+ def self.dispatch_command(cmd, state, smooth, context)
45
+ case cmd.opcode
46
+ when "M", "m"
47
+ handle_moveto(cmd, state, smooth, context)
48
+ when "L", "l"
49
+ handle_lineto(cmd, state, context)
50
+ when "H", "h"
51
+ handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "H")
52
+ when "V", "v"
53
+ handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "V")
54
+ when "C", "c"
55
+ handle_cubic(cmd, state, smooth, context)
56
+ when "S", "s"
57
+ handle_smooth_cubic(cmd, state, smooth, context)
58
+ when "Q", "q"
59
+ handle_quadratic(cmd, state, smooth, context)
60
+ when "T", "t"
61
+ handle_smooth_quadratic(cmd, state, smooth, context)
62
+ when "A", "a"
63
+ handle_arc(cmd, state, context)
64
+ when "Z", "z"
65
+ context.emitter.emit(Model::Operators::Path::Closepath.new)
66
+ state.close!
67
+ end
68
+ end
69
+
70
+ # ---- per-command handlers ----
71
+
72
+ def self.handle_moveto(cmd, state, smooth, context)
73
+ x, y = cmd.args
74
+ if cmd.opcode == "M"
75
+ state.move_to(x, y)
76
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: x, y: y))
77
+ else
78
+ state.move_to_rel(x, y)
79
+ context.emitter.emit(Model::Operators::Path::Rmoveto.new(dx: x,
80
+ dy: y))
81
+ end
82
+ smooth.smoothable = false
83
+ end
84
+
85
+ def self.handle_lineto(cmd, state, context)
86
+ x, y = cmd.args
87
+ if cmd.opcode == "L"
88
+ state.line_to(x, y)
89
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: x, y: y))
90
+ else
91
+ state.line_to_rel(x, y)
92
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: x,
93
+ dy: y))
94
+ end
95
+ end
96
+
97
+ def self.handle_hv(cmd, state, context, horizontal:)
98
+ v = cmd.args[0]
99
+ if cmd.opcode.upcase == cmd.opcode # absolute
100
+ if horizontal
101
+ new_x = v
102
+ state.line_to(new_x, state.current_y)
103
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: new_x,
104
+ y: state.current_y))
105
+ else
106
+ new_y = v
107
+ state.line_to(state.current_x, new_y)
108
+ context.emitter.emit(Model::Operators::Path::Lineto.new(
109
+ x: state.current_x, y: new_y,
110
+ ))
111
+ end
112
+ elsif horizontal
113
+ state.line_to_rel(v, 0)
114
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: v,
115
+ dy: 0))
116
+ else
117
+ state.line_to_rel(0, v)
118
+ context.emitter.emit(Model::Operators::Path::Rlineto.new(dx: 0,
119
+ dy: v))
120
+ end
121
+ end
122
+
123
+ def self.handle_cubic(cmd, state, smooth, context)
124
+ args = cmd.args
125
+ x1, y1, x2, y2, x3, y3 = adapt_curve_args(args, cmd.opcode, state)
126
+ state.curve_to(x3, y3)
127
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
128
+ x1: x1, y1: y1, x2: x2, y2: y2, x3: x3, y3: y3,
129
+ ))
130
+ smooth.control_x = x2
131
+ smooth.control_y = y2
132
+ smooth.smoothable = true
133
+ end
134
+
135
+ def self.handle_smooth_cubic(cmd, state, smooth, context)
136
+ x2, y2, x3, y3 = cmd.args
137
+ if cmd.opcode == "S"
138
+ x1, y1 = reflect_control(smooth, state)
139
+ state.curve_to(x3, y3)
140
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
141
+ x1: x1, y1: y1, x2: x2, y2: y2, x3: x3, y3: y3,
142
+ ))
143
+ else
144
+ dx1, dy1 = reflect_control_rel(smooth, state)
145
+ state.curve_to_rel(dx2: x2, dy2: y2, dx3: x3, dy3: y3, dx1: dx1,
146
+ dy1: dy1)
147
+ context.emitter.emit(Model::Operators::Path::Rcurveto.new(
148
+ dx1: dx1, dy1: dy1, dx2: x2, dy2: y2, dx3: x3, dy3: y3,
149
+ ))
150
+ x2 = state.current_x - x2
151
+ y2 = state.current_y - y2
152
+ end
153
+ smooth.control_x = state.current_x + (x2 - state.current_x)
154
+ smooth.control_y = state.current_y + (y2 - state.current_y)
155
+ smooth.smoothable = true
156
+ end
157
+
158
+ def self.handle_quadratic(cmd, state, smooth, context)
159
+ args = cmd.args
160
+ qx, qy, x3, y3 = adapt_quadratic_args(args, cmd.opcode, state)
161
+ # Convert quadratic to cubic: c1 = p0 + 2/3*(q-p0),
162
+ # c2 = p3 + 2/3*(q-p3).
163
+ p0x = state.current_x
164
+ p0y = state.current_y
165
+ c1x = p0x + ((2.0 / 3.0) * (qx - p0x))
166
+ c1y = p0y + ((2.0 / 3.0) * (qy - p0y))
167
+ c2x = x3 + ((2.0 / 3.0) * (qx - x3))
168
+ c2y = y3 + ((2.0 / 3.0) * (qy - y3))
169
+ state.curve_to(x3, y3)
170
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
171
+ x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: x3, y3: y3,
172
+ ))
173
+ smooth.control_x = qx
174
+ smooth.control_y = qy
175
+ smooth.smoothable = true
176
+ end
177
+
178
+ def self.handle_smooth_quadratic(cmd, state, smooth, context)
179
+ x3, y3 = cmd.args
180
+ qx, qy =
181
+ if smooth.smoothable
182
+ [reflect_x(smooth.control_x, state),
183
+ reflect_y(smooth.control_y, state)]
184
+ else
185
+ [state.current_x, state.current_y]
186
+ end
187
+ p0x = state.current_x
188
+ p0y = state.current_y
189
+ c1x = p0x + ((2.0 / 3.0) * (qx - p0x))
190
+ c1y = p0y + ((2.0 / 3.0) * (qy - p0y))
191
+ c2x = x3 + ((2.0 / 3.0) * (qx - x3))
192
+ c2y = y3 + ((2.0 / 3.0) * (qy - y3))
193
+ if cmd.opcode == "T"
194
+ state.curve_to(x3, y3)
195
+ else
196
+ state.curve_to_rel(x3 - p0x, y3 - p0y) # treat as rel target
197
+ # Re-anchor to absolute target for emission below.
198
+ state.current_x = p0x + x3
199
+ state.current_y = p0y + y3
200
+ end
201
+ context.emitter.emit(Model::Operators::Path::Curveto.new(
202
+ x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: state.current_x, y3: state.current_y,
203
+ ))
204
+ smooth.control_x = qx
205
+ smooth.control_y = qy
206
+ smooth.smoothable = true
207
+ end
208
+
209
+ # Convert SVG elliptical arc to PS arc(s). SVG arc parametrizes
210
+ # by endpoint + flags; PS arc is center-based. Use the
211
+ # W3C-endorsed endpoint-to-center conversion.
212
+ def self.handle_arc(cmd, state, context)
213
+ rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y = cmd.args
214
+ cx, cy, normalized_rx, normalized_ry, psi, theta1, theta2 =
215
+ Postsvg::Translation::ArcConverter.endpoint_to_center(
216
+ x1: state.current_x, y1: state.current_y,
217
+ rx: rx, ry: ry, x_axis_rotation: x_axis_rotation,
218
+ large_arc: large_arc_flag != 0, sweep: sweep_flag != 0,
219
+ x2: x, y2: y
220
+ )
221
+ # Emit arc with center, radius, angle range. For elliptical
222
+ # arcs (rx != ry or non-zero rotation), emit scale/rotate
223
+ # transforms around the arc.
224
+ if x_axis_rotation.abs > 1e-6 || (rx - ry).abs > 1e-6
225
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
226
+ context.emitter.emit(Model::Operators::Transformations::Concat.new(matrix: [
227
+ Math.cos(psi), Math.sin(psi), -Math.sin(psi), Math.cos(psi), cx, cy
228
+ ]))
229
+ context.emitter.emit(Model::Operators::Transformations::Scale.new(
230
+ sx: normalized_rx, sy: normalized_ry,
231
+ ))
232
+ context.emitter.emit(Model::Operators::Path::Arc.new(
233
+ x: 0, y: 0, radius: 1.0, angle1: theta1, angle2: theta2,
234
+ ))
235
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
236
+ else
237
+ context.emitter.emit(Model::Operators::Path::Arc.new(
238
+ x: cx, y: cy, radius: normalized_rx, angle1: theta1, angle2: theta2,
239
+ ))
240
+ end
241
+ state.move_to(x, y)
242
+ end
243
+
244
+ # ---- helpers ----
245
+
246
+ def self.adapt_curve_args(args, opcode, state)
247
+ return args if opcode.upcase == opcode
248
+
249
+ # Relative: dxN/dyN become xN = current + dxN
250
+ x1 = state.current_x + args[0]
251
+ y1 = state.current_y + args[1]
252
+ x2 = state.current_x + args[2]
253
+ y2 = state.current_y + args[3]
254
+ x3 = state.current_x + args[4]
255
+ y3 = state.current_y + args[5]
256
+ [x1, y1, x2, y2, x3, y3]
257
+ end
258
+
259
+ def self.adapt_quadratic_args(args, opcode, state)
260
+ return args if opcode.upcase == opcode
261
+
262
+ qx = state.current_x + args[0]
263
+ qy = state.current_y + args[1]
264
+ x3 = state.current_x + args[2]
265
+ y3 = state.current_y + args[3]
266
+ [qx, qy, x3, y3]
267
+ end
268
+
269
+ def self.reflect_control(smooth, state)
270
+ return [state.current_x, state.current_y] unless smooth.smoothable
271
+
272
+ [reflect_x(smooth.control_x, state),
273
+ reflect_y(smooth.control_y, state)]
274
+ end
275
+
276
+ def self.reflect_control_rel(smooth, state)
277
+ abs_x, abs_y = reflect_control(smooth, state)
278
+ [abs_x - state.current_x, abs_y - state.current_y]
279
+ end
280
+
281
+ def self.reflect_x(control_x, state)
282
+ (2 * state.current_x) - control_x
283
+ end
284
+
285
+ def self.reflect_y(control_y, state)
286
+ (2 * state.current_y) - control_y
287
+ end
288
+
289
+ # Mutable path-tracking state shared across command handlers.
290
+ # Tracks current pen position + start-of-subpath (for Z) +
291
+ # accumulated bbox vertices.
292
+ class PathState
293
+ attr_reader :current_x, :current_y, :start_x, :start_y, :bbox_points
294
+
295
+ def initialize
296
+ @current_x = 0.0
297
+ @current_y = 0.0
298
+ @start_x = nil
299
+ @start_y = nil
300
+ @bbox_points = []
301
+ end
302
+
303
+ def move_to(x, y)
304
+ @current_x = x.to_f
305
+ @current_y = y.to_f
306
+ @start_x ||= @current_x
307
+ @start_y ||= @current_y
308
+ track(x, y)
309
+ end
310
+
311
+ def move_to_rel(dx, dy)
312
+ @current_x += dx.to_f
313
+ @current_y += dy.to_f
314
+ @start_x ||= @current_x
315
+ @start_y ||= @current_y
316
+ track(@current_x, @current_y)
317
+ end
318
+
319
+ def line_to(x, y)
320
+ @current_x = x.to_f
321
+ @current_y = y.to_f
322
+ track(x, y)
323
+ end
324
+
325
+ def line_to_rel(dx, dy)
326
+ @current_x += dx.to_f
327
+ @current_y += dy.to_f
328
+ track(@current_x, @current_y)
329
+ end
330
+
331
+ def curve_to(x3, y3)
332
+ @current_x = x3.to_f
333
+ @current_y = y3.to_f
334
+ track(x3, y3)
335
+ end
336
+
337
+ def curve_to_rel(dx1:, dy1:, dx2:, dy2:, dx3:, dy3:)
338
+ # We don't track c1/c2 — only the endpoint enters bbox.
339
+ @current_x += dx3.to_f
340
+ @current_y += dy3.to_f
341
+ track(@current_x, @current_y)
342
+ end
343
+
344
+ def close!
345
+ if @start_x
346
+ @current_x = @start_x
347
+ @current_y = @start_y
348
+ end
349
+ @start_x = nil
350
+ @start_y = nil
351
+ end
352
+
353
+ def current_x
354
+ @current_x
355
+ end
356
+
357
+ def current_y
358
+ @current_y
359
+ end
360
+
361
+ private
362
+
363
+ def track(x, y)
364
+ @bbox_points << x
365
+ @bbox_points << y
366
+ end
367
+ end
368
+ private_constant :PathState
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ # SVG `<polygon>`: like `<polyline>` but the path is closed.
7
+ # Emits moveto + N×lineto + closepath + paint.
8
+ class PolygonHandler
9
+ extend Shared
10
+
11
+ def self.call(element, context)
12
+ context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
13
+ emit_transform(element, context)
14
+ context.emitter.emit(Model::Operators::Path::Newpath.new)
15
+ points = element.points
16
+ points.each_slice(2).with_index do |(x, y), idx|
17
+ if idx.zero?
18
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: x,
19
+ y: y))
20
+ else
21
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: x,
22
+ y: y))
23
+ end
24
+ end
25
+ context.emitter.emit(Model::Operators::Path::Closepath.new)
26
+ emit_paint(element, context)
27
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
28
+ xs = points.each_slice(2).map(&:first)
29
+ ys = points.each_slice(2).map(&:last)
30
+ expand_bbox(context, xs.min, ys.min, xs.max, ys.max) if xs.any?
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ module Handlers
6
+ class PolylineHandler
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
+ points = element.points
15
+ points.each_slice(2).with_index do |(x, y), idx|
16
+ if idx.zero?
17
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: x,
18
+ y: y))
19
+ else
20
+ context.emitter.emit(Model::Operators::Path::Lineto.new(x: x,
21
+ y: y))
22
+ end
23
+ end
24
+ context.emitter.emit(Model::Operators::Painting::Stroke.new)
25
+ context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
26
+ xs = points.each_slice(2).map(&:first)
27
+ ys = points.each_slice(2).map(&:last)
28
+ expand_bbox(context, xs.min, ys.min, xs.max, ys.max) if xs.any?
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end