postsvg 0.2.6 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +49 -533
  3. data/CLAUDE.md +3 -27
  4. data/Gemfile +2 -2
  5. data/README.adoc +4 -62
  6. data/Rakefile +0 -17
  7. data/lib/postsvg/cli.rb +5 -10
  8. data/lib/postsvg/color.rb +132 -0
  9. data/lib/postsvg/format_number.rb +22 -0
  10. data/lib/postsvg/graphics_context.rb +1 -2
  11. data/lib/postsvg/interpreter.rb +2 -1
  12. data/lib/postsvg/matrix.rb +106 -0
  13. data/lib/postsvg/model/literals/array.rb +41 -0
  14. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  15. data/lib/postsvg/model/literals/hex.rb +37 -0
  16. data/lib/postsvg/model/literals/name.rb +40 -0
  17. data/lib/postsvg/model/literals/number.rb +36 -0
  18. data/lib/postsvg/model/literals/procedure.rb +41 -0
  19. data/lib/postsvg/model/literals/string.rb +30 -0
  20. data/lib/postsvg/model/literals.rb +19 -0
  21. data/lib/postsvg/model/operator.rb +58 -0
  22. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  23. data/lib/postsvg/model/operators/boolean.rb +182 -0
  24. data/lib/postsvg/model/operators/color.rb +74 -0
  25. data/lib/postsvg/model/operators/container.rb +186 -0
  26. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  27. data/lib/postsvg/model/operators/device.rb +21 -0
  28. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  29. data/lib/postsvg/model/operators/font.rb +121 -0
  30. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  31. data/lib/postsvg/model/operators/painting.rb +29 -0
  32. data/lib/postsvg/model/operators/path.rb +169 -0
  33. data/lib/postsvg/model/operators/stack.rb +72 -0
  34. data/lib/postsvg/model/operators/transformations.rb +103 -0
  35. data/lib/postsvg/model/operators.rb +89 -0
  36. data/lib/postsvg/model/program.rb +68 -0
  37. data/lib/postsvg/model/token.rb +43 -0
  38. data/lib/postsvg/model.rb +17 -0
  39. data/lib/postsvg/options.rb +1 -1
  40. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  41. data/lib/postsvg/renderer.rb +3 -7
  42. data/lib/postsvg/serializer.rb +325 -0
  43. data/lib/postsvg/source/ast_builder.rb +308 -0
  44. data/lib/postsvg/source/lexer.rb +322 -0
  45. data/lib/postsvg/source/operand_stack.rb +55 -0
  46. data/lib/postsvg/source.rb +21 -0
  47. data/lib/postsvg/svg/clip_path_registry.rb +1 -3
  48. data/lib/postsvg/svg/element.rb +2 -4
  49. data/lib/postsvg/svg/elements/ellipse.rb +1 -2
  50. data/lib/postsvg/svg/elements/svg.rb +1 -2
  51. data/lib/postsvg/svg/paint.rb +1 -1
  52. data/lib/postsvg/svg/parser.rb +3 -3
  53. data/lib/postsvg/svg/path_data/parser.rb +6 -10
  54. data/lib/postsvg/svg/stroke.rb +2 -4
  55. data/lib/postsvg/svg/transform_list.rb +1 -1
  56. data/lib/postsvg/svg.rb +1 -1
  57. data/lib/postsvg/svg_builder.rb +11 -15
  58. data/lib/postsvg/translation/arc_converter.rb +14 -23
  59. data/lib/postsvg/translation/bounding_box.rb +4 -7
  60. data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
  61. data/lib/postsvg/translation/handlers/ellipse_handler.rb +3 -8
  62. data/lib/postsvg/translation/handlers/group_handler.rb +1 -2
  63. data/lib/postsvg/translation/handlers/image_handler.rb +2 -3
  64. data/lib/postsvg/translation/handlers/line_handler.rb +2 -6
  65. data/lib/postsvg/translation/handlers/open_handler.rb +2 -3
  66. data/lib/postsvg/translation/handlers/path_handler.rb +60 -82
  67. data/lib/postsvg/translation/handlers/polygon_handler.rb +2 -4
  68. data/lib/postsvg/translation/handlers/polyline_handler.rb +4 -5
  69. data/lib/postsvg/translation/handlers/rect_handler.rb +5 -10
  70. data/lib/postsvg/translation/handlers/shared.rb +11 -11
  71. data/lib/postsvg/translation/handlers/svg_handler.rb +2 -3
  72. data/lib/postsvg/translation/handlers/text_handler.rb +8 -17
  73. data/lib/postsvg/translation/handlers.rb +1 -2
  74. data/lib/postsvg/translation/ps_renderer.rb +4 -18
  75. data/lib/postsvg/translation/record_emitter.rb +0 -54
  76. data/lib/postsvg/version.rb +1 -1
  77. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
  78. data/lib/postsvg/visitors/ps_visitor/color.rb +5 -8
  79. data/lib/postsvg/visitors/ps_visitor/common.rb +2 -1
  80. data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
  81. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
  82. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +4 -8
  83. data/lib/postsvg/visitors/ps_visitor/font.rb +4 -15
  84. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +5 -7
  85. data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
  86. data/lib/postsvg/visitors/ps_visitor/path.rb +33 -52
  87. data/lib/postsvg/visitors/ps_visitor/transformations.rb +7 -45
  88. data/lib/postsvg/visitors/ps_visitor.rb +2 -18
  89. data/lib/postsvg.rb +38 -43
  90. metadata +39 -20
  91. data/gemfiles/native-parity/Gemfile +0 -8
