postsvg 0.3.0 → 0.3.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +533 -49
  3. data/CLAUDE.md +27 -3
  4. data/Gemfile +2 -2
  5. data/README.adoc +62 -4
  6. data/Rakefile +17 -0
  7. data/gemfiles/native-parity/Gemfile +8 -0
  8. data/lib/postsvg/cli.rb +10 -5
  9. data/lib/postsvg/graphics_context.rb +2 -1
  10. data/lib/postsvg/interpreter.rb +1 -2
  11. data/lib/postsvg/options.rb +1 -1
  12. data/lib/postsvg/parser/postscript_parser.rb +7 -7
  13. data/lib/postsvg/renderer.rb +7 -3
  14. data/lib/postsvg/svg/clip_path_registry.rb +3 -1
  15. data/lib/postsvg/svg/element.rb +4 -2
  16. data/lib/postsvg/svg/elements/ellipse.rb +2 -1
  17. data/lib/postsvg/svg/elements/svg.rb +2 -1
  18. data/lib/postsvg/svg/paint.rb +1 -1
  19. data/lib/postsvg/svg/parser.rb +3 -3
  20. data/lib/postsvg/svg/path_data/parser.rb +10 -6
  21. data/lib/postsvg/svg/stroke.rb +4 -2
  22. data/lib/postsvg/svg/transform_list.rb +1 -1
  23. data/lib/postsvg/svg.rb +1 -1
  24. data/lib/postsvg/svg_builder.rb +15 -11
  25. data/lib/postsvg/translation/arc_converter.rb +23 -14
  26. data/lib/postsvg/translation/bounding_box.rb +7 -4
  27. data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
  28. data/lib/postsvg/translation/handlers/ellipse_handler.rb +8 -3
  29. data/lib/postsvg/translation/handlers/group_handler.rb +2 -1
  30. data/lib/postsvg/translation/handlers/image_handler.rb +3 -2
  31. data/lib/postsvg/translation/handlers/line_handler.rb +6 -2
  32. data/lib/postsvg/translation/handlers/open_handler.rb +3 -2
  33. data/lib/postsvg/translation/handlers/path_handler.rb +82 -60
  34. data/lib/postsvg/translation/handlers/polygon_handler.rb +4 -2
  35. data/lib/postsvg/translation/handlers/polyline_handler.rb +5 -4
  36. data/lib/postsvg/translation/handlers/rect_handler.rb +10 -5
  37. data/lib/postsvg/translation/handlers/shared.rb +11 -11
  38. data/lib/postsvg/translation/handlers/svg_handler.rb +3 -2
  39. data/lib/postsvg/translation/handlers/text_handler.rb +17 -8
  40. data/lib/postsvg/translation/handlers.rb +2 -1
  41. data/lib/postsvg/translation/ps_renderer.rb +18 -4
  42. data/lib/postsvg/translation/record_emitter.rb +54 -0
  43. data/lib/postsvg/version.rb +1 -1
  44. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
  45. data/lib/postsvg/visitors/ps_visitor/color.rb +8 -5
  46. data/lib/postsvg/visitors/ps_visitor/common.rb +1 -2
  47. data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
  48. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
  49. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +8 -4
  50. data/lib/postsvg/visitors/ps_visitor/font.rb +15 -4
  51. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +7 -5
  52. data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
  53. data/lib/postsvg/visitors/ps_visitor/path.rb +52 -33
  54. data/lib/postsvg/visitors/ps_visitor/transformations.rb +45 -7
  55. data/lib/postsvg/visitors/ps_visitor.rb +18 -2
  56. data/lib/postsvg.rb +43 -38
  57. metadata +20 -39
  58. data/lib/postsvg/color.rb +0 -132
  59. data/lib/postsvg/format_number.rb +0 -22
  60. data/lib/postsvg/matrix.rb +0 -106
  61. data/lib/postsvg/model/literals/array.rb +0 -41
  62. data/lib/postsvg/model/literals/dictionary.rb +0 -34
  63. data/lib/postsvg/model/literals/hex.rb +0 -37
  64. data/lib/postsvg/model/literals/name.rb +0 -40
  65. data/lib/postsvg/model/literals/number.rb +0 -36
  66. data/lib/postsvg/model/literals/procedure.rb +0 -41
  67. data/lib/postsvg/model/literals/string.rb +0 -30
  68. data/lib/postsvg/model/literals.rb +0 -19
  69. data/lib/postsvg/model/operator.rb +0 -58
  70. data/lib/postsvg/model/operators/arithmetic.rb +0 -264
  71. data/lib/postsvg/model/operators/boolean.rb +0 -182
  72. data/lib/postsvg/model/operators/color.rb +0 -74
  73. data/lib/postsvg/model/operators/container.rb +0 -186
  74. data/lib/postsvg/model/operators/control_flow.rb +0 -119
  75. data/lib/postsvg/model/operators/device.rb +0 -21
  76. data/lib/postsvg/model/operators/dictionary.rb +0 -118
  77. data/lib/postsvg/model/operators/font.rb +0 -121
  78. data/lib/postsvg/model/operators/graphics_state.rb +0 -84
  79. data/lib/postsvg/model/operators/painting.rb +0 -29
  80. data/lib/postsvg/model/operators/path.rb +0 -169
  81. data/lib/postsvg/model/operators/stack.rb +0 -72
  82. data/lib/postsvg/model/operators/transformations.rb +0 -103
  83. data/lib/postsvg/model/operators.rb +0 -89
  84. data/lib/postsvg/model/program.rb +0 -68
  85. data/lib/postsvg/model/token.rb +0 -43
  86. data/lib/postsvg/model.rb +0 -17
  87. data/lib/postsvg/serializer.rb +0 -325
  88. data/lib/postsvg/source/ast_builder.rb +0 -308
  89. data/lib/postsvg/source/lexer.rb +0 -322
  90. data/lib/postsvg/source/operand_stack.rb +0 -55
  91. data/lib/postsvg/source.rb +0 -21
