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
+ # Base value object for an SVG element. Subclasses (Svg::Elements::*)
6
+ # carry typed semantic fields; the base provides a uniform interface
7
+ # for the translation layer's dispatch.
8
+ #
9
+ # Element instances are immutable: built once from a Nokogiri node
10
+ # via the .from_node factory. The node is not retained.
11
+ #
12
+ # Every element exposes `children`, `transform`, `clip_path_id`,
13
+ # `fill`, `stroke_paint`, `stroke`, `stroke_paint_value`, all
14
+ # defaulting to nil / []. Subclasses override only the attributes
15
+ # that are actually present in the source element. This lets the
16
+ # Translation::Handlers::Shared helpers call any of these methods
17
+ # uniformly without resorting to `respond_to?` type checks.
18
+ class Element
19
+ ELEMENT_NAME = "abstract"
20
+
21
+ attr_reader :transform, :clip_path_id, :attributes,
22
+ :children, :fill, :stroke_paint, :stroke
23
+
24
+ def initialize(transform: nil, clip_path_id: nil, attributes: {},
25
+ children: [], fill: nil, stroke_paint: nil, stroke: nil)
26
+ @transform = transform
27
+ @clip_path_id = clip_path_id
28
+ @attributes = attributes.dup.freeze
29
+ @children = children.freeze
30
+ @fill = fill
31
+ @stroke_paint = stroke_paint
32
+ @stroke = stroke
33
+ end
34
+
35
+ # Default paint: black fill, no stroke. Handlers can override.
36
+ def default_fill
37
+ nil
38
+ end
39
+
40
+ def element_name
41
+ self.class::ELEMENT_NAME
42
+ end
43
+
44
+ # Registry: SVG element name -> Element subclass. OCP: register
45
+ # new subclasses via Element.register("rect", Elements::Rect)
46
+ # instead of editing a switch. Backed by a constant Hash so
47
+ # subclasses share a single registry regardless of +self+ at
48
+ # the call site.
49
+ REGISTRY = {}
50
+
51
+ class << self
52
+ def registry
53
+ Postsvg::Svg::Element::REGISTRY
54
+ end
55
+
56
+ def register(element_name, subclass)
57
+ registry[element_name] = subclass
58
+ end
59
+
60
+ def from_node(node)
61
+ subclass = registry[node.name]
62
+ return subclass.from_node(node) if subclass
63
+
64
+ OpenElement.new(name: node.name,
65
+ children: node.element_children.map do |c|
66
+ from_node(c)
67
+ end)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Fallback for unknown SVG element types. Carries the element name
73
+ # so handlers can skip or warn; carries children so traversal
74
+ # still descends into known subtrees.
75
+ class OpenElement < Element
76
+ ELEMENT_NAME = "open"
77
+
78
+ attr_reader :name
79
+
80
+ def initialize(name:, **rest)
81
+ super(**rest)
82
+ @name = name
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Circle < Element
7
+ ELEMENT_NAME = "circle"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :cx, :cy, :r, :fill, :stroke_paint, :stroke
11
+
12
+ def initialize(cx:, cy:, r:, fill:, stroke_paint:, stroke:, **rest)
13
+ super(**rest)
14
+ @cx = cx
15
+ @cy = cy
16
+ @r = r
17
+ @fill = fill
18
+ @stroke_paint = stroke_paint
19
+ @stroke = stroke
20
+ end
21
+
22
+ def self.from_node(node)
23
+ new(cx: AttributeParser.number(node["cx"], default: 0),
24
+ cy: AttributeParser.number(node["cy"], default: 0),
25
+ r: AttributeParser.number(node["r"], default: 0),
26
+ fill: Paint.parse(node["fill"]),
27
+ stroke_paint: Paint.parse(node["stroke"]),
28
+ stroke: Stroke.parse(node),
29
+ transform: TransformList.parse(node["transform"]),
30
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
31
+ attributes: Elements.node_attributes(node))
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class ClipPath < Element
7
+ ELEMENT_NAME = "clipPath"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :id, :children
11
+
12
+ def initialize(id:, children: [], **rest)
13
+ super(**rest)
14
+ @id = id
15
+ @children = children.freeze
16
+ end
17
+
18
+ def self.from_node(node)
19
+ new(id: node["id"],
20
+ children: node.element_children.map { |c| Element.from_node(c) },
21
+ attributes: Elements.node_attributes(node))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Defs < Element
7
+ ELEMENT_NAME = "defs"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :children
11
+
12
+ def initialize(children: [], **rest)
13
+ super(**rest)
14
+ @children = children.freeze
15
+ end
16
+
17
+ def self.from_node(node)
18
+ new(children: node.element_children.map { |c| Element.from_node(c) },
19
+ attributes: Elements.node_attributes(node))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Ellipse < Element
7
+ ELEMENT_NAME = "ellipse"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :cx, :cy, :rx, :ry, :fill, :stroke_paint, :stroke
11
+
12
+ def initialize(cx:, cy:, rx:, ry:, fill:, stroke_paint:, stroke:,
13
+ **rest)
14
+ super(**rest)
15
+ @cx = cx
16
+ @cy = cy
17
+ @rx = rx
18
+ @ry = ry
19
+ @fill = fill
20
+ @stroke_paint = stroke_paint
21
+ @stroke = stroke
22
+ end
23
+
24
+ def self.from_node(node)
25
+ new(cx: AttributeParser.number(node["cx"], default: 0),
26
+ cy: AttributeParser.number(node["cy"], default: 0),
27
+ rx: AttributeParser.number(node["rx"], default: 0),
28
+ ry: AttributeParser.number(node["ry"], default: 0),
29
+ fill: Paint.parse(node["fill"]),
30
+ stroke_paint: Paint.parse(node["stroke"]),
31
+ stroke: Stroke.parse(node),
32
+ transform: TransformList.parse(node["transform"]),
33
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
34
+ attributes: Elements.node_attributes(node))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Group < Element
7
+ ELEMENT_NAME = "g"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :children
11
+
12
+ def initialize(children: [], **rest)
13
+ super(**rest)
14
+ @children = children.freeze
15
+ end
16
+
17
+ def self.from_node(node)
18
+ new(children: node.element_children.map { |c| Element.from_node(c) },
19
+ transform: TransformList.parse(node["transform"]),
20
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
21
+ attributes: Elements.node_attributes(node))
22
+ end
23
+ end
24
+
25
+ def self.node_attributes(node)
26
+ attrs = {}
27
+ node.attribute_nodes.each { |a| attrs[a.name] = a.value }
28
+ attrs
29
+ end
30
+
31
+ def self.parse_clip_path_id(text)
32
+ m = text&.match(/url\(\s*["']?#([^)\s"']+)/)
33
+ m && m[1]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Image < Element
7
+ ELEMENT_NAME = "image"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :x, :y, :width, :height, :href
11
+
12
+ def initialize(x:, y:, width:, height:, href:, **rest)
13
+ super(**rest)
14
+ @x = x
15
+ @y = y
16
+ @width = width
17
+ @height = height
18
+ @href = href
19
+ end
20
+
21
+ def self.from_node(node)
22
+ href = node["href"] || node["xlink:href"]
23
+ new(x: AttributeParser.number(node["x"], default: 0),
24
+ y: AttributeParser.number(node["y"], default: 0),
25
+ width: AttributeParser.number(node["width"], default: 0),
26
+ height: AttributeParser.number(node["height"], default: 0),
27
+ href: href,
28
+ transform: TransformList.parse(node["transform"]),
29
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
30
+ attributes: Elements.node_attributes(node))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Line < Element
7
+ ELEMENT_NAME = "line"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :x1, :y1, :x2, :y2, :stroke_paint, :stroke
11
+
12
+ def initialize(x1:, y1:, x2:, y2:, stroke_paint:, stroke:, **rest)
13
+ super(**rest)
14
+ @x1 = x1
15
+ @y1 = y1
16
+ @x2 = x2
17
+ @y2 = y2
18
+ @stroke_paint = stroke_paint
19
+ @stroke = stroke
20
+ end
21
+
22
+ def self.from_node(node)
23
+ new(x1: AttributeParser.number(node["x1"], default: 0),
24
+ y1: AttributeParser.number(node["y1"], default: 0),
25
+ x2: AttributeParser.number(node["x2"], default: 0),
26
+ y2: AttributeParser.number(node["y2"], default: 0),
27
+ stroke_paint: Paint.parse(node["stroke"]),
28
+ stroke: Stroke.parse(node),
29
+ transform: TransformList.parse(node["transform"]),
30
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
31
+ attributes: Elements.node_attributes(node))
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Path < Element
7
+ ELEMENT_NAME = "path"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :commands, :fill, :stroke_paint, :stroke
11
+
12
+ def initialize(commands:, fill:, stroke_paint:, stroke:, **rest)
13
+ super(**rest)
14
+ @commands = commands.freeze
15
+ @fill = fill
16
+ @stroke_paint = stroke_paint
17
+ @stroke = stroke
18
+ end
19
+
20
+ def self.from_node(node)
21
+ new(commands: PathData.parse(node["d"]),
22
+ fill: Paint.parse(node["fill"]),
23
+ stroke_paint: Paint.parse(node["stroke"]),
24
+ stroke: Stroke.parse(node),
25
+ transform: TransformList.parse(node["transform"]),
26
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
27
+ attributes: Elements.node_attributes(node))
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Polygon < Polyline
7
+ ELEMENT_NAME = "polygon"
8
+ register ELEMENT_NAME, self
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Polyline < Element
7
+ ELEMENT_NAME = "polyline"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :points, :fill, :stroke_paint, :stroke
11
+
12
+ def initialize(points:, fill:, stroke_paint:, stroke:, **rest)
13
+ super(**rest)
14
+ @points = points.freeze
15
+ @fill = fill
16
+ @stroke_paint = stroke_paint
17
+ @stroke = stroke
18
+ end
19
+
20
+ def self.from_node(node)
21
+ new(points: AttributeParser.number_list(node["points"]),
22
+ fill: Paint.parse(node["fill"]),
23
+ stroke_paint: Paint.parse(node["stroke"]),
24
+ stroke: Stroke.parse(node),
25
+ transform: TransformList.parse(node["transform"]),
26
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
27
+ attributes: Elements.node_attributes(node))
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Rect < Element
7
+ ELEMENT_NAME = "rect"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :x, :y, :width, :height, :rx, :ry,
11
+ :fill, :stroke_paint, :stroke
12
+
13
+ def initialize(x:, y:, width:, height:, rx:, ry:, fill:,
14
+ stroke_paint:, stroke:, **rest)
15
+ super(**rest)
16
+ @x = x
17
+ @y = y
18
+ @width = width
19
+ @height = height
20
+ @rx = rx
21
+ @ry = ry
22
+ @fill = fill
23
+ @stroke_paint = stroke_paint
24
+ @stroke = stroke
25
+ end
26
+
27
+ def self.from_node(node)
28
+ new(x: AttributeParser.number(node["x"], default: 0),
29
+ y: AttributeParser.number(node["y"], default: 0),
30
+ width: AttributeParser.number(node["width"], default: 0),
31
+ height: AttributeParser.number(node["height"], default: 0),
32
+ rx: AttributeParser.number(node["rx"]),
33
+ ry: AttributeParser.number(node["ry"]),
34
+ fill: Paint.parse(node["fill"]),
35
+ stroke_paint: Paint.parse(node["stroke"]),
36
+ stroke: Stroke.parse(node),
37
+ transform: TransformList.parse(node["transform"]),
38
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
39
+ attributes: Elements.node_attributes(node))
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Svg < Element
7
+ ELEMENT_NAME = "svg"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :viewbox, :width, :height, :children
11
+
12
+ def initialize(viewbox: nil, width: nil, height: nil, children: [],
13
+ **rest)
14
+ super(**rest)
15
+ @viewbox = viewbox
16
+ @width = width
17
+ @height = height
18
+ @children = children.freeze
19
+ end
20
+
21
+ def self.from_node(node)
22
+ children = node.element_children.map { |c| Element.from_node(c) }
23
+ vb = node["viewBox"]&.split(/[\s,]+/)&.map(&:to_f)
24
+ new(viewbox: (vb if vb&.length == 4),
25
+ width: AttributeParser.length(node["width"]),
26
+ height: AttributeParser.length(node["height"]),
27
+ children: children,
28
+ transform: TransformList.parse(node["transform"]),
29
+ clip_path_id: parse_clip_path_id(node["clip-path"]))
30
+ end
31
+
32
+ def self.parse_clip_path_id(text)
33
+ m = text&.match(/url\(\s*["']?#([^)\s"']+)/)
34
+ m && m[1]
35
+ end
36
+ private_class_method :parse_clip_path_id
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ module Elements
6
+ class Text < Element
7
+ ELEMENT_NAME = "text"
8
+ register ELEMENT_NAME, self
9
+
10
+ attr_reader :x, :y, :content, :font_family, :font_size,
11
+ :fill, :stroke_paint, :stroke
12
+
13
+ def initialize(x:, y:, content:, font_family:, font_size:,
14
+ fill:, stroke_paint:, stroke:, **rest)
15
+ super(**rest)
16
+ @x = x
17
+ @y = y
18
+ @content = content
19
+ @font_family = font_family
20
+ @font_size = font_size
21
+ @fill = fill
22
+ @stroke_paint = stroke_paint
23
+ @stroke = stroke
24
+ end
25
+
26
+ def self.from_node(node)
27
+ new(x: AttributeParser.number(node["x"], default: 0),
28
+ y: AttributeParser.number(node["y"], default: 0),
29
+ content: node.text,
30
+ font_family: node["font-family"] || "Helvetica",
31
+ font_size: AttributeParser.number(node["font-size"], default: 12),
32
+ fill: Paint.parse(node["fill"]),
33
+ stroke_paint: Paint.parse(node["stroke"]),
34
+ stroke: Stroke.parse(node),
35
+ transform: TransformList.parse(node["transform"]),
36
+ clip_path_id: Elements.parse_clip_path_id(node["clip-path"]),
37
+ attributes: Elements.node_attributes(node))
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ module Svg
5
+ # Element subclass catalogue. Each subclass is registered with its
6
+ # SVG tag name. The +load_all!+ class method forces each autoload
7
+ # to fire; this populates the Element.registry so Svg::Parser can
8
+ # dispatch by tag name.
9
+ module Elements
10
+ autoload :Svg, "postsvg/svg/elements/svg"
11
+ autoload :Group, "postsvg/svg/elements/group"
12
+ autoload :Path, "postsvg/svg/elements/path"
13
+ autoload :Rect, "postsvg/svg/elements/rect"
14
+ autoload :Circle, "postsvg/svg/elements/circle"
15
+ autoload :Ellipse, "postsvg/svg/elements/ellipse"
16
+ autoload :Line, "postsvg/svg/elements/line"
17
+ autoload :Polyline, "postsvg/svg/elements/polyline"
18
+ autoload :Polygon, "postsvg/svg/elements/polygon"
19
+ autoload :Text, "postsvg/svg/elements/text"
20
+ autoload :Image, "postsvg/svg/elements/image"
21
+ autoload :Defs, "postsvg/svg/elements/defs"
22
+ autoload :ClipPath, "postsvg/svg/elements/clip_path"
23
+
24
+ class << self
25
+ def load_all!
26
+ constants.each { |c| const_get(c) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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
+ ::Postscript::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