@@ -17,8 +17,7 @@ module Postsvg
17
17
  autoload :TextHandler, "postsvg/translation/handlers/text_handler"
18
18
  autoload :ImageHandler, "postsvg/translation/handlers/image_handler"
19
19
  autoload :DefsHandler, "postsvg/translation/handlers/defs_handler"
20
- autoload :ClipPathHandler,
21
- "postsvg/translation/handlers/clip_path_handler"
20
+ autoload :ClipPathHandler, "postsvg/translation/handlers/clip_path_handler"
22
21
  autoload :OpenHandler, "postsvg/translation/handlers/open_handler"
23
22
  autoload :Shared, "postsvg/translation/handlers/shared"
24
23
  end
@@ -72,12 +72,10 @@ module Postsvg
72
72
 
73
73
  def compute_viewport_bbox
74
74
  if document.viewbox
75
- x, y, width, height = document.viewbox
76
- BoundingBox.new(min_x: x, min_y: y, max_x: x + width,
77
- max_y: y + height)
75
+ llx, lly, urx, ury = document.viewbox
76
+ BoundingBox.new(min_x: llx, min_y: lly, max_x: urx, max_y: ury)
78
77
  elsif document.width && document.height
79
- BoundingBox.new(min_x: 0, min_y: 0, max_x: document.width,
80
- max_y: document.height)
78
+ BoundingBox.new(min_x: 0, min_y: 0, max_x: document.width, max_y: document.height)
81
79
  else
82
80
  BoundingBox.empty
83
81
  end
@@ -87,17 +85,6 @@ module Postsvg
87
85
  registry.translate(element, context)
88
86
  end
89
87
 
90
- # SVG's y axis points down; PostScript's points up. Convert once at
91
- # the document boundary, before any authored element transforms.
92
- def coordinate_conversion(bbox)
93
- emitter = RecordEmitter.new
94
- emitter.emit(Model::Operators::Transformations::Translate.new(
95
- tx: 0, ty: bbox[3] - bbox[1],
96
- ))
97
- emitter.emit(Model::Operators::Transformations::Scale.new(sx: 1, sy: -1))
98
- emitter.records
99
- end
100
-
101
88
  def build_program(emitter, context)
102
89
  bbox = context.bounding_box
103
90
  header_bbox =
@@ -111,8 +98,7 @@ module Postsvg
111
98
  epsf: eps,
112
99
  title: "Translated by Postsvg #{::Postsvg::VERSION}",
113
100
  )
114
- Model::Program.new(header: header,
115
- body: coordinate_conversion(header_bbox) + emitter.records)
101
+ Model::Program.new(header: header, body: emitter.records)
116
102
  end
117
103
  end
118
104
  end
@@ -7,54 +7,6 @@ module Postsvg
7
7
  # surface so handlers don't all need to know how the program is
8
8
  # stored.
9
9
  class RecordEmitter