@@ -7,6 +7,54 @@ 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
+
10
58
  attr_reader :records
11
59
 
12
60
  def initialize
@@ -14,6 +62,12 @@ module Postsvg
14
62
  end
15
63
 
16
64
  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))
17
71
  @records << record
18
72
  self
19
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Postsvg
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
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.to_f)
35
+ @stack << (a.to_f / divisor)
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,22 +7,25 @@ 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 = Postsvg::Color.gray(op.gray.to_f)
10
+ color = Postscript::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 = Postsvg::Color.rgb(op.red.to_f, op.green.to_f, op.blue.to_f)
15
+ color = Postscript::Color.rgb(op.red.to_f, op.green.to_f,
16
+ op.blue.to_f)
16
17
  @graphics.update(fill_color: color, stroke_color: color)
17
18
  end
18
19
 
19
20
  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
+ color = Postscript::Color.cmyk(op.cyan.to_f, op.magenta.to_f,
22
+ op.yellow.to_f, op.key.to_f)
21
23
  @graphics.update(fill_color: color, stroke_color: color)
22
24
  end
23
25
 
24
26
  def visit_sethsbcolor(op, _ctx)
25
- color = hsb_to_color(op.hue.to_f, op.saturation.to_f, op.brightness.to_f)
27
+ color = hsb_to_color(op.hue.to_f, op.saturation.to_f,
28
+ op.brightness.to_f)
26
29
  @graphics.update(fill_color: color, stroke_color: color)
27
30
  end
28
31
 
@@ -45,7 +48,7 @@ module Postsvg
45
48
  when 4 then [t, p, v]
46
49
  else [v, p, q]
47
50
  end
48
- Postsvg::Color.rgb(r, g, b)
51
+ Postscript::Color.rgb(r, g, b)
49
52
  end
50
53
  end
