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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module PathData
6
+ # Tokenizer + parser for SVG path data. Handles the standard
7
+ # grammar from SVG 1.1 §8.3. Output is a list of Command value
8
+ # objects with normalized argument counts.
9
+ module Parser
10
+ module_function
11
+
12
+ COMMAND_RE = /([MmLlHhVvCcSsQqTtAaZz])/
13
+ ARG_RE = /(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/
14
+
15
+ def parse(text)
16
+ return [] if text.nil? || text.empty?
17
+
18
+ commands = []
19
+ tokens = tokenize(text)
20
+ i = 0
21
+ while i < tokens.length
22
+ opcode = tokens[i]
23
+ i += 1
24
+ arity = arity_for(opcode)
25
+ if arity.zero?
26
+ commands << Command.new(opcode: opcode, args: [])
27
+ next
28
+ end
29
+
30
+ # Repeat: M / L / C etc. can have multiple coordinate
31
+ # tuples after a single opcode. Consume in groups of arity.
32
+ loop do
33
+ args, consumed = take_args(tokens, i, arity)
34
+ break unless consumed == arity
35
+
36
+ commands << Command.new(opcode: opcode, args: args)
37
+ i += consumed
38
+ # After moveto, implicit lineto for subsequent tuples.
39
+ opcode = if opcode == "M"
40
+ "L"
41
+ else
42
+ (opcode == "m" ? "l" : opcode)
43
+ end
44
+ # For H/V/Z, no implicit repetition.
45
+ break if %w[H V Z h v z].include?(opcode)
46
+ end
47
+ end
48
+ commands
49
+ end
50
+
51
+ def arity_for(opcode)
52
+ case opcode.upcase
53
+ when "M", "L", "T" then 2
54
+ when "H", "V" then 1
55
+ when "C" then 6
56
+ when "S", "Q" then 4
57
+ when "A" then 7
58
+ when "Z" then 0
59
+ else 0
60
+ end
61
+ end
62
+
63
+ def tokenize(text)
64
+ text.to_s.gsub("-", " -")
65
+ .scan(/([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/)
66
+ .flatten.compact
67
+ end
68
+
69
+ NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/
70
+
71
+ def take_args(tokens, start, count)
72
+ args = []
73
+ consumed = 0
74
+ while consumed < count && start + consumed < tokens.length
75
+ tok = tokens[start + consumed]
76
+ break unless NUMBER_ONLY_RE.match?(tok.to_s)
77
+
78
+ args << tok.to_f
79
+ consumed += 1
80
+ end
81
+ [args, consumed]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ # Parses the SVG path "d" attribute into a list of Command value
6
+ # objects. Each Command carries its opcode (M, L, C, Z, A, etc.)
7
+ # and its numeric arguments.
8
+ module PathData
9
+ autoload :Command, "postsvg/svg/path_data/command"
10
+ autoload :Parser, "postsvg/svg/path_data/parser"
11
+
12
+ def self.parse(text)
13
+ Parser.parse(text)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ # Stroke style value object. Width is a Float (default 1.0),
6
+ # dasharray is an Array of Floats, dashoffset is a Float, linecap
7
+ # / linejoin are Symbols.
8
+ Stroke = Struct.new(:width, :dasharray, :dashoffset, :linecap, :linejoin,
9
+ :miterlimit, keyword_init: true) do
10
+ def self.parse(node)
11
+ width = AttributeParser.number(node["stroke-width"], default: 1.0)
12
+ dasharray_raw = node["stroke-dasharray"]
13
+ dasharray =
14
+ if dasharray_raw && dasharray_raw != "none"
15
+ AttributeParser.number_list(dasharray_raw)
16
+ end
17
+ dashoffset = AttributeParser.number(node["stroke-dashoffset"],
18
+ default: 0.0)
19
+ linecap = (node["stroke-linecap"] || "butt").to_sym
20
+ linejoin = (node["stroke-linejoin"] || "miter").to_sym
21
+ miterlimit = AttributeParser.number(node["stroke-miterlimit"],
22
+ default: 10.0)
23
+ new(width: width, dasharray: dasharray, dashoffset: dashoffset,
24
+ linecap: linecap, linejoin: linejoin, miterlimit: miterlimit)
25
+ end
26
+
27
+ def default?
28
+ width == 1.0 && dasharray.nil? && dashoffset.zero? &&
29
+ linecap == :butt && linejoin == :miter && miterlimit == 10.0
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ # Parsed SVG transform attribute. Produces a list of
6
+ # Postscript::Matrix instances that, multiplied in order, give the
7
+ # composite CTM for the element.
8
+ class TransformList
9
+ attr_reader :matrices
10
+
11
+ def initialize(matrices = [])
12
+ @matrices = matrices.freeze
13
+ freeze
14
+ end
15
+
16
+ def empty?
17
+ @matrices.empty?
18
+ end
19
+
20
+ def composite
21
+ return Matrix.new if @matrices.empty?
22
+
23
+ @matrices.reduce(Matrix.new) { |acc, m| acc.multiply(m) }
24
+ end
25
+
26
+ def self.parse(text)
27
+ return new if text.nil? || text.strip.empty?
28
+
29
+ matrices = []
30
+ text.scan(/(\w+)\s*\(([^)]*)\)/).each do |name, args|
31
+ nums = args.split(/[\s,]+/).map(&:to_f)
32
+ case name
33
+ when "matrix"
34
+ next unless nums.length == 6
35
+
36
+ matrices << Matrix.new(a: nums[0], b: nums[1], c: nums[2],
37
+ d: nums[3], e: nums[4], f: nums[5])
38
+ when "translate"
39
+ tx = nums[0] || 0
40
+ ty = nums[1] || 0
41
+ matrices << Matrix.new(e: tx, f: ty)
42
+ when "scale"
43
+ sx = nums[0] || 1
44
+ sy = nums.length > 1 ? nums[1] : sx
45
+ matrices << Matrix.new(a: sx, d: sy)
46
+ when "rotate"
47
+ angle = nums[0] || 0
48
+ matrices << Matrix.new.rotate(angle)
49
+ when "skewX"
50
+ matrices << Matrix.new.skew_x(nums[0] || 0)
51
+ when "skewY"
52
+ matrices << Matrix.new.skew_y(nums[0] || 0)
53
+ end
54
+ end
55
+ new(matrices)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # SVG domain model. Built once from a Nokogiri-parsed document;
5
+ # consumed by the Translation layer (SVG -> PS).
6
+ #
7
+ # Svg::* value objects know nothing about PostScript. Their job is
8
+ # to present SVG semantics in a typed, immutable form.
9
+ module Svg
10
+ autoload :Element, "postsvg/svg/element"
11
+ autoload :OpenElement, "postsvg/svg/element"
12
+ autoload :Document, "postsvg/svg/document"
13
+ autoload :Parser, "postsvg/svg/parser"
14
+ autoload :AttributeParser, "postsvg/svg/attribute_parser"
15
+ autoload :Paint, "postsvg/svg/paint"
16
+ autoload :Stroke, "postsvg/svg/stroke"
17
+ autoload :TransformList, "postsvg/svg/transform_list"
18
+ autoload :PathData, "postsvg/svg/path_data"
19
+ autoload :ClipPathRegistry, "postsvg/svg/clip_path_registry"
20
+ autoload :Elements, "postsvg/svg/elements"
21
+ end
22
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Append-only SVG emitter. The Renderer / Visitors call methods on a
5
+ # builder instance; #to_s finalizes and returns the SVG string.
6
+ #
7
+ # Determinism invariants:
8
+ # - Two runs of the same input produce byte-equal output.
9
+ # - IDs (clip1, grad1, pattern1) start at 1 on every SvgBuilder.new.
10
+ # - Floats formatted through FormatNumber.
11
+ # - <defs> block orders clipPaths, gradients, then patterns by
12
+ # registration order so output is reproducible.
13
+ class SvgBuilder
14
+ CLIP_PREFIX = "clip"
15
+ GRADIENT_PREFIX = "grad"
16
+ PATTERN_PREFIX = "pattern"
17
+
18
+ def initialize
19
+ @buffer = String.new(capacity: 4096)
20
+ @defs_buffer = String.new(capacity: 1024)
21
+ @body_buffer = String.new(capacity: 4096)
22
+ @clip_paths = {} # path_d -> id
23
+ @gradients = {} # signature -> id
24
+ @patterns = {} # signature -> id
25
+ @next_clip_id = 1
26
+ @next_gradient_id = 1
27
+ @next_pattern_id = 1
28
+ @svg_open = false
29
+ @group_depth = 0
30
+ @header_height = nil
31
+ end
32
+
33
+ def open_svg(viewbox:, width:, height:)
34
+ raise RenderError, "svg already open" if @svg_open
35
+
36
+ @svg_open = true
37
+ @header_height = height
38
+ @buffer << %(<?xml version="1.0" encoding="UTF-8"?>\n)
39
+ @buffer << %(<svg xmlns="http://www.w3.org/2000/svg" )
40
+ @buffer << %(viewBox="#{viewbox}" )
41
+ @buffer << %(width="#{num(width)}" height="#{num(height)}">\n)
42
+ self
43
+ end
44
+
45
+ def open_y_flip_group
46
+ open_group(transform: "translate(0 #{num(@header_height)}) scale(1 -1)")
47
+ end
48
+
49
+ def open_group(transform: nil, clip_path_id: nil)
50
+ attrs = +""
51
+ attrs << %( transform="#{transform}") if transform
52
+ attrs << %( clip-path="url(##{clip_path_id})") if clip_path_id
53
+ @body_buffer << %(<g#{attrs}>\n)
54
+ @group_depth += 1
55
+ self
56
+ end
57
+
58
+ def close_group
59
+ raise RenderError, "no open group" if @group_depth.zero?
60
+
61
+ @body_buffer << %(</g>\n)
62
+ @group_depth -= 1
63
+ self
64
+ end
65
+
66
+ # Register a clip path. Returns the id used. If +path_d+ was
67
+ # previously registered, returns the existing id (dedup).
68
+ def register_clip_path(path_d)
69
+ return @clip_paths[path_d] if @clip_paths.key?(path_d)
70
+
71
+ id = "#{CLIP_PREFIX}#{@next_clip_id}"
72
+ @next_clip_id += 1
73
+ @clip_paths[path_d] = id
74
+ @defs_buffer << %(<clipPath id="#{id}"><path d="#{path_d}" /></clipPath>\n)
75
+ id
76
+ end
77
+
78
+ def register_linear_gradient(stops:, x1:, y1:, x2:, y2:)
79
+ sig = gradient_signature("linear", stops, [x1, y1, x2, y2])
80
+ return @gradients[sig] if @gradients.key?(sig)
81
+
82
+ id = "#{GRADIENT_PREFIX}#{@next_gradient_id}"
83
+ @next_gradient_id += 1
84
+ @gradients[sig] = id
85
+ @defs_buffer << %(<linearGradient id="#{id}" )
86
+ @defs_buffer << %(x1="#{num(x1)}" y1="#{num(y1)}" )
87
+ @defs_buffer << %(x2="#{num(x2)}" y2="#{num(y2)}">\n)
88
+ stops.each do |stop|
89
+ @defs_buffer << %(<stop offset="#{num(stop[:offset])}" )
90
+ @defs_buffer << %(stop-color="#{stop[:color].to_svg}" />\n)
91
+ end
92
+ @defs_buffer << %(</linearGradient>\n)
93
+ id
94
+ end
95
+
96
+ def register_radial_gradient(stops:, cx:, cy:, r:, fx: nil, fy: nil)
97
+ fx ||= cx
98
+ fy ||= cy
99
+ sig = gradient_signature("radial", stops, [cx, cy, r, fx, fy])
100
+ return @gradients[sig] if @gradients.key?(sig)
101
+
102
+ id = "#{GRADIENT_PREFIX}#{@next_gradient_id}"
103
+ @next_gradient_id += 1
104
+ @gradients[sig] = id
105
+ @defs_buffer << %(<radialGradient id="#{id}" )
106
+ @defs_buffer << %(cx="#{num(cx)}" cy="#{num(cy)}" r="#{num(r)}" )
107
+ @defs_buffer << %(fx="#{num(fx)}" fy="#{num(fy)}">\n)
108
+ stops.each do |stop|
109
+ @defs_buffer << %(<stop offset="#{num(stop[:offset])}" )
110
+ @defs_buffer << %(stop-color="#{stop[:color].to_svg}" />\n)
111
+ end
112
+ @defs_buffer << %(</radialGradient>\n)
113
+ id
114
+ end
115
+
116
+ def register_pattern(width:, height:, body_blocks:)
117
+ sig = "pattern:#{width}:#{height}:#{body_blocks.join('|')}"
118
+ return @patterns[sig] if @patterns.key?(sig)
119
+
120
+ id = "#{PATTERN_PREFIX}#{@next_pattern_id}"
121
+ @next_pattern_id += 1
122
+ @patterns[sig] = id
123
+ @defs_buffer << %(<pattern id="#{id}" )
124
+ @defs_buffer << %(width="#{num(width)}" height="#{num(height)}" )
125
+ @defs_buffer << %(patternUnits="userSpaceOnUse">\n)
126
+ body_blocks.each { |b| @defs_buffer << b }
127
+ @defs_buffer << %(</pattern>\n)
128
+ id
129
+ end
130
+
131
+ # Emit a <path>. +mode+ is one of :fill, :stroke, :fill_and_stroke.
132
+ def path(d:, mode:, color: nil, stroke_color: nil,
133
+ stroke_width: nil, line_cap: nil, line_join: nil,
134
+ dash: nil, clip_path_id: nil, fill_id: nil)
135
+ attrs = "d=\"#{d}\""
136
+
137
+ attrs << case mode
138
+ when :fill
139
+ fill_attr(color, fill_id)
140
+ when :stroke
141
+ %( fill="none" stroke="#{color_attr(stroke_color || color)}")
142
+ when :fill_and_stroke
143
+ "#{fill_attr(color,
144
+ fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
145
+ else
146
+ %( fill="none" stroke="none")
147
+ end
148
+
149
+ attrs << %( stroke-width="#{num(stroke_width)}") if stroke_width && stroke_width != 1.0
150
+ attrs << %( stroke-linecap="#{line_cap}") if line_cap && line_cap != :butt
151
+ attrs << %( stroke-linejoin="#{line_join}") if line_join && line_join != :miter
152
+ attrs << %( stroke-dasharray="#{dash}") if dash
153
+ attrs << %( clip-path="url(##{clip_path_id})") if clip_path_id
154
+
155
+ @body_buffer << %(<path #{attrs} />\n)
156
+ self
157
+ end
158
+
159
+ def text(content:, x:, y:, font_family:, font_size:, color:,
160
+ transform: nil)
161
+ attrs = +""
162
+ attrs << %( transform="#{transform}") if transform
163
+ attrs << %( x="#{num(x)}" y="#{num(y)}")
164
+ attrs << %( font-family="#{escape(font_family)}")
165
+ attrs << %( font-size="#{num(font_size)}")
166
+ attrs << %( fill="#{color_attr(color)}")
167
+ @body_buffer << %(<text#{attrs}>#{escape(content)}</text>\n)
168
+ self
169
+ end
170
+
171
+ def image(href:, x:, y:, width:, height:, transform: nil)
172
+ attrs = +""
173
+ attrs << %( transform="#{transform}") if transform
174
+ attrs << %( x="#{num(x)}" y="#{num(y)}")
175
+ attrs << %( width="#{num(width)}" height="#{num(height)}")
176
+ attrs << %( href="#{escape(href)}")
177
+ @body_buffer << %(<image#{attrs} />\n)
178
+ self
179
+ end
180
+
181
+ def comment(text)
182
+ @body_buffer << %(<!-- #{escape(text)} -->\n)
183
+ self
184
+ end
185
+
186
+ def close_svg
187
+ raise RenderError, "svg not open" unless @svg_open
188
+ if @group_depth.positive?
189
+ raise RenderError,
190
+ "unclosed groups: #{@group_depth}"
191
+ end
192
+
193
+ @buffer << "<defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
194
+ @buffer << @defs_buffer
195
+ @buffer << "</defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
196
+ @buffer << @body_buffer
197
+ @buffer << "</svg>\n"
198
+ @svg_open = false
199
+ self
200
+ end
201
+
202
+ def to_s
203
+ raise RenderError, "svg not closed" if @svg_open
204
+
205
+ @buffer.dup
206
+ end
207
+
208
+ # Expose internal buffers for testing only.
209
+ def buffer_size
210
+ @buffer.bytesize + @defs_buffer.bytesize + @body_buffer.bytesize
211
+ end
212
+
213
+ def registered_clip_count
214
+ @clip_paths.size
215
+ end
216
+
217
+ def registered_gradient_count
218
+ @gradients.size
219
+ end
220
+
221
+ private
222
+
223
+ def num(value)
224
+ FormatNumber.call(value)
225
+ end
226
+
227
+ def color_attr(color)
228
+ return "none" if color.nil?
229
+
230
+ color.is_a?(Color) ? color.to_svg : color.to_s
231
+ end
232
+
233
+ def fill_attr(color, fill_id)
234
+ return %( fill="url(##{fill_id})") if fill_id
235
+
236
+ %( fill="#{color_attr(color)}")
237
+ end
238
+
239
+ def gradient_signature(kind, stops, coords)
240
+ stops_str = stops.map { |s| "#{s[:offset]}:#{s[:color]}" }.join(",")
241
+ "#{kind}|#{stops_str}|#{coords.map { |c| num(c) }.join(',')}"
242
+ end
243
+
244
+ def escape(text)
245
+ text.to_s
246
+ .gsub("&", "&amp;")
247
+ .gsub("<", "&lt;")
248
+ .gsub(">", "&gt;")
249
+ .gsub('"', "&quot;")
250
+ .gsub("'", "&#39;")
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Translation
5
+ # SVG endpoint-to-center arc parametrization converter. Implements
6
+ # the algorithm documented in the SVG 1.1 implementation notes
7
+ # (appendix F.6.5). Returns the PS-style center parametrization:
8
+ # center x/y, normalized radii, rotation, and start/end angles.
9
+ module ArcConverter
10
+ EPSILON = 1e-12
11
+
12
+ module_function
13
+
14
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
15
+ def endpoint_to_center(x1:, y1:, rx:, ry:, x_axis_rotation:,
16
+ large_arc:, sweep:, x2:, y2:)
17
+ rx = rx.abs
18
+ ry = ry.abs
19
+ # If the endpoints coincide, the arc is degenerate.
20
+ return [x1, y1, 0.0, 0.0, 0.0, 0.0, 0.0] if rx < EPSILON || ry < EPSILON
21
+
22
+ phi = x_axis_rotation * Math::PI / 180.0
23
+ cos_phi = Math.cos(phi)
24
+ sin_phi = Math.sin(phi)
25
+
26
+ # Step 1: rotate and scale (x1,y1) into the unit-circle frame.
27
+ dx = (x1 - x2) / 2.0
28
+ dy = (y1 - y2) / 2.0
29
+ x1p = (cos_phi * dx) + (sin_phi * dy)
30
+ y1p = (-sin_phi * dx) + (cos_phi * dy)
31
+
32
+ # Ensure radii are large enough; if not, scale them up.
33
+ rx_sq = rx * rx
34
+ ry_sq = ry * ry
35
+ x1p_sq = x1p * x1p
36
+ y1p_sq = y1p * y1p
37
+ lambda = (x1p_sq / rx_sq) + (y1p_sq / ry_sq)
38
+ if lambda > 1.0
39
+ sqrt_lambda = Math.sqrt(lambda)
40
+ rx *= sqrt_lambda
41
+ ry *= sqrt_lambda
42
+ rx_sq = rx * rx
43
+ ry_sq = ry * ry
44
+ end
45
+
46
+ # Step 2: compute center (cx', cy') in the rotated frame.
47
+ sign = large_arc == sweep ? -1 : 1
48
+ num = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq)
49
+ den = (rx_sq * y1p_sq) + (ry_sq * x1p_sq)
50
+ den = EPSILON if den.abs < EPSILON
51
+ coef = sign * Math.sqrt([num / den, 0.0].max)
52
+ cxp = coef * (rx * y1p) / ry
53
+ cyp = -coef * (ry * x1p) / rx
54
+
55
+ # Step 3: rotate (cx', cy') back into original frame.
56
+ cx = (cos_phi * cxp) - (sin_phi * cyp) + ((x1 + x2) / 2.0)
57
+ cy = (sin_phi * cxp) + (cos_phi * cyp) + ((y1 + y2) / 2.0)
58
+
59
+ # Step 4: compute angles.
60
+ ux = (x1p - cxp) / rx
61
+ uy = (y1p - cyp) / ry
62
+ vx = (-x1p - cxp) / rx
63
+ vy = (-y1p - cyp) / ry
64
+
65
+ theta1 = angle_between(1.0, 0.0, ux, uy)
66
+ delta_theta = angle_between(ux, uy, vx, vy) % (2 * Math::PI)
67
+ delta_theta -= 2 * Math::PI if !sweep && delta_theta.positive?
68
+
69
+ theta2 = theta1 + delta_theta
70
+
71
+ [cx, cy, rx, ry, phi, theta1 * 180.0 / Math::PI, theta2 * 180.0 / Math::PI]
72
+ end
73
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
74
+
75
+ def angle_between(ux, uy, vx, vy)
76
+ len = vector_length(ux, uy, vx, vy)
77
+ return 0.0 if len < EPSILON
78
+
79
+ cos_val = (dot_product(ux, uy, vx, vy) / len).clamp(-1.0, 1.0)
80
+ cross_sign(ux, uy, vx, vy) * Math.acos(cos_val)
81
+ end
82
+
83
+ def vector_length(ux, uy, vx, vy)
84
+ Math.sqrt(((ux * ux) + (uy * uy)) * ((vx * vx) + (vy * vy)))
85
+ end
86
+
87
+ def dot_product(ux, uy, vx, vy)
88
+ (ux * vx) + (uy * vy)
89
+ end
90
+
91
+ def cross_sign(ux, uy, vx, vy)
92
+ ((ux * vy) - (uy * vx)).negative? ? -1.0 : 1.0
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,62 @@
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),
31
+ x_range.begin].compact.min
32
+ new_max_x = [(empty? ? x_range.end : @max_x), x_range.end].compact.max
33
+ new_min_y = [(empty? ? y_range.begin : @min_y),
34
+ y_range.begin].compact.min
35
+ new_max_y = [(empty? ? y_range.end : @max_y), y_range.end].compact.max
36
+ BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x,
37
+ max_y: new_max_y)
38
+ end
39
+
40
+ def to_a
41
+ empty? ? [] : [@min_x, @min_y, @max_x, @max_y]
42
+ end
43
+
44
+ def width
45
+ empty? ? 0 : @max_x - @min_x
46
+ end
47
+
48
+ def height
49
+ empty? ? 0 : @max_y - @min_y
50
+ end
51
+
52
+ def to_dsc_comment
53
+ return nil if empty?
54
+
55
+ "%s %s %s %s" % [
56
+ FormatNumber.call(@min_x), FormatNumber.call(@min_y),
57
+ FormatNumber.call(@max_x), FormatNumber.call(@max_y)
58
+ ]
59
+ end
60
+ end
61
+ end
62
+ 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