10
- NUMBER_OPERANDS = lambda do |record|
11
- record.operand_values.map { |value| Model::Literals::Number.new(value) }
12
- end
13
- NO_OPERANDS = ->(_record) { [] }
14
-
15
- OPERAND_BUILDERS = {
16
- Model::Operators::Color::Setgray => NUMBER_OPERANDS,
17
- Model::Operators::Color::Setrgbcolor => NUMBER_OPERANDS,
18
- Model::Operators::GraphicsState::Setlinewidth => NUMBER_OPERANDS,
19
- Model::Operators::Path::Moveto => NUMBER_OPERANDS,
20
- Model::Operators::Path::Rmoveto => lambda do |record|
21
- [Model::Literals::Number.new(record.dx), Model::Literals::Number.new(record.dy)]
22
- end,
23
- Model::Operators::Path::Lineto => NUMBER_OPERANDS,
24
- Model::Operators::Path::Rlineto => NUMBER_OPERANDS,
25
- Model::Operators::Path::Curveto => NUMBER_OPERANDS,
26
- Model::Operators::Path::Rcurveto => NUMBER_OPERANDS,
27
- Model::Operators::Path::Arc => NUMBER_OPERANDS,
28
- Model::Operators::Path::Arcn => NUMBER_OPERANDS,
29
- Model::Operators::Transformations::Translate => NUMBER_OPERANDS,
30
- Model::Operators::Transformations::Scale => NUMBER_OPERANDS,
31
- Model::Operators::Font::Findfont => lambda do |record|
32
- # findfont accepts strings as well as names; whitespace must not
33
- # split a family name into separate PostScript tokens.
34
- if record.name.match?(/\s/)
35
- [Model::Literals::StringLiteral.new(record.name)]
36
- else
37
- [Model::Literals::Name.new(record.name, literal: true)]
38
- end
39
- end,
40
- Model::Operators::Font::Scalefont => lambda do |record|
41
- [Model::Literals::Number.new(record.size)]
42
- end,
43
- Model::Operators::Font::Show => lambda do |record|
44
- [Model::Literals::StringLiteral.new(record.text)]
45
- end,
46
- Model::Operators::GraphicsState::Setlinecap => lambda do |record|
47
- [Model::Literals::Number.new(record.cap_code)]
48
- end,
49
- Model::Operators::GraphicsState::Setlinejoin => lambda do |record|
50
- [Model::Literals::Number.new(record.join_code)]
51
- end,
52
- Model::Operators::Transformations::Concat => lambda do |record|
53
- elements = record.matrix.map { |value| Model::Literals::Number.new(value) }
54
- [Model::Literals::ArrayLiteral.new(elements)]
55
- end,
56
- }.freeze
57
-
58
10
  attr_reader :records
59
11
 
60
12
  def initialize
@@ -62,12 +14,6 @@ module Postsvg
62
14
  end
63
15
 
64
16
  def emit(record)
65
- # Operators built by the svg -> ps handlers carry their
66
- # operands as attributes; the serializer writes keywords only
67
- # and expects the operands as separate literal statements
68
- # ahead of the keyword (the shape the PS parse path produces).
69
- # Splice them in so the record stream round-trips.
70
- @records.concat(OPERAND_BUILDERS.fetch(record.class, NO_OPERANDS).call(record))
71
17
  @records << record
72
18
  self
73
19
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Postsvg
4
- VERSION = "0.2.6"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -12,19 +12,19 @@ module Postsvg
12
12
  def visit_add(_op, _ctx)
13
13
  b = pop_runtime_number
14
14
  a = pop_runtime_number
15
- @stack << (a + b)
15
+ @stack << a + b
16
16
  end
17
17
 
18
18
  def visit_sub(_op, _ctx)
19
19
  b = pop_runtime_number
20
20
  a = pop_runtime_number
21
- @stack << (a - b)
21
+ @stack << a - b
22
22
  end
23
23
 
24
24
  def visit_mul(_op, _ctx)
25
25
  b = pop_runtime_number
26
26
  a = pop_runtime_number
27
- @stack << (a * b)
27
+ @stack << a * b
28
28
  end
29
29
 
30
30
  def visit_div(_op, _ctx)
@@ -32,7 +32,7 @@ module Postsvg
32
32
  a = pop_runtime_number
33
33
  raise RenderError, "division by zero" if divisor.zero?