51
54
  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.sub(/\A\//, "")
16
+ when String then key.to_s.delete_prefix("/")
17
17
  else key.to_s
18
18
  end
19
19
  end
@@ -25,7 +25,6 @@ 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
29
28
  end
30
29
  end
31
30
 
@@ -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
- 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
54
+ @stack << case op.operand
55
+ when String
56
+ op.operand[op.start, op.count]
57
+ when Array
58
+ op.operand[op.start, op.count]
59
+ when Model::Literals::StringLiteral
60
+ op.operand.value[op.start, op.count]
61
+ when Model::Literals::ArrayLiteral
62
+ op.operand.elements[op.start, op.count]
63
+ else
64
+ ""
65
+ end
66
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 Postsvg::ExitSignal
42
+ rescue Postscript::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 Postsvg::ExitSignal
77
+ raise Postscript::ExitSignal
78
78
  end
79
79
 
80
80
  def visit_quit(_op, _ctx)
81
- raise Postsvg::QuitSignal
81
+ raise Postscript::QuitSignal
82
82
  end
83
83
 
84
84
  def visit_exec(_op, _ctx)
@@ -72,10 +72,14 @@ module Postsvg
72
72
 
73
73
  def visit_maxlength(op, _ctx)
74
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
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
79
83
  end
80
84
  end
81
85
 
@@ -31,15 +31,26 @@ module Postsvg
31
31
  end
32
32
 
33
33
  def visit_scalefont(op, _ctx)
34
- size = op.size.to_i || op.size.to_f
34
+ size = op.size.to_i
35
+ consume_numeric_operands(op.size)
35
36
  font = @stack.pop
36
- base = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: nil)
37
+ base = if font.is_a?(FontRef)
38
+ font
39
+ else
40
+ FontRef.new(name: "Helvetica",
41
+ size: nil)
42
+ end
37
43
  @stack << FontRef.new(name: base.name, size: size.to_f)
38
44
  end
39
45
 
40
46
  def visit_setfont(_op, _ctx)
41
47
  font = @stack.pop
42
- ref = font.is_a?(FontRef) ? font : FontRef.new(name: "Helvetica", size: 12.0)
48
+ ref = if font.is_a?(FontRef)
49
+ font
50
+ else
51
+ FontRef.new(name: "Helvetica",
52
+ size: 12.0)
53
+ end
43
54
  @graphics.update(font_name: ref.name || "Helvetica",
44
55
  font_size: ref.size || 12.0)
45
56
  end
@@ -67,7 +78,7 @@ module Postsvg
67
78
  y: -screen_pos[:y],
68
79
  font_family: @graphics.current.font_name,
69
80
  font_size: @graphics.current.font_size,
70
- color: color || Postsvg::Color::BLACK,
81
+ color: color || Postscript::Color::BLACK,
71
82
  transform: "scale(1 -1)",
72
83
  )
73
84
  @path.reset
@@ -7,15 +7,17 @@ module Postsvg
7
7
  # dash attribute setters.
8
8
  module GraphicsState
9
9
  def visit_gsave(_op, _ctx)
10
+ @saved_paths << @path.dup
10
11
  @graphics.gsave
11
12
  end
12
13
 
13
14
  def visit_grestore(_op, _ctx)
15
+ @path = @saved_paths.pop if @graphics.depth > 1 && !@saved_paths.empty?
14
16
  @graphics.grestore
15
17
  end
16
18
 
17
19
  def visit_grestoreall(_op, _ctx)
18
- @graphics.grestore while @graphics.depth > 1
20
+ visit_grestore(nil, nil) while @graphics.depth > 1
19
21
  end
20
22
 
21
23
  def visit_setlinewidth(op, _ctx)
@@ -23,12 +25,14 @@ module Postsvg
23
25
  end
24
26
 
25
27
  def visit_setlinecap(op, _ctx)
26
- name = { 0 => :butt, 1 => :round, 2 => :square }.fetch(op.cap_code, :butt)
28
+ name = { 0 => :butt, 1 => :round, 2 => :square }.fetch(op.cap_code,
29
+ :butt)
27
30
  @graphics.update(line_cap: name)
28
31
  end
29
32
 
30
33
  def visit_setlinejoin(op, _ctx)
