postsvg 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/CLAUDE.md +173 -0
  4. data/Gemfile +2 -3
  5. data/README.adoc +456 -179
  6. data/Rakefile +100 -0
  7. data/TODO.roadmap/00-architecture.md +139 -0
  8. data/TODO.roadmap/01-autoload-migration.md +39 -0
  9. data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
  10. data/TODO.roadmap/03-domain-model.md +66 -0
  11. data/TODO.roadmap/04-lexer.md +40 -0
  12. data/TODO.roadmap/05-parser.md +43 -0
  13. data/TODO.roadmap/06-graphics-state.md +45 -0
  14. data/TODO.roadmap/07-matrix-and-color.md +37 -0
  15. data/TODO.roadmap/08-svg-builder.md +51 -0
  16. data/TODO.roadmap/09-renderer.md +52 -0
  17. data/TODO.roadmap/10-visitor.md +58 -0
  18. data/TODO.roadmap/11-operator-coverage.md +103 -0
  19. data/TODO.roadmap/12-svg-domain.md +62 -0
  20. data/TODO.roadmap/13-translation-handlers.md +69 -0
  21. data/TODO.roadmap/14-ps-serializer.md +49 -0
  22. data/TODO.roadmap/15-cli-and-public-api.md +47 -0
  23. data/TODO.roadmap/16-specs.md +84 -0
  24. data/TODO.roadmap/17-docs-sync.md +47 -0
  25. data/TODO.roadmap/18-performance-and-determinism.md +47 -0
  26. data/TODO.roadmap/19-error-model.md +45 -0
  27. data/TODO.roadmap/20-font-and-text.md +46 -0
  28. data/TODO.roadmap/21-images.md +45 -0
  29. data/TODO.roadmap/22-forms-and-resources.md +25 -0
  30. data/TODO.roadmap/23-level2-level3.md +52 -0
  31. data/TODO.roadmap/24-ci-and-release.md +42 -0
  32. data/TODO.roadmap/README.md +77 -0
  33. data/docs/.gitignore +29 -0
  34. data/docs/CHANGELOG.md +114 -0
  35. data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
  36. data/docs/DEPLOYMENT.md +456 -0
  37. data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
  38. data/docs/DOCUMENTATION_PLAN.md +425 -0
  39. data/docs/FINAL_SUMMARY.md +657 -0
  40. data/docs/Gemfile +15 -0
  41. data/docs/README.md +327 -0
  42. data/docs/_config.yml +99 -0
  43. data/docs/advanced-topics.adoc +370 -0
  44. data/docs/api-reference/colors.adoc +705 -0
  45. data/docs/api-reference/converter.adoc +699 -0
  46. data/docs/api-reference/execution-context.adoc +1210 -0
  47. data/docs/api-reference/graphics-state.adoc +1070 -0
  48. data/docs/api-reference/interpreter.adoc +810 -0
  49. data/docs/api-reference/matrix.adoc +1179 -0
  50. data/docs/api-reference/path-builder.adoc +1284 -0
  51. data/docs/api-reference/postsvg-module.adoc +388 -0
  52. data/docs/api-reference/svg-generator.adoc +891 -0
  53. data/docs/api-reference/tokenizer.adoc +925 -0
  54. data/docs/api-reference.adoc +221 -0
  55. data/docs/architecture/command-registry.adoc +1191 -0
  56. data/docs/architecture/conversion-pipeline.adoc +746 -0
  57. data/docs/architecture/design-decisions.adoc +999 -0
  58. data/docs/architecture/generator-stage.adoc +1115 -0
  59. data/docs/architecture/graphics-state-model.adoc +1089 -0
  60. data/docs/architecture/interpreter-stage.adoc +1125 -0
  61. data/docs/architecture/parser-stage.adoc +1051 -0
  62. data/docs/architecture.adoc +354 -0
  63. data/docs/cli-reference/batch-command.adoc +616 -0
  64. data/docs/cli-reference/check-command.adoc +677 -0
  65. data/docs/cli-reference/cli-options.adoc +802 -0
  66. data/docs/cli-reference/convert-command.adoc +462 -0
  67. data/docs/cli-reference/version-command.adoc +296 -0
  68. data/docs/cli-reference.adoc +317 -0
  69. data/docs/concepts/conversion-pipeline.adoc +903 -0
  70. data/docs/concepts/coordinate-systems.adoc +836 -0
  71. data/docs/concepts/graphics-state.adoc +861 -0
  72. data/docs/concepts/path-operations.adoc +1076 -0
  73. data/docs/concepts/postscript-language.adoc +859 -0
  74. data/docs/concepts/svg-generation.adoc +937 -0
  75. data/docs/concepts.adoc +198 -0
  76. data/docs/contributing.adoc +443 -0
  77. data/docs/development.adoc +420 -0
  78. data/docs/faq.adoc +493 -0
  79. data/docs/getting-started/basic-usage.adoc +538 -0
  80. data/docs/getting-started/common-workflows.adoc +577 -0
  81. data/docs/getting-started/first-conversion.adoc +492 -0
  82. data/docs/getting-started/installation.adoc +534 -0
  83. data/docs/getting-started.adoc +94 -0
  84. data/docs/index.adoc +248 -0
  85. data/docs/optimization.adoc +196 -0
  86. data/docs/ps2svg_compatibility.adoc +149 -0
  87. data/docs/quick-reference.adoc +453 -0
  88. data/docs/sitemap.adoc +337 -0
  89. data/docs/troubleshooting.adoc +486 -0
  90. data/docs/validation.adoc +772 -0
  91. data/exe/postsvg +1 -0
  92. data/lib/postsvg/cli.rb +104 -57
  93. data/lib/postsvg/color.rb +132 -0
  94. data/lib/postsvg/errors.rb +68 -3
  95. data/lib/postsvg/format_number.rb +22 -0
  96. data/lib/postsvg/graphics_context.rb +80 -0
  97. data/lib/postsvg/graphics_stack.rb +43 -0
  98. data/lib/postsvg/model/literals/array.rb +41 -0
  99. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  100. data/lib/postsvg/model/literals/hex.rb +37 -0
  101. data/lib/postsvg/model/literals/name.rb +40 -0
  102. data/lib/postsvg/model/literals/number.rb +36 -0
  103. data/lib/postsvg/model/literals/procedure.rb +41 -0
  104. data/lib/postsvg/model/literals/string.rb +30 -0
  105. data/lib/postsvg/model/literals.rb +19 -0
  106. data/lib/postsvg/model/operator.rb +58 -0
  107. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  108. data/lib/postsvg/model/operators/boolean.rb +182 -0
  109. data/lib/postsvg/model/operators/color.rb +74 -0
  110. data/lib/postsvg/model/operators/container.rb +186 -0
  111. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  112. data/lib/postsvg/model/operators/device.rb +21 -0
  113. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  114. data/lib/postsvg/model/operators/font.rb +121 -0
  115. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  116. data/lib/postsvg/model/operators/painting.rb +29 -0
  117. data/lib/postsvg/model/operators/path.rb +169 -0
  118. data/lib/postsvg/model/operators/stack.rb +72 -0
  119. data/lib/postsvg/model/operators/transformations.rb +103 -0
  120. data/lib/postsvg/model/operators.rb +89 -0
  121. data/lib/postsvg/model/program.rb +68 -0
  122. data/lib/postsvg/model/token.rb +43 -0
  123. data/lib/postsvg/model.rb +17 -0
  124. data/lib/postsvg/options.rb +29 -0
  125. data/lib/postsvg/renderer.rb +85 -0
  126. data/lib/postsvg/serializer.rb +325 -0
  127. data/lib/postsvg/source/ast_builder.rb +308 -0
  128. data/lib/postsvg/source/lexer.rb +322 -0
  129. data/lib/postsvg/source/operand_stack.rb +55 -0
  130. data/lib/postsvg/source.rb +21 -0
  131. data/lib/postsvg/svg/attribute_parser.rb +45 -0
  132. data/lib/postsvg/svg/clip_path_registry.rb +44 -0
  133. data/lib/postsvg/svg/document.rb +22 -0
  134. data/lib/postsvg/svg/element.rb +84 -0
  135. data/lib/postsvg/svg/elements/circle.rb +36 -0
  136. data/lib/postsvg/svg/elements/clip_path.rb +26 -0
  137. data/lib/postsvg/svg/elements/defs.rb +24 -0
  138. data/lib/postsvg/svg/elements/ellipse.rb +38 -0
  139. data/lib/postsvg/svg/elements/group.rb +37 -0
  140. data/lib/postsvg/svg/elements/image.rb +35 -0
  141. data/lib/postsvg/svg/elements/line.rb +36 -0
  142. data/lib/postsvg/svg/elements/path.rb +32 -0
  143. data/lib/postsvg/svg/elements/polygon.rb +12 -0
  144. data/lib/postsvg/svg/elements/polyline.rb +32 -0
  145. data/lib/postsvg/svg/elements/rect.rb +44 -0
  146. data/lib/postsvg/svg/elements/svg.rb +39 -0
  147. data/lib/postsvg/svg/elements/text.rb +42 -0
  148. data/lib/postsvg/svg/elements.rb +31 -0
  149. data/lib/postsvg/svg/paint.rb +34 -0
  150. data/lib/postsvg/svg/parser.rb +39 -0
  151. data/lib/postsvg/svg/path_data/command.rb +27 -0
  152. data/lib/postsvg/svg/path_data/parser.rb +82 -0
  153. data/lib/postsvg/svg/path_data.rb +17 -0
  154. data/lib/postsvg/svg/stroke.rb +31 -0
  155. data/lib/postsvg/svg/transform_list.rb +59 -0
  156. data/lib/postsvg/svg.rb +22 -0
  157. data/lib/postsvg/svg_builder.rb +249 -0
  158. data/lib/postsvg/translation/arc_converter.rb +86 -0
  159. data/lib/postsvg/translation/bounding_box.rb +59 -0
  160. data/lib/postsvg/translation/context.rb +34 -0
  161. data/lib/postsvg/translation/handler_registry.rb +38 -0
  162. data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
  163. data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
  164. data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
  165. data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
  166. data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
  167. data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
  168. data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
  169. data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
  170. data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
  171. data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
  172. data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
  173. data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
  174. data/lib/postsvg/translation/handlers/shared.rb +110 -0
  175. data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
  176. data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
  177. data/lib/postsvg/translation/handlers.rb +25 -0
  178. data/lib/postsvg/translation/ps_renderer.rb +105 -0
  179. data/lib/postsvg/translation/record_emitter.rb +35 -0
  180. data/lib/postsvg/translation.rb +17 -0
  181. data/lib/postsvg/version.rb +1 -1
  182. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
  183. data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
  184. data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
  185. data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
  186. data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
  187. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
  188. data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
  189. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
  190. data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
  191. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
  192. data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
  193. data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
  194. data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
  195. data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
  196. data/lib/postsvg/visitors/ps_visitor.rb +208 -0
  197. data/lib/postsvg/visitors.rb +9 -0
  198. data/lib/postsvg.rb +93 -59
  199. data/lychee.toml +86 -0
  200. metadata +216 -11
  201. data/postsvg.gemspec +0 -38
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ # Paint value object: resolves a CSS-style paint value to either
6
+ # a Color instance, a paint-server URL reference (gradient /
7
+ # pattern id), or :none.
8
+ Paint = Struct.new(:kind, :value, keyword_init: true) do
9
+ def self.parse(text)
10
+ return Paint.new(kind: :none) if text.nil? || text.strip == "none"
11
+
12
+ if text&.start_with?("url(")
13
+ id = text.match(/url\(\s*["']?#([^)\s"']+)/)
14
+ return Paint.new(kind: :reference, value: id && id[1])
15
+ end
16
+
17
+ color = begin
18
+ ::Postsvg::Color.parse(text.to_s)
19
+ rescue ArgumentError
20
+ nil
21
+ end
22
+ if color
23
+ Paint.new(kind: :color, value: color)
24
+ else
25
+ Paint.new(kind: :none)
26
+ end
27
+ end
28
+
29
+ def none? = kind == :none
30
+ def reference? = kind == :reference
31
+ def color? = kind == :color
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Postsvg
6
+ module Svg
7
+ # Parses an SVG document via Nokogiri and produces an Svg::Document.
8
+ # Pure: no I/O, no global state.
9
+ class Parser
10
+ def self.call(svg_string)
11
+ doc = Nokogiri::XML(svg_string, &:noblanks)
12
+ root = doc.root
13
+ raise TranslationError, "no root element in SVG" unless root
14
+
15
+ Elements.load_all! # populate Element.registry with all known tag handlers
16
+ root_element = Element.from_node(root)
17
+ viewbox, width, height = extract_root_dimensions(root)
18
+ clip_paths = ClipPathRegistry.from_node(root)
19
+ Document.new(root_element: root_element,
20
+ viewbox: viewbox,
21
+ width: width,
22
+ height: height,
23
+ clip_paths: clip_paths)
24
+ end
25
+
26
+ def self.extract_root_dimensions(root)
27
+ vb = root["viewBox"]
28
+ width = root["width"]
29
+ height = root["height"]
30
+ parsed_vb =
31
+ if vb
32
+ nums = vb.split(/\s+|,/).map(&:to_f)
33
+ nums if nums.length == 4
34
+ end
35
+ [parsed_vb, width&.to_f, height&.to_f]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module PathData
6
+ # Single SVG path command. Opcode is upper-case (absolute) or
7
+ # lower-case (relative). +args+ is the raw numeric arguments.
8
+ Command = Struct.new(:opcode, :args, keyword_init: true) do
9
+ def absolute? = opcode == opcode.upcase
10
+
11
+ def relative? = opcode == opcode.downcase
12
+
13
+ def arity
14
+ case opcode.upcase
15
+ when "M", "L", "T" then 2
16
+ when "H", "V" then 1
17
+ when "C" then 6
18
+ when "S", "Q" then 4
19
+ when "A" then 7
20
+ when "Z" then 0
21
+ else 0
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,82 @@
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+)?)/.freeze
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 = (opcode == "M") ? "L" : (opcode == "m" ? "l" : opcode)
40
+ # For H/V/Z, no implicit repetition.
41
+ break if %w[H V Z h v z].include?(opcode)
42
+ end
43
+ end
44
+ commands
45
+ end
46
+
47
+ def arity_for(opcode)
48
+ case opcode.upcase
49
+ when "M", "L", "T" then 2
50
+ when "H", "V" then 1
51
+ when "C" then 6
52
+ when "S", "Q" then 4
53
+ when "A" then 7
54
+ when "Z" then 0
55
+ else 0
56
+ end
57
+ end
58
+
59
+ def tokenize(text)
60
+ text.to_s.gsub(/-/, " -")
61
+ .scan(/([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/)
62
+ .flatten.compact
63
+ end
64
+
65
+ NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/.freeze
66
+
67
+ def take_args(tokens, start, count)
68
+ args = []
69
+ consumed = 0
70
+ while consumed < count && start + consumed < tokens.length
71
+ tok = tokens[start + consumed]
72
+ break unless NUMBER_ONLY_RE.match?(tok.to_s)
73
+
74
+ args << tok.to_f
75
+ consumed += 1
76
+ end
77
+ [args, consumed]
78
+ end
79
+ end
80
+ end
81
+ end
82
+ 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,31 @@
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"], default: 0.0)
18
+ linecap = (node["stroke-linecap"] || "butt").to_sym
19
+ linejoin = (node["stroke-linejoin"] || "miter").to_sym
20
+ miterlimit = AttributeParser.number(node["stroke-miterlimit"], default: 10.0)
21
+ new(width: width, dasharray: dasharray, dashoffset: dashoffset,
22
+ linecap: linecap, linejoin: linejoin, miterlimit: miterlimit)
23
+ end
24
+
25
+ def default?
26
+ width == 1.0 && dasharray.nil? && dashoffset.zero? &&
27
+ linecap == :butt && linejoin == :miter && miterlimit == 10.0
28
+ end
29
+ end
30
+ end
31
+ 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
+ # Postsvg::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,249 @@
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".freeze
15
+ GRADIENT_PREFIX = "grad".freeze
16
+ PATTERN_PREFIX = "pattern".freeze
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, fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
144
+ else
145
+ %( fill="none" stroke="none")
146
+ end
147
+
148
+ attrs << %( stroke-width="#{num(stroke_width)}") if stroke_width && stroke_width != 1.0
149
+ attrs << %( stroke-linecap="#{line_cap}") if line_cap && line_cap != :butt
150
+ attrs << %( stroke-linejoin="#{line_join}") if line_join && line_join != :miter
151
+ attrs << %( stroke-dasharray="#{dash}") if dash
152
+ attrs << %( clip-path="url(##{clip_path_id})") if clip_path_id
153
+
154
+ @body_buffer << %(<path #{attrs} />\n)
155
+ self
156
+ end
157
+
158
+ def text(content:, x:, y:, font_family:, font_size:, color:,
159
+ transform: nil)
160
+ attrs = +""
161
+ attrs << %( transform="#{transform}") if transform
162
+ attrs << %( x="#{num(x)}" y="#{num(y)}")
163
+ attrs << %( font-family="#{escape(font_family)}")
164
+ attrs << %( font-size="#{num(font_size)}")
165
+ attrs << %( fill="#{color_attr(color)}")
166
+ @body_buffer << %(<text#{attrs}>#{escape(content)}</text>\n)
167
+ self
168
+ end
169
+
170
+ def image(href:, x:, y:, width:, height:, transform: nil)
171
+ attrs = +""
172
+ attrs << %( transform="#{transform}") if transform
173
+ attrs << %( x="#{num(x)}" y="#{num(y)}")
174
+ attrs << %( width="#{num(width)}" height="#{num(height)}")
175
+ attrs << %( href="#{escape(href)}")
176
+ @body_buffer << %(<image#{attrs} />\n)
177
+ self
178
+ end
179
+
180
+ def comment(text)
181
+ @body_buffer << %(<!-- #{escape(text)} -->\n)
182
+ self
183
+ end
184
+
185
+ def close_svg
186
+ raise RenderError, "svg not open" unless @svg_open
187
+ raise RenderError, "unclosed groups: #{@group_depth}" if @group_depth.positive?
188
+
189
+ @buffer << "<defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
190
+ @buffer << @defs_buffer
191
+ @buffer << "</defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
192
+ @buffer << @body_buffer
193
+ @buffer << "</svg>\n"
194
+ @svg_open = false
195
+ self
196
+ end
197
+
198
+ def to_s
199
+ raise RenderError, "svg not closed" if @svg_open
200
+
201
+ @buffer.dup
202
+ end
203
+
204
+ # Expose internal buffers for testing only.
205
+ def buffer_size
206
+ @buffer.bytesize + @defs_buffer.bytesize + @body_buffer.bytesize
207
+ end
208
+
209
+ def registered_clip_count
210
+ @clip_paths.size
211
+ end
212
+
213
+ def registered_gradient_count
214
+ @gradients.size
215
+ end
216
+
217
+ private
218
+
219
+ def num(value)
220
+ FormatNumber.call(value)
221
+ end
222
+
223
+ def color_attr(color)
224
+ return "none" if color.nil?
225
+
226
+ color.is_a?(Color) ? color.to_svg : color.to_s
227
+ end
228
+
229
+ def fill_attr(color, fill_id)
230
+ return %( fill="url(##{fill_id})") if fill_id
231
+
232
+ %( fill="#{color_attr(color)}")
233
+ end
234
+
235
+ def gradient_signature(kind, stops, coords)
236
+ stops_str = stops.map { |s| "#{s[:offset]}:#{s[:color]}" }.join(",")
237
+ "#{kind}|#{stops_str}|#{coords.map { |c| num(c) }.join(',')}"
238
+ end
239
+
240
+ def escape(text)
241
+ text.to_s
242
+ .gsub("&", "&amp;")
243
+ .gsub("<", "&lt;")
244
+ .gsub(">", "&gt;")
245
+ .gsub('"', "&quot;")
246
+ .gsub("'", "&#39;")
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,86 @@
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
+ dot = ux * vx + uy * vy
77
+ len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
78
+ return 0.0 if len < EPSILON
79
+
80
+ cos_val = (dot / len).clamp(-1.0, 1.0)
81
+ sign = (ux * vy - uy * vx).negative? ? -1.0 : 1.0
82
+ sign * Math.acos(cos_val)
83
+ end
84
+ end
85
+ end
86
+ end