34
34
 
35
- @stack << (a.to_f / divisor)
35
+ @stack << (a.to_f / divisor.to_f)
36
36
  end
37
37
 
38
38
  def visit_idiv(_op, _ctx)
@@ -85,7 +85,7 @@ module Postsvg
85
85
  def visit_atan(_op, _ctx)
86
86
  b = pop_runtime_number
87
87
  a = pop_runtime_number
88
- @stack << (Math.atan2(a.to_f, b.to_f) * (180.0 / Math::PI))
88
+ @stack << Math.atan2(a.to_f, b.to_f) * (180.0 / Math::PI)
89
89
  end
90
90
 
91
91
  def visit_cos(_op, _ctx)
@@ -107,7 +107,7 @@ module Postsvg
107
107
  def visit_exp(_op, _ctx)
108
108
  b = pop_runtime_number
109
109
  a = pop_runtime_number
110
- @stack << (a.to_f**b.to_f)
110
+ @stack << a.to_f**b.to_f
111
111
  end
112
112
 
113
113
  private
@@ -7,25 +7,22 @@ module Postsvg
7
7
  # GraphicsContext; the next paint operator reads the new colors.
8
8
  module Color
9
9
  def visit_setgray(op, _ctx)
10
- color = Postscript::Color.gray(op.gray.to_f)
10
+ color = Postsvg::Color.gray(op.gray.to_f)
11
11
  @graphics.update(fill_color: color, stroke_color: color)
12
12
  end
13
13
 
14
14
  def visit_setrgbcolor(op, _ctx)
15
- color = Postscript::Color.rgb(op.red.to_f, op.green.to_f,
16
- op.blue.to_f)
15
+ color = Postsvg::Color.rgb(op.red.to_f, op.green.to_f, op.blue.to_f)
17
16
  @graphics.update(fill_color: color, stroke_color: color)
18
17
  end
19
18
 
20
19
  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)
20
+ color = Postsvg::Color.cmyk(op.cyan.to_f, op.magenta.to_f, op.yellow.to_f, op.key.to_f)
23
21
  @graphics.update(fill_color: color, stroke_color: color)
24
22
  end
25
23
 
26
24
  def visit_sethsbcolor(op, _ctx)
27
- color = hsb_to_color(op.hue.to_f, op.saturation.to_f,
28
- op.brightness.to_f)
25
+ color = hsb_to_color(op.hue.to_f, op.saturation.to_f, op.brightness.to_f)
29
26
  @graphics.update(fill_color: color, stroke_color: color)
30
27
  end
31
28
 
@@ -48,7 +45,7 @@ module Postsvg
48
45
  when 4 then [t, p, v]
49
46
  else [v, p, q]
50
47
  end
51
- Postscript::Color.rgb(r, g, b)
48
+ Postsvg::Color.rgb(r, g, b)
52
49
  end
53
50
  end
54
51
  end
@@ -13,7 +13,7 @@ module Postsvg
13
13
  def normalize_key(key)
14
14
  case key
15
15
  when Model::Literals::Name then key.value