31
- name = { 0 => :miter, 1 => :round, 2 => :bevel }.fetch(op.join_code, :miter)
34
+ name = { 0 => :miter, 1 => :round, 2 => :bevel }.fetch(op.join_code,
35
+ :miter)
32
36
  @graphics.update(line_join: name)
33
37
  end
34
38
 
@@ -44,8 +48,6 @@ module Postsvg
44
48
  pattern.map { |v| FormatNumber.call(v.to_f) }.join(" ")
45
49
  when Numeric
46
50
  FormatNumber.call(pattern.to_f)
47
- else
48
- nil
49
51
  end
50
52
  @graphics.update(dash: dash_str)
51
53
  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
28
+ path_d = @path.to_path_d(@graphics.current.ctm)
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
46
+ d = @path.to_path_d(@graphics.current.ctm)
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,25 +5,29 @@ 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 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.
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.
11
12
  module Path
12
13
  def visit_newpath(_op, _ctx)
13
14
  @path.reset
14
15
  end
15
16
 
16
17
  def visit_moveto(op, _ctx)
18
+ @path.matrix ||= @graphics.current.ctm
17
19
  @path.move_to(op.x, op.y)
18
20
  @graphics.update(last_text_position: { x: op.x, y: op.y })
19
21
  end
20
22
 
21
23
  def visit_rmoveto(op, _ctx)
24
+ @path.matrix ||= @graphics.current.ctm
22
25
  @path.move_to_rel(op.dx, op.dy)
23
26
  pos = @graphics.current.last_text_position
24
27
  base_x = pos ? pos[:x] : 0
25
28
  base_y = pos ? pos[:y] : 0
26
- @graphics.update(last_text_position: { x: base_x + op.dx, y: base_y + op.dy })
29
+ @graphics.update(last_text_position: { x: base_x + op.dx,
30
+ y: base_y + op.dy })
27
31
  end
28
32
 
29
33
  def visit_lineto(op, _ctx)
@@ -65,46 +69,61 @@ module Postsvg
65
69
  # are parametrised by center, radius, angle range. SVG arcs
66
70
  # are parametrised by endpoint, radii, large-arc/sweep flags.
67
71
  def append_arc(cx, cy, radius, angle1, angle2, sweep:)
72
+ consume_numeric_operands(cx, cy, radius, angle1, angle2)
68
73
  r = radius.abs
69
74
  return if r.zero?
70
75
 
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?
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)
82
85
  end
86
+ @path.append_deferred(command, end_x, end_y)
87
+ end
83
88
 
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
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
90
98
  if delta.abs < 1e-6
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
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
99
104
  else
100
- @path.arc_to(r, r, 0, large_arc ? 1 : 0, svg_sweep, end_x, end_y)
105
+ path.arc_to(rx, ry, rotation, delta > 180, svg_sweep, end_x, end_y)
101
106
  end
107
+ path.to_path_d
102
108
  end
103
109
 
104
- def last_command_starts_with?(prefix)
105
- return false if @path.commands.empty?
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
106
115
 
107
- @path.commands.last.start_with?(prefix)
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]
108
127
  end
109
128
  end
110
129
  end
@@ -6,12 +6,19 @@ 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
+
9
15
  def visit_translate(op, _ctx)
10
16
  new_ctm = @graphics.current.ctm.translate(op.tx.to_f, op.ty.to_f)
11
17
  @graphics.update(ctm: new_ctm)
12
18
  end
13
19
 
14
20
  def visit_scale(op, _ctx)
21
+ consume_numeric_operands(op.sx, op.sy)
15
22
  new_ctm = @graphics.current.ctm.scale(op.sx.to_f, op.sy.to_f)
16
23
  @graphics.update(ctm: new_ctm)
17
24
  end
@@ -22,7 +29,7 @@ module Postsvg
22
29
  end
23
30
 
24
31
  def visit_concat(op, _ctx)
25
- matrix = matrix_from_operand(op.matrix)
32
+ matrix = matrix_from_operand(matrix_operand(op))
26
33
  return unless matrix
27
34
 
