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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -0
- data/CLAUDE.md +173 -0
- data/Gemfile +2 -3
- data/README.adoc +456 -179
- data/Rakefile +100 -0
- data/TODO.roadmap/00-architecture.md +139 -0
- data/TODO.roadmap/01-autoload-migration.md +39 -0
- data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
- data/TODO.roadmap/03-domain-model.md +66 -0
- data/TODO.roadmap/04-lexer.md +40 -0
- data/TODO.roadmap/05-parser.md +43 -0
- data/TODO.roadmap/06-graphics-state.md +45 -0
- data/TODO.roadmap/07-matrix-and-color.md +37 -0
- data/TODO.roadmap/08-svg-builder.md +51 -0
- data/TODO.roadmap/09-renderer.md +52 -0
- data/TODO.roadmap/10-visitor.md +58 -0
- data/TODO.roadmap/11-operator-coverage.md +103 -0
- data/TODO.roadmap/12-svg-domain.md +62 -0
- data/TODO.roadmap/13-translation-handlers.md +69 -0
- data/TODO.roadmap/14-ps-serializer.md +49 -0
- data/TODO.roadmap/15-cli-and-public-api.md +47 -0
- data/TODO.roadmap/16-specs.md +84 -0
- data/TODO.roadmap/17-docs-sync.md +47 -0
- data/TODO.roadmap/18-performance-and-determinism.md +47 -0
- data/TODO.roadmap/19-error-model.md +45 -0
- data/TODO.roadmap/20-font-and-text.md +46 -0
- data/TODO.roadmap/21-images.md +45 -0
- data/TODO.roadmap/22-forms-and-resources.md +25 -0
- data/TODO.roadmap/23-level2-level3.md +52 -0
- data/TODO.roadmap/24-ci-and-release.md +42 -0
- data/TODO.roadmap/README.md +77 -0
- data/docs/.gitignore +29 -0
- data/docs/CHANGELOG.md +114 -0
- data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
- data/docs/DEPLOYMENT.md +456 -0
- data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
- data/docs/DOCUMENTATION_PLAN.md +425 -0
- data/docs/FINAL_SUMMARY.md +657 -0
- data/docs/Gemfile +15 -0
- data/docs/README.md +327 -0
- data/docs/_config.yml +99 -0
- data/docs/advanced-topics.adoc +370 -0
- data/docs/api-reference/colors.adoc +705 -0
- data/docs/api-reference/converter.adoc +699 -0
- data/docs/api-reference/execution-context.adoc +1210 -0
- data/docs/api-reference/graphics-state.adoc +1070 -0
- data/docs/api-reference/interpreter.adoc +810 -0
- data/docs/api-reference/matrix.adoc +1179 -0
- data/docs/api-reference/path-builder.adoc +1284 -0
- data/docs/api-reference/postsvg-module.adoc +388 -0
- data/docs/api-reference/svg-generator.adoc +891 -0
- data/docs/api-reference/tokenizer.adoc +925 -0
- data/docs/api-reference.adoc +221 -0
- data/docs/architecture/command-registry.adoc +1191 -0
- data/docs/architecture/conversion-pipeline.adoc +746 -0
- data/docs/architecture/design-decisions.adoc +999 -0
- data/docs/architecture/generator-stage.adoc +1115 -0
- data/docs/architecture/graphics-state-model.adoc +1089 -0
- data/docs/architecture/interpreter-stage.adoc +1125 -0
- data/docs/architecture/parser-stage.adoc +1051 -0
- data/docs/architecture.adoc +354 -0
- data/docs/cli-reference/batch-command.adoc +616 -0
- data/docs/cli-reference/check-command.adoc +677 -0
- data/docs/cli-reference/cli-options.adoc +802 -0
- data/docs/cli-reference/convert-command.adoc +462 -0
- data/docs/cli-reference/version-command.adoc +296 -0
- data/docs/cli-reference.adoc +317 -0
- data/docs/concepts/conversion-pipeline.adoc +903 -0
- data/docs/concepts/coordinate-systems.adoc +836 -0
- data/docs/concepts/graphics-state.adoc +861 -0
- data/docs/concepts/path-operations.adoc +1076 -0
- data/docs/concepts/postscript-language.adoc +859 -0
- data/docs/concepts/svg-generation.adoc +937 -0
- data/docs/concepts.adoc +198 -0
- data/docs/contributing.adoc +443 -0
- data/docs/development.adoc +420 -0
- data/docs/faq.adoc +493 -0
- data/docs/getting-started/basic-usage.adoc +538 -0
- data/docs/getting-started/common-workflows.adoc +577 -0
- data/docs/getting-started/first-conversion.adoc +492 -0
- data/docs/getting-started/installation.adoc +534 -0
- data/docs/getting-started.adoc +94 -0
- data/docs/index.adoc +248 -0
- data/docs/optimization.adoc +196 -0
- data/docs/ps2svg_compatibility.adoc +149 -0
- data/docs/quick-reference.adoc +453 -0
- data/docs/sitemap.adoc +337 -0
- data/docs/troubleshooting.adoc +486 -0
- data/docs/validation.adoc +772 -0
- data/exe/postsvg +1 -0
- data/lib/postsvg/cli.rb +104 -57
- data/lib/postsvg/color.rb +132 -0
- data/lib/postsvg/errors.rb +68 -3
- data/lib/postsvg/format_number.rb +22 -0
- data/lib/postsvg/graphics_context.rb +80 -0
- data/lib/postsvg/graphics_stack.rb +43 -0
- data/lib/postsvg/model/literals/array.rb +41 -0
- data/lib/postsvg/model/literals/dictionary.rb +34 -0
- data/lib/postsvg/model/literals/hex.rb +37 -0
- data/lib/postsvg/model/literals/name.rb +40 -0
- data/lib/postsvg/model/literals/number.rb +36 -0
- data/lib/postsvg/model/literals/procedure.rb +41 -0
- data/lib/postsvg/model/literals/string.rb +30 -0
- data/lib/postsvg/model/literals.rb +19 -0
- data/lib/postsvg/model/operator.rb +58 -0
- data/lib/postsvg/model/operators/arithmetic.rb +264 -0
- data/lib/postsvg/model/operators/boolean.rb +182 -0
- data/lib/postsvg/model/operators/color.rb +74 -0
- data/lib/postsvg/model/operators/container.rb +186 -0
- data/lib/postsvg/model/operators/control_flow.rb +119 -0
- data/lib/postsvg/model/operators/device.rb +21 -0
- data/lib/postsvg/model/operators/dictionary.rb +118 -0
- data/lib/postsvg/model/operators/font.rb +121 -0
- data/lib/postsvg/model/operators/graphics_state.rb +84 -0
- data/lib/postsvg/model/operators/painting.rb +29 -0
- data/lib/postsvg/model/operators/path.rb +169 -0
- data/lib/postsvg/model/operators/stack.rb +72 -0
- data/lib/postsvg/model/operators/transformations.rb +103 -0
- data/lib/postsvg/model/operators.rb +89 -0
- data/lib/postsvg/model/program.rb +68 -0
- data/lib/postsvg/model/token.rb +43 -0
- data/lib/postsvg/model.rb +17 -0
- data/lib/postsvg/options.rb +29 -0
- data/lib/postsvg/renderer.rb +85 -0
- data/lib/postsvg/serializer.rb +325 -0
- data/lib/postsvg/source/ast_builder.rb +308 -0
- data/lib/postsvg/source/lexer.rb +322 -0
- data/lib/postsvg/source/operand_stack.rb +55 -0
- data/lib/postsvg/source.rb +21 -0
- data/lib/postsvg/svg/attribute_parser.rb +45 -0
- data/lib/postsvg/svg/clip_path_registry.rb +44 -0
- data/lib/postsvg/svg/document.rb +22 -0
- data/lib/postsvg/svg/element.rb +84 -0
- data/lib/postsvg/svg/elements/circle.rb +36 -0
- data/lib/postsvg/svg/elements/clip_path.rb +26 -0
- data/lib/postsvg/svg/elements/defs.rb +24 -0
- data/lib/postsvg/svg/elements/ellipse.rb +38 -0
- data/lib/postsvg/svg/elements/group.rb +37 -0
- data/lib/postsvg/svg/elements/image.rb +35 -0
- data/lib/postsvg/svg/elements/line.rb +36 -0
- data/lib/postsvg/svg/elements/path.rb +32 -0
- data/lib/postsvg/svg/elements/polygon.rb +12 -0
- data/lib/postsvg/svg/elements/polyline.rb +32 -0
- data/lib/postsvg/svg/elements/rect.rb +44 -0
- data/lib/postsvg/svg/elements/svg.rb +39 -0
- data/lib/postsvg/svg/elements/text.rb +42 -0
- data/lib/postsvg/svg/elements.rb +31 -0
- data/lib/postsvg/svg/paint.rb +34 -0
- data/lib/postsvg/svg/parser.rb +39 -0
- data/lib/postsvg/svg/path_data/command.rb +27 -0
- data/lib/postsvg/svg/path_data/parser.rb +82 -0
- data/lib/postsvg/svg/path_data.rb +17 -0
- data/lib/postsvg/svg/stroke.rb +31 -0
- data/lib/postsvg/svg/transform_list.rb +59 -0
- data/lib/postsvg/svg.rb +22 -0
- data/lib/postsvg/svg_builder.rb +249 -0
- data/lib/postsvg/translation/arc_converter.rb +86 -0
- data/lib/postsvg/translation/bounding_box.rb +59 -0
- data/lib/postsvg/translation/context.rb +34 -0
- data/lib/postsvg/translation/handler_registry.rb +38 -0
- data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
- data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
- data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
- data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
- data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
- data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
- data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
- data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
- data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
- data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
- data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
- data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
- data/lib/postsvg/translation/handlers/shared.rb +110 -0
- data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
- data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
- data/lib/postsvg/translation/handlers.rb +25 -0
- data/lib/postsvg/translation/ps_renderer.rb +105 -0
- data/lib/postsvg/translation/record_emitter.rb +35 -0
- data/lib/postsvg/translation.rb +17 -0
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
- data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
- data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
- data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
- data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
- data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
- data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
- data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
- data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
- data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
- data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
- data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
- data/lib/postsvg/visitors/ps_visitor.rb +208 -0
- data/lib/postsvg/visitors.rb +9 -0
- data/lib/postsvg.rb +93 -59
- data/lychee.toml +86 -0
- metadata +216 -11
- 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
|
data/lib/postsvg/svg.rb
ADDED
|
@@ -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("&", "&")
|
|
243
|
+
.gsub("<", "<")
|
|
244
|
+
.gsub(">", ">")
|
|
245
|
+
.gsub('"', """)
|
|
246
|
+
.gsub("'", "'")
|
|
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
|