16
- when String then key.to_s.delete_prefix("/")
16
+ when String then key.to_s.sub(/\A\//, "")
17
17
  else key.to_s
18
18
  end
19
19
  end
@@ -25,6 +25,7 @@ module Postsvg
25
25
  when String then node
26
26
  when Model::Literals::StringLiteral then node.value
27
27
  when Model::Literals::HexLiteral then node.bytes
28
+ else nil
28
29
  end
29
30
  end
30
31
 
@@ -44,25 +44,25 @@ module Postsvg
44
44
  @stack << value
45
45
  end
46
46
 
47
- def visit_put(_op, _ctx)
47
+ def visit_put(op, _ctx)
48
48
  # PS put mutates in place. Our value objects are immutable.
49
49
  # Marked as a comment in the output so the gap is visible.
50
50
  @builder.comment("put: in-place mutation not supported")
51
51
  end
52
52
 
53
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
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
66
  end
67
67
 
68
68
  def visit_putinterval(_op, _ctx)
@@ -39,7 +39,7 @@ module Postsvg
39
39
  body = @stack.pop
40
40
  LOOP_LIMIT.times do
41
41
  descend_into_procedure(body)
42
- rescue Postscript::ExitSignal
42
+ rescue Postsvg::ExitSignal
43
43
  break
44
44
  end
45
45
  end
@@ -74,11 +74,11 @@ module Postsvg
74
74
  end
75
75
 
76
76
  def visit_exit(_op, _ctx)
77
- raise Postscript::ExitSignal
77
+ raise Postsvg::ExitSignal
78
78
  end
79
79
 
80
80
  def visit_quit(_op, _ctx)
81
- raise Postscript::QuitSignal
81
+ raise Postsvg::QuitSignal
82
82
  end
83
83
 
84
84
  def visit_exec(_op, _ctx)
@@ -72,14 +72,10 @@ module Postsvg
72
72
 
73
73
  def visit_maxlength(op, _ctx)
74
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
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
83
79
  end
84
80
  end
85
81
 
@@ -31,26 +31,15 @@ module Postsvg
31
31
  end
32
32
 
33
33
  def visit_scalefont(op, _ctx)
34
- size = op.size.to_i
35
- consume_numeric_operands(op.size)
34
+ size = op.size.to_i || op.size.to_f
36
35
  font = @stack.pop
37
- base = if font.is_a?(FontRef)
38
- font
39
- else
40
- FontRef.new(name: "Helvetica",
41
- size: nil)
42
- end
36
+ base = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: nil)
43
37
  @stack << FontRef.new(name: base.name, size: size.to_f)
44
38
  end
45
39
 
46
40
  def visit_setfont(_op, _ctx)
47
41
  font = @stack.pop
48
- ref = if font.is_a?(FontRef)
49
- font
50
- else
51
- FontRef.new(name: "Helvetica",
52
- size: 12.0)
53
- end
42
+ ref = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: 12.0)
54
43
  @graphics.update(font_name: ref.name || "Helvetica",
55
44
  font_size: ref.size || 12.0)
56
45
  end
@@ -78,7 +67,7 @@ module Postsvg
78
67
  y: -screen_pos[:y],
79
68
  font_family: @graphics.current.font_name,
80
69
  font_size: @graphics.current.font_size,
81
- color: color || Postscript::Color::BLACK,
70
+ color: color || Postsvg::Color::BLACK,
82
71
  transform: "scale(1 -1)",
83
72
  )
84
73
  @path.reset
@@ -7,17 +7,15 @@ module Postsvg
7
7
  # dash attribute setters.
8
8
  module GraphicsState
9
9
  def visit_gsave(_op, _ctx)
10
- @saved_paths << @path.dup
11
10
  @graphics.gsave
12
11
  end
13
12
 
14
13
  def visit_grestore(_op, _ctx)
15
- @path = @saved_paths.pop if @graphics.depth > 1 && !@saved_paths.empty?
16
14
  @graphics.grestore
17
15
  end
18
16
 
19
17
  def visit_grestoreall(_op, _ctx)
20
- visit_grestore(nil, nil) while @graphics.depth > 1
18
+ @graphics.grestore while @graphics.depth > 1
21
19
  end
22
20
 
23
21
  def visit_setlinewidth(op, _ctx)
@@ -25,14 +23,12 @@ module Postsvg
25
23
  end
26
24
 
27
25
  def visit_setlinecap(op, _ctx)
28
- name = { 0 => :butt, 1 => :round, 2 => :square }.fetch(op.cap_code,
29
- :butt)
26
+ name = { 0 => :butt, 1 => :round, 2 => :square }.fetch(op.cap_code, :butt)
30
27
  @graphics.update(line_cap: name)
31
28
  end
32
29
 
33
30
  def visit_setlinejoin(op, _ctx)
34
- name = { 0 => :miter, 1 => :round, 2 => :bevel }.fetch(op.join_code,
35
- :miter)
31
+ name = { 0 => :miter, 1 => :round, 2 => :bevel }.fetch(op.join_code, :miter)
36
32
  @graphics.update(line_join: name)
37
33
  end
38
34
 
@@ -48,6 +44,8 @@ module Postsvg
48
44
  pattern.map { |v| FormatNumber.call(v.to_f) }.join(" ")
49
45
  when Numeric
50
46
  FormatNumber.call(pattern.to_f)
47
+ else
48
+ nil
51
49
  end
52
50
  @graphics.update(dash: dash_str)
53
51
  end
