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