28
35
  new_ctm = @graphics.current.ctm.multiply(matrix)
@@ -30,16 +37,17 @@ module Postsvg
30
37
  end
31
38
 
32
39
  def visit_matrix(_op, _ctx)
33
- @stack << [1, 0, 0, 1, 0, 0]
40
+ @stack << StackMatrix[1, 0, 0, 1, 0, 0]
34
41
  end
35
42
 
36
43
  def visit_currentmatrix(_op, _ctx)
44
+ @stack.pop if @stack.last.is_a?(StackMatrix)
37
45
  ctm = @graphics.current.ctm
38
- @stack << [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
46
+ @stack << StackMatrix[ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
39
47
  end
40
48
 
41
49
  def visit_setmatrix(op, _ctx)
42
- matrix = matrix_from_operand(op.matrix)
50
+ matrix = matrix_from_operand(matrix_operand(op))
43
51
  return unless matrix
44
52
 
45
53
  @graphics.update(ctm: matrix)
@@ -85,16 +93,46 @@ module Postsvg
85
93
 
86
94
  private
87
95
 
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.
88
120
  def matrix_from_operand(operand)
89
121
  case operand
90
122
  when Matrix then operand
123
+ when Model::Literals::ArrayLiteral then matrix_from_operand(operand.elements)
91
124
  when Array
92
- return nil unless operand.length == 6
125
+ numbers = operand.map { |element| matrix_number(element) }
126
+ return nil unless numbers.length == 6 && numbers.all?
93
127
 
94
- Matrix.new(a: operand[0], b: operand[1], c: operand[2],
95
- d: operand[3], e: operand[4], f: operand[5])
128
+ Matrix.new(a: numbers[0], b: numbers[1], c: numbers[2],
129
+ d: numbers[3], e: numbers[4], f: numbers[5])
96
130
  end
97
131
  end
132
+
133
+ def matrix_number(element)
134
+ numeric_value(element) if element.is_a?(Numeric) || element.is_a?(Model::Literals::Number)
135
+ end
98
136
  end
99
137
  end
100
138
  end
@@ -36,6 +36,7 @@ module Postsvg
36
36
  @graphics = graphics
37
37
  @stack = [] # operand stack for execution semantics
38
38
  @path = PathState.new
39
+ @saved_paths = []
39
40
  @dict_stack = [{}] # runtime dict stack (mirrors PLRM dict stack)
40
41
  @recursion_depth = 0
41
42
  end
@@ -116,6 +117,7 @@ module Postsvg
116
117
  # geometry bookkeeping.
117
118
  class PathState
118
119
  attr_reader :commands, :current_x, :current_y
120
+ attr_accessor :matrix
119
121
 
120
122
  def initialize
121
123
  @commands = []
@@ -127,6 +129,11 @@ module Postsvg
127
129
  @commands.empty?
128
130
  end
129
131
 
132
+ def initialize_copy(other)
133
+ super
134
+ @commands = other.commands.map(&:dup)
135
+ end
136
+
130
137
  def move_to(x, y)
131
138
  @commands << "M #{fmt(x)} #{fmt(y)}"
132
139
  @current_x = x.to_f
@@ -170,18 +177,27 @@ module Postsvg
170
177
  @current_y = y.to_f
171
178
  end
172
179
 
180
+ def append_deferred(command, x, y)
181
+ @commands << command
182
+ @current_x = x
183
+ @current_y = y
184
+ end
185
+
173
186
  def close
174
187
  @commands << "Z"
175
188
  end
176
189
 
177
190
  def reset
191
+ @matrix = nil
178
192
  @commands = []
179
193
  @current_x = 0.0
180
194
  @current_y = 0.0
181
195
  end
182
196
 
183
- def to_path_d
184
- @commands.join(" ")
197
+ def to_path_d(paint_matrix = @matrix || Matrix.new)
198
+ @commands.map do |command|
199
+ command.is_a?(Proc) ? command.call(paint_matrix) : command
200
+ end.join(" ")
185
201
  end
186
202
 
187
203
  def fmt(value)