@@ -25,7 +25,7 @@ module Postsvg
25
25
  def visit_clip(_op, _ctx)
26
26
  return if @path.empty?
27
27
 
28
- path_d = @path.to_path_d(@graphics.current.ctm)
28
+ path_d = @path.to_path_d
29
29
  clip_id = @builder.register_clip_path(path_d)
30
30
  @graphics.update(clip_paths: @graphics.current.clip_paths + [clip_id])
31
31
  @path.reset
@@ -43,7 +43,7 @@ module Postsvg
43
43
  def flush_path(mode)
44
44
  return if @path.empty?
45
45
 
46
- d = @path.to_path_d(@graphics.current.ctm)
46
+ d = @path.to_path_d
47
47
  ctx = @graphics.current
48
48
  transform_str = build_transform_string(ctx)
49
49
  clip_id = ctx.clip_paths.last
@@ -66,7 +66,7 @@ module Postsvg
66
66
  line_cap: ctx.line_cap,
67
67
  line_join: ctx.line_join,
68
68
  dash: ctx.dash,
69
- clip_path_id: clip_path_id
69
+ clip_path_id: clip_path_id,
70
70
  )
71
71
  end
72
72
 
@@ -5,29 +5,25 @@ module Postsvg
5
5
  class PsVisitor
6
6
  # Path construction operator handlers. Each appends a command to
7
7
  # +@path+ (PathState). CTM-aware: when the current CTM is not
8
- # identity, the renderer wraps painted paths in a <g transform=>.
9
- # Arcs retain their construction CTM and are mapped into the paint CTM
10
- # at emission, whether or not the temporary transform was restored.
11
- # Other mid-path CTM changes retain the existing paint-time behavior.
8
+ # identity, the renderer will wrap emitted paths in a
9
+ # <g transform=>. Points are stored in user-space coordinates;
10
+ # the visitor delegates to the SvgBuilder for transform emission.
12
11
  module Path
13
12
  def visit_newpath(_op, _ctx)
14
13
  @path.reset
15
14
  end
16
15
 
17
16
  def visit_moveto(op, _ctx)
18
- @path.matrix ||= @graphics.current.ctm
19
17
  @path.move_to(op.x, op.y)
20
18
  @graphics.update(last_text_position: { x: op.x, y: op.y })
21
19
  end
22
20
 
23
21
  def visit_rmoveto(op, _ctx)
24
- @path.matrix ||= @graphics.current.ctm
25
22
  @path.move_to_rel(op.dx, op.dy)
26
23
  pos = @graphics.current.last_text_position
27
24
  base_x = pos ? pos[:x] : 0
28
25
  base_y = pos ? pos[:y] : 0
29
- @graphics.update(last_text_position: { x: base_x + op.dx,
30
- y: base_y + op.dy })
26
+ @graphics.update(last_text_position: { x: base_x + op.dx, y: base_y + op.dy })
31
27
  end
32
28
 
33
29
  def visit_lineto(op, _ctx)
@@ -69,61 +65,46 @@ module Postsvg
69
65
  # are parametrised by center, radius, angle range. SVG arcs
70
66
  # are parametrised by endpoint, radii, large-arc/sweep flags.
71
67
  def append_arc(cx, cy, radius, angle1, angle2, sweep:)
72
- consume_numeric_operands(cx, cy, radius, angle1, angle2)
73
68
  r = radius.abs
74
69
  return if r.zero?
75
70
 
76
- construction_matrix = @graphics.current.ctm
77
- @path.matrix ||= construction_matrix
78
- point_matrix = @path.matrix.invert.multiply(construction_matrix)
79
- end_x, end_y = arc_point(point_matrix, cx, cy, r, angle2)
80
- starts_path = @path.empty?
81
- command = lambda do |paint_matrix|
82
- matrix = paint_matrix.invert.multiply(construction_matrix)
83
- render_arc(cx, cy, r, angle1, angle2, sweep: sweep,
84
- matrix: matrix, starts_path: starts_path)
71
+ start_rad = angle1 * Math::PI / 180.0
72
+ start_x = cx + (r * Math.cos(start_rad))
73
+ start_y = cy + (r * Math.sin(start_rad))
74
+ end_rad = angle2 * Math::PI / 180.0
75
+ end_x = cx + (r * Math.cos(end_rad))
76
+ end_y = cy + (r * Math.sin(end_rad))
77
+
78
+ # If the current path is empty, the arc implicitly starts
79
+ # with a moveto to its starting point.
80
+ if @path.empty? || last_command_starts_with?("M")
81
+ @path.move_to(start_x, start_y) if @path.empty?
85
82
  end
86
- @path.append_deferred(command, end_x, end_y)
87
- end
88
83
 
89
- def render_arc(cx, cy, radius, angle1, angle2, sweep:, matrix:, starts_path:)
90
- path = PathState.new
91
- start_x, start_y = arc_point(matrix, cx, cy, radius, angle1)
92
- end_x, end_y = arc_point(matrix, cx, cy, radius, angle2)
93
- path.move_to(start_x, start_y) if starts_path
94
- delta = sweep ? (angle2 - angle1) % 360 : (angle1 - angle2) % 360
95
- rx, ry, rotation = arc_radii(matrix, radius)
96
- reflected = ((matrix.a * matrix.d) - (matrix.b * matrix.c)).negative?
97
- svg_sweep = sweep != reflected
84
+ delta = (angle2 - angle1) % 360
85
+ delta += 360 if delta.negative?
86
+ large_arc = delta > 180
87
+
88
+ # SVG sweep flag is 1 when the arc is drawn clockwise.
89
+ svg_sweep = sweep ? (delta.positive? ? 1 : 0) : 0
98
90
  if delta.abs < 1e-6
99
- # Full circles need two arcs; retain the corpus's large-arc spelling.
100
- mid_x, mid_y = arc_point(matrix, cx, cy, radius, angle1 + 180)
101
- path.arc_to(rx, ry, rotation, true, svg_sweep, mid_x, mid_y)
102
- path.arc_to(rx, ry, rotation, true, svg_sweep, start_x, start_y)
103
- path.close
91
+ # Full circle: emit two arcs.
92
+ mid_angle = angle1 + 180
93
+ mid_rad = mid_angle * Math::PI / 180.0
94
+ mid_x = cx + (r * Math.cos(mid_rad))
95
+ mid_y = cy + (r * Math.sin(mid_rad))
96
+ @path.arc_to(r, r, 0, 0, svg_sweep, mid_x, mid_y)
97
+ @path.arc_to(r, r, 0, 0, svg_sweep, start_x, start_y)
98
+ @path.close
104
99
  else
105
- path.arc_to(rx, ry, rotation, delta > 180, svg_sweep, end_x, end_y)
100
+ @path.arc_to(r, r, 0, large_arc ? 1 : 0, svg_sweep, end_x, end_y)
106
101
  end
107
- path.to_path_d
108
102
  end
109
103
 
110
- def arc_point(matrix, cx, cy, radius, angle)
111
- radians = angle * Math::PI / 180.0
112
- matrix.apply_point(cx + (radius * Math.cos(radians)),
113
- cy + (radius * Math.sin(radians))).values_at(:x, :y)
114
- end
104
+ def last_command_starts_with?(prefix)
105
+ return false if @path.commands.empty?
115
106
 
116
- # Singular values give the ellipse radii even when its frame is rotated
117
- # or sheared. The principal eigenvector gives its SVG axis rotation.
118
- def arc_radii(matrix, radius)
119
- xx = (matrix.a**2) + (matrix.c**2)
120
- xy = (matrix.a * matrix.b) + (matrix.c * matrix.d)
121
- yy = (matrix.b**2) + (matrix.d**2)
122
- root = Math.hypot(xx - yy, 2 * xy)
123
- rx = radius * Math.sqrt([(xx + yy + root) / 2, 0].max)
124
- ry = radius * Math.sqrt([(xx + yy - root) / 2, 0].max)
125
- rotation = root < 1e-12 ? 0.0 : Math.atan2(2 * xy, xx - yy) * 90 / Math::PI
126
- [rx, ry, rotation]
107
+ @path.commands.last.start_with?(prefix)
127
108
  end
128
109
  end
129
110
  end
@@ -6,19 +6,12 @@ module Postsvg
6
6
  # Coordinate transformation operator handlers. Each returns a
7
7
  # new CTM via Matrix composition and updates the GraphicsContext.
8
8
  module Transformations
9
- # A matrix this visitor produced (`matrix`, `currentmatrix`). The
10
- # runtime stack also holds values nothing popped (`def` operands,
11
- # unresolved names, path operands), so only these are trusted as a
12
- # matrix the parser could not see.
13
- class StackMatrix < Array; end
14
-
15
9
  def visit_translate(op, _ctx)
16
10
  new_ctm = @graphics.current.ctm.translate(op.tx.to_f, op.ty.to_f)
17
11
  @graphics.update(ctm: new_ctm)
18
12
  end
19
13
 
20
14
  def visit_scale(op, _ctx)
21
- consume_numeric_operands(op.sx, op.sy)
22
15
  new_ctm = @graphics.current.ctm.scale(op.sx.to_f, op.sy.to_f)
23
16
  @graphics.update(ctm: new_ctm)
24
17
  end
@@ -29,7 +22,7 @@ module Postsvg
29
22
  end
30
23
 
31
24
  def visit_concat(op, _ctx)
32
- matrix = matrix_from_operand(matrix_operand(op))
25
+ matrix = matrix_from_operand(op.matrix)
33
26
  return unless matrix
34
27
 
35
28
  new_ctm = @graphics.current.ctm.multiply(matrix)
@@ -37,17 +30,16 @@ module Postsvg
37
30
  end
38
31
 
39
32
  def visit_matrix(_op, _ctx)
40
- @stack << StackMatrix[1, 0, 0, 1, 0, 0]
33
+ @stack << [1, 0, 0, 1, 0, 0]
41
34
  end
42
35
 
43
36
  def visit_currentmatrix(_op, _ctx)
44
- @stack.pop if @stack.last.is_a?(StackMatrix)
45
37
  ctm = @graphics.current.ctm
46
- @stack << StackMatrix[ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
38
+ @stack << [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
47
39
  end
48
40
 
49
41
  def visit_setmatrix(op, _ctx)
50
- matrix = matrix_from_operand(matrix_operand(op))
42
+ matrix = matrix_from_operand(op.matrix)
51
43
  return unless matrix
52
44
 
53
45
  @graphics.update(ctm: matrix)
@@ -93,46 +85,16 @@ module Postsvg
93
85
 
94
86
  private
95
87
 
96
- # Parsed literal operands remain on the visitor stack. Consume only
97
- # an exact numeric suffix, leaving directly constructed operators alone.
98
- def consume_numeric_operands(*values)
99
- @stack.pop(values.length) if @stack.last(values.length) == values
100
- end
101
-
102
- # A literal matrix was also pushed by visit_array; consume that copy
103
- # so a later operator does not apply it again. A computed matrix is
104
- # taken from the stack only when this visitor produced it.
105
- def matrix_operand(op)
106
- unless op.matrix.is_a?(Model::Computed)
107
- @stack.pop if literal_on_stack?(op.matrix)
108
- return op.matrix
109
- end
110
-
111
- @stack.pop if @stack.last.is_a?(StackMatrix)
112
- end
113
-
114
- def literal_on_stack?(matrix)
115
- matrix.is_a?(Model::Literals::ArrayLiteral) && @stack.last.equal?(matrix.elements)
116
- end
117
-
118
- # Arrays on the runtime stack hold either plain numbers or parsed
119
- # Number literals. Anything other than six numbers is not a matrix.
120
88
  def matrix_from_operand(operand)
121
89
  case operand
122
90
  when Matrix then operand
123
- when Model::Literals::ArrayLiteral then matrix_from_operand(operand.elements)
124
91
  when Array
125
- numbers = operand.map { |element| matrix_number(element) }
126
- return nil unless numbers.length == 6 && numbers.all?
92
+ return nil unless operand.length == 6
127
93
 
128
- Matrix.new(a: numbers[0], b: numbers[1], c: numbers[2],
129
- d: numbers[3], e: numbers[4], f: numbers[5])
94
+ Matrix.new(a: operand[0], b: operand[1], c: operand[2],
95
+ d: operand[3], e: operand[4], f: operand[5])
130
96
  end
131
97
  end
132
-
133
- def matrix_number(element)
134
- numeric_value(element) if element.is_a?(Numeric) || element.is_a?(Model::Literals::Number)
135
- end
136
98
  end
137
99
  end
138
100
  end