stationery 0.7.0 → 0.9.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 +22 -0
- data/README.md +251 -23
- data/lib/stationery/canvas.rb +22 -1
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +69 -9
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +16 -0
- data/lib/stationery/pdf/assembler.rb +51 -11
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +141 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +34 -26
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +144 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +123 -0
- data/lib/stationery/testing/matchers.rb +92 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +11 -0
- metadata +16 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# The XMP metadata packet (ISO 16684-1) mirroring the document info:
|
|
6
|
+
# Dublin Core title, creator, description, subject and language, the
|
|
7
|
+
# `xmp:` dates and creator tool, and `pdf:` producer and keywords. Later
|
|
8
|
+
# identification schemas (PDF/A, PDF/UA, Factur-X) go in `extensions:`.
|
|
9
|
+
#
|
|
10
|
+
# The packet is deterministic for the same info and time: no instance
|
|
11
|
+
# ids, so two renders of one document are byte-identical.
|
|
12
|
+
module XMP
|
|
13
|
+
PACKET_ID = "W5M0MpCehiHzreSzNTczkc9d"
|
|
14
|
+
PADDING = "#{" " * 63}\n" * 32 # 2 KB of whitespace, as the spec suggests
|
|
15
|
+
DC = "http://purl.org/dc/elements/1.1/"
|
|
16
|
+
XMP_NS = "http://ns.adobe.com/xap/1.0/"
|
|
17
|
+
PDF_NS = "http://ns.adobe.com/pdf/1.3/"
|
|
18
|
+
RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
19
|
+
PDFA_EXTENSION = { "pdfaExtension" => "http://www.aiim.org/pdfa/ns/extension/",
|
|
20
|
+
"pdfaSchema" => "http://www.aiim.org/pdfa/ns/schema#",
|
|
21
|
+
"pdfaProperty" => "http://www.aiim.org/pdfa/ns/property#" }.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# `info` is the Info dictionary's keys (`:Title`, `:Author`, `:Subject`,
|
|
26
|
+
# `:Keywords`, `:Creator`, `:Producer`), `lang` the catalog language,
|
|
27
|
+
# `time` the creation instant, and `extensions` further schemas as
|
|
28
|
+
# `{ namespace_uri => { prefix: "pdfaid", "part" => 3, "conformance" => "B" } }`:
|
|
29
|
+
# each becomes one `rdf:Description`; Array values become an `rdf:Bag`.
|
|
30
|
+
# `schemas` describes extension schemas PDF/A does not know by itself:
|
|
31
|
+
# `[{ name:, uri:, prefix:, properties: [{ name:, type:, category:, description: }] }]`.
|
|
32
|
+
def packet(info: {}, lang: nil, time: Time.now, extensions: {}, schemas: [])
|
|
33
|
+
descriptions = [dublin_core(info, lang), xmp_schema(info, time), pdf_schema(info)]
|
|
34
|
+
descriptions << extension_schemas(schemas)
|
|
35
|
+
descriptions.concat(extensions.map { |uri, values| extension(uri, values) })
|
|
36
|
+
body = <<~XML.gsub(/^ *\n/, "")
|
|
37
|
+
<?xpacket begin="" id="#{PACKET_ID}"?>
|
|
38
|
+
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
|
39
|
+
<rdf:RDF xmlns:rdf="#{RDF}">
|
|
40
|
+
#{descriptions.compact.join("\n")}
|
|
41
|
+
</rdf:RDF>
|
|
42
|
+
</x:xmpmeta>
|
|
43
|
+
XML
|
|
44
|
+
"#{body}#{PADDING}<?xpacket end=\"w\"?>\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def escape(value)
|
|
48
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dublin_core(info, lang)
|
|
52
|
+
keywords = info[:Keywords].to_s.split(/,\s*/).reject(&:empty?)
|
|
53
|
+
elements = [
|
|
54
|
+
alt("dc:title", info[:Title]),
|
|
55
|
+
seq("dc:creator", info[:Author]),
|
|
56
|
+
alt("dc:description", info[:Subject]),
|
|
57
|
+
bag("dc:subject", keywords),
|
|
58
|
+
bag("dc:language", lang && [lang])
|
|
59
|
+
]
|
|
60
|
+
description(DC, "dc", elements)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def xmp_schema(info, time)
|
|
64
|
+
stamp = time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
65
|
+
elements = [
|
|
66
|
+
element("xmp:CreateDate", stamp), element("xmp:ModifyDate", stamp), element("xmp:MetadataDate", stamp),
|
|
67
|
+
element("xmp:CreatorTool", info[:Creator])
|
|
68
|
+
]
|
|
69
|
+
description(XMP_NS, "xmp", elements)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pdf_schema(info)
|
|
73
|
+
description(PDF_NS, "pdf", [element("pdf:Producer", info[:Producer]), element("pdf:Keywords", info[:Keywords])])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extension(uri, values)
|
|
77
|
+
prefix = values.fetch(:prefix) { raise ArgumentError, "XMP extension #{uri} needs a :prefix" }
|
|
78
|
+
elements = values.except(:prefix).map do |name, value|
|
|
79
|
+
value.is_a?(Array) ? bag("#{prefix}:#{name}", value) : element("#{prefix}:#{name}", value)
|
|
80
|
+
end
|
|
81
|
+
description(uri, prefix, elements)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The PDF/A extension schema container describing each of `schemas`.
|
|
85
|
+
def extension_schemas(schemas)
|
|
86
|
+
return if schemas.empty?
|
|
87
|
+
|
|
88
|
+
namespaces = PDFA_EXTENSION.map { |prefix, uri| %(xmlns:#{prefix}="#{uri}") }.join(" ")
|
|
89
|
+
items = schemas.map { |schema| " #{schema_item(schema)}\n" }.join
|
|
90
|
+
<<~XML.chomp.gsub(/^/, " ")
|
|
91
|
+
<rdf:Description rdf:about="" #{namespaces}>
|
|
92
|
+
<pdfaExtension:schemas><rdf:Bag>
|
|
93
|
+
#{items.chomp}
|
|
94
|
+
</rdf:Bag></pdfaExtension:schemas>
|
|
95
|
+
</rdf:Description>
|
|
96
|
+
XML
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def schema_item(schema)
|
|
100
|
+
properties = schema.fetch(:properties).map do |property|
|
|
101
|
+
resource("pdfaProperty", name: property.fetch(:name), valueType: property.fetch(:type),
|
|
102
|
+
category: property.fetch(:category), description: property.fetch(:description))
|
|
103
|
+
end
|
|
104
|
+
head = { schema: schema.fetch(:name), namespaceURI: schema.fetch(:uri), prefix: schema.fetch(:prefix) }
|
|
105
|
+
resource("pdfaSchema", **head) do
|
|
106
|
+
"<pdfaSchema:property><rdf:Seq>#{properties.join}</rdf:Seq></pdfaSchema:property>"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resource(prefix, **values)
|
|
111
|
+
elements = values.map { |name, value| element("#{prefix}:#{name}", value) }.join
|
|
112
|
+
%(<rdf:li rdf:parseType="Resource">#{elements}#{yield if block_given?}</rdf:li>)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def description(uri, prefix, elements)
|
|
116
|
+
elements = elements.compact
|
|
117
|
+
return if elements.empty?
|
|
118
|
+
|
|
119
|
+
" <rdf:Description rdf:about=\"\" xmlns:#{prefix}=\"#{escape(uri)}\">\n" \
|
|
120
|
+
"#{elements.map { |line| " #{line}\n" }.join} </rdf:Description>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def element(name, value)
|
|
124
|
+
return if blank?(value)
|
|
125
|
+
|
|
126
|
+
"<#{name}>#{escape(value)}</#{name}>"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def alt(name, value)
|
|
130
|
+
return if blank?(value)
|
|
131
|
+
|
|
132
|
+
"<#{name}><rdf:Alt><rdf:li xml:lang=\"x-default\">#{escape(value)}</rdf:li></rdf:Alt></#{name}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def seq(name, value) = list(name, "rdf:Seq", value)
|
|
136
|
+
def bag(name, value) = list(name, "rdf:Bag", value)
|
|
137
|
+
|
|
138
|
+
def list(name, container, values)
|
|
139
|
+
values = Array(values).reject { |value| blank?(value) }
|
|
140
|
+
return if values.empty?
|
|
141
|
+
|
|
142
|
+
items = values.map { |value| "<rdf:li>#{escape(value)}</rdf:li>" }.join
|
|
143
|
+
"<#{name}><#{container}>#{items}</#{container}></#{name}>"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def blank?(value) = value.nil? || value.to_s.empty?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/stationery/resources.rb
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
# How deep one html or markdown source may nest, and how deep it went.
|
|
6
|
+
# Parsing, layout and painting all recurse over the nesting, so a parser
|
|
7
|
+
# flattens what lies deeper than `limit` into the deepest element it kept
|
|
8
|
+
# (text stays, structure goes) and records the depth it saw here.
|
|
9
|
+
class Nesting
|
|
10
|
+
DEFAULT = 64
|
|
11
|
+
|
|
12
|
+
attr_reader :limit, :deepest
|
|
13
|
+
|
|
14
|
+
def initialize(limit = DEFAULT)
|
|
15
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
16
|
+
raise ArgumentError, "max_depth must be a positive Integer (got #{limit.inspect})"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@limit = limit
|
|
20
|
+
@deepest = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(depth)
|
|
24
|
+
@deepest = depth if depth > @deepest
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exceeded? = @deepest > @limit
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Block quotes and lists each indent what they hold, so nested deep
|
|
7
|
+
# enough they leave their text no width and it is lost. Past LIMIT
|
|
8
|
+
# levels the content is drawn where the last indented level put it:
|
|
9
|
+
# a block quote without its bar, list items without their markers.
|
|
10
|
+
module Indents
|
|
11
|
+
LIMIT = 12
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def indented(block)
|
|
16
|
+
return flat(block) if @indents >= LIMIT
|
|
17
|
+
|
|
18
|
+
@indents += 1
|
|
19
|
+
begin
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
@indents -= 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flat(block) = group(block.is_a?(List) ? block.items.flatten(1) : block.blocks)
|
|
27
|
+
|
|
28
|
+
# How many block quotes and lists deep the blocks go.
|
|
29
|
+
def indents_of(blocks)
|
|
30
|
+
blocks.map do |block|
|
|
31
|
+
case block
|
|
32
|
+
when Blockquote then 1 + indents_of(block.blocks)
|
|
33
|
+
when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
|
|
34
|
+
when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
|
|
35
|
+
else 0
|
|
36
|
+
end
|
|
37
|
+
end.max || 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "nodes"
|
|
4
4
|
require_relative "styles"
|
|
5
|
+
require_relative "renderer/indents"
|
|
5
6
|
require_relative "renderer/inlines"
|
|
6
7
|
require_relative "renderer/links"
|
|
7
8
|
|
|
@@ -9,8 +10,12 @@ module Stationery
|
|
|
9
10
|
module Rich
|
|
10
11
|
# Draws rich-text blocks with a component's element DSL. Images come from
|
|
11
12
|
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
12
|
-
# from files under `base_path:`; remote URLs are never fetched.
|
|
13
|
+
# from files under `base_path:`; remote URLs are never fetched. Block
|
|
14
|
+
# quotes and lists nested deeper than Indents::LIMIT stop indenting, which
|
|
15
|
+
# is reported as a NestingLimit warning.
|
|
13
16
|
class Renderer
|
|
17
|
+
include Indents
|
|
18
|
+
|
|
14
19
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
15
20
|
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
16
21
|
|
|
@@ -23,18 +28,25 @@ module Stationery
|
|
|
23
28
|
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
24
29
|
@bookmarks = bookmarks
|
|
25
30
|
@links = Links.new(links || LINK_SCHEMES, builder.warnings)
|
|
31
|
+
@indents = 0
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
def render(blocks)
|
|
34
|
+
def render(blocks)
|
|
35
|
+
depth = indents_of(blocks)
|
|
36
|
+
@builder.warnings << Warnings::NestingLimit.new(depth:, limit: Indents::LIMIT) if depth > Indents::LIMIT
|
|
37
|
+
group(blocks)
|
|
38
|
+
end
|
|
29
39
|
|
|
30
40
|
private
|
|
31
41
|
|
|
42
|
+
def group(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
43
|
+
|
|
32
44
|
def block(block)
|
|
33
45
|
case block
|
|
34
46
|
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
35
47
|
when Heading then heading(block)
|
|
36
|
-
when List then list(block)
|
|
37
|
-
when Blockquote then
|
|
48
|
+
when List then indented(block) { list(block) }
|
|
49
|
+
when Blockquote then indented(block) { blockquote(block) }
|
|
38
50
|
when CodeBlock then code(block)
|
|
39
51
|
when Rule then @component.rule(**@styles[:hr])
|
|
40
52
|
when Table then table(block)
|
|
@@ -59,11 +71,15 @@ module Stationery
|
|
|
59
71
|
{ title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
|
|
60
72
|
end
|
|
61
73
|
|
|
74
|
+
def blockquote(quote)
|
|
75
|
+
@component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
def list(list)
|
|
63
79
|
options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
|
|
64
80
|
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
65
81
|
list.items.each do |blocks|
|
|
66
|
-
@component.li { @component.text_style(**@styles[:li]) {
|
|
82
|
+
@component.li { @component.text_style(**@styles[:li]) { group(blocks) } }
|
|
67
83
|
end
|
|
68
84
|
end
|
|
69
85
|
end
|
|
@@ -81,7 +97,7 @@ module Stationery
|
|
|
81
97
|
|
|
82
98
|
def cell_content(cell)
|
|
83
99
|
style = cell.header ? @styles[:table][:header] : {}
|
|
84
|
-
-> { @component.text_style(**style, align: cell.align) {
|
|
100
|
+
-> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
|
|
85
101
|
end
|
|
86
102
|
|
|
87
103
|
def image(node)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# Turns `clip-path: url(#id)` into the Region an element is painted in.
|
|
6
|
+
# The shapes of the clipPath (and the shapes its `use` children refer to)
|
|
7
|
+
# are outlined in the user space of the clipped element, under the clip
|
|
8
|
+
# path's own transform and theirs; `clipPathUnits="objectBoundingBox"`
|
|
9
|
+
# scales them to the element's box. They join into one clipping path, so
|
|
10
|
+
# overlapping shapes wound in opposite directions cancel where they
|
|
11
|
+
# overlap. A clip path's own clip-path is the region around it. Text in
|
|
12
|
+
# a clip path is skipped and reported.
|
|
13
|
+
class ClipPath
|
|
14
|
+
OUTLINED = (Shapes::NAMES + %w[text]).freeze
|
|
15
|
+
|
|
16
|
+
def initialize(ids, walker)
|
|
17
|
+
@ids = ids
|
|
18
|
+
@walker = walker
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The Region for `element`, drawn with `style`; nil without such a clip
|
|
22
|
+
# path, when the element is painted unclipped.
|
|
23
|
+
def region(id, element, style, seen = [])
|
|
24
|
+
clip = @ids[id]
|
|
25
|
+
return @walker.issue("clip-path: ##{id} not found") unless clip&.name == "clipPath"
|
|
26
|
+
return @walker.issue("clip-path: circular reference ##{id}") if seen.include?(id)
|
|
27
|
+
|
|
28
|
+
base = user_space(clip, element, style) or return Region::EMPTY
|
|
29
|
+
own = base.child(clip)
|
|
30
|
+
outlines = clip.children.grep(Parser::Element).filter_map { |child| outline(child, own.child(child), id) }
|
|
31
|
+
outer = own.clip_path && region(own.clip_path, element, style, seen + [id])
|
|
32
|
+
Region.new(outlines.map(&:first), outlines.any?(&:last), outer)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# The style the clip path's content starts from; nil when the units
|
|
38
|
+
# are the element's box and it has none.
|
|
39
|
+
def user_space(clip, element, style)
|
|
40
|
+
space = style.at(style.matrix, Style::DEFAULTS)
|
|
41
|
+
return space unless clip.attributes["clipPathUnits"] == "objectBoundingBox"
|
|
42
|
+
|
|
43
|
+
x, y, width, height = @walker.box(element, style)
|
|
44
|
+
space.transformed([width, 0, 0, height, x, y]) if x
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [part, even-odd?] for a child that outlines something.
|
|
48
|
+
def outline(child, style, id)
|
|
49
|
+
return unless style.displayed? && style.visible?
|
|
50
|
+
|
|
51
|
+
case child.name
|
|
52
|
+
when "use" then used(child, style, id)
|
|
53
|
+
when "text" then @walker.issue("clipPath ##{id}: text")
|
|
54
|
+
when *Shapes::NAMES then [Region::Part.new(style.matrix, Region::Shape.new(child)), style.clip_even_odd?]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A use in a clip path refers to a shape or a text, not to a group.
|
|
59
|
+
def used(child, style, id)
|
|
60
|
+
attributes = child.attributes
|
|
61
|
+
target = @ids[(attributes["href"] || attributes["xlink:href"]).to_s[/\A#(.+)/, 1]]
|
|
62
|
+
return unless target && OUTLINED.include?(target.name)
|
|
63
|
+
|
|
64
|
+
placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
|
|
65
|
+
outline(target, placed.child(target), id)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -3,29 +3,37 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module SVG
|
|
5
5
|
# A parsed SVG drawn as vector paths on a canvas. Supports the subset icon
|
|
6
|
-
# sets use: path, rect (with rx), circle, ellipse, line,
|
|
7
|
-
# and g, with fill, stroke, stroke width/caps/joins,
|
|
8
|
-
# inline styles, <style> stylesheets, transforms,
|
|
9
|
-
# and text/tspan
|
|
6
|
+
# sets and exports use: path, rect (with rx), circle, ellipse, line,
|
|
7
|
+
# polyline, polygon and g, with fill, stroke, stroke width/caps/joins,
|
|
8
|
+
# fill-rule, opacity, inline styles, <style> stylesheets, transforms,
|
|
9
|
+
# linear/radial gradients and text/tspan; `use` (of shapes, groups, text,
|
|
10
|
+
# symbols and other uses), `symbol` and nested `svg` viewports with
|
|
11
|
+
# `preserveAspectRatio`, and `clipPath`. `currentColor` takes the colour
|
|
12
|
+
# you pass, or the `color` an element sets.
|
|
10
13
|
class Document
|
|
11
|
-
SHAPES =
|
|
14
|
+
SHAPES = Shapes::NAMES
|
|
12
15
|
QUIET = (SHAPES + %w[svg g title desc metadata style defs linearGradient radialGradient stop text
|
|
13
|
-
tspan]).freeze
|
|
16
|
+
tspan use symbol clipPath]).freeze
|
|
14
17
|
|
|
15
18
|
def self.parse(source)
|
|
16
|
-
|
|
19
|
+
nesting = nil
|
|
20
|
+
root = Parser.parse(source) { |depth| nesting = depth }
|
|
17
21
|
raise Error, "not an svg document" unless root&.name == "svg"
|
|
18
22
|
|
|
19
|
-
new(root)
|
|
23
|
+
new(root, nesting:)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
# `nesting` is how deep the source nested when that was deeper than
|
|
27
|
+
# Parser::MAX_DEPTH (and was flattened there), else nil.
|
|
28
|
+
attr_reader :view_box, :unsupported, :nesting
|
|
23
29
|
|
|
24
|
-
def initialize(root)
|
|
30
|
+
def initialize(root, nesting: nil)
|
|
25
31
|
@root = root
|
|
32
|
+
@nesting = nesting
|
|
26
33
|
@view_box = read_view_box
|
|
27
34
|
@sheet = Stylesheet.parse(style_text)
|
|
28
35
|
@gradients = Gradient.collect(root, @sheet)
|
|
36
|
+
@ids = index
|
|
29
37
|
@unsupported = (element_names(root).uniq - QUIET).sort + approximations
|
|
30
38
|
end
|
|
31
39
|
|
|
@@ -41,7 +49,7 @@ module Stationery
|
|
|
41
49
|
style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:, sheet: @sheet).child(@root)
|
|
42
50
|
painter = Painter.new(canvas, @gradients, @view_box.last(2))
|
|
43
51
|
text = Text.new(canvas, painter, book || Fonts::FontBook.new, family || Fonts::Bundled::DEFAULT)
|
|
44
|
-
|
|
52
|
+
walker(painter.method(:clip)).children(@root, style) do |element, own|
|
|
45
53
|
element.name == "text" ? text.draw(element, own) : painter.paint(element, own)
|
|
46
54
|
end
|
|
47
55
|
end
|
|
@@ -61,6 +69,14 @@ module Stationery
|
|
|
61
69
|
styles.flat_map(&:children).join(" ")
|
|
62
70
|
end
|
|
63
71
|
|
|
72
|
+
# Every element with an id, the first of a repeated id.
|
|
73
|
+
def index
|
|
74
|
+
ids = {}
|
|
75
|
+
Parser.walk(@root) { |element| ids[element.attributes["id"]] ||= element }
|
|
76
|
+
ids.delete(nil)
|
|
77
|
+
ids
|
|
78
|
+
end
|
|
79
|
+
|
|
64
80
|
def element_names(element)
|
|
65
81
|
element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
|
|
66
82
|
end
|
|
@@ -71,23 +87,15 @@ module Stationery
|
|
|
71
87
|
end
|
|
72
88
|
|
|
73
89
|
# Yields every drawn shape and text with its resolved style, in paint
|
|
74
|
-
# order
|
|
75
|
-
def
|
|
76
|
-
parent.children.each do |element|
|
|
77
|
-
style = parent_style.child(element)
|
|
78
|
-
next unless style.displayed?
|
|
79
|
-
|
|
80
|
-
if element.name == "g" then shapes(element, style, &)
|
|
81
|
-
elsif (SHAPES.include?(element.name) || element.name == "text") && style.visible? then yield element, style
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
90
|
+
# order; `clipper` paints what is clipped (see Walker).
|
|
91
|
+
def walker(clipper = nil) = Walker.new(@ids, @view_box.last(2), clipper:)
|
|
85
92
|
|
|
86
|
-
# Paint references to missing gradients, gradient spreads drawn as pad
|
|
87
|
-
# and stylesheet selectors ignored.
|
|
93
|
+
# Paint references to missing gradients, gradient spreads drawn as pad,
|
|
94
|
+
# uses and clip paths that lead nowhere, and stylesheet selectors ignored.
|
|
88
95
|
def approximations
|
|
96
|
+
walker = self.walker
|
|
89
97
|
missing = []
|
|
90
|
-
|
|
98
|
+
walker.children(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
|
|
91
99
|
[style.fill, style.stroke].grep(Style::Reference).each do |reference|
|
|
92
100
|
missing << "url(##{reference.id})" unless @gradients.key?(reference.id)
|
|
93
101
|
end
|
|
@@ -96,7 +104,7 @@ module Stationery
|
|
|
96
104
|
"#{gradient.kind}Gradient spreadMethod=#{gradient.approximated_spread}" if gradient.approximated_spread
|
|
97
105
|
end
|
|
98
106
|
selectors = @sheet.unsupported.empty? ? [] : ["style selectors: #{@sheet.unsupported.join(", ")}"]
|
|
99
|
-
(missing + spreads).uniq.sort + selectors
|
|
107
|
+
(missing + spreads + walker.issues).uniq.sort + selectors
|
|
100
108
|
end
|
|
101
109
|
end
|
|
102
110
|
end
|
|
@@ -28,6 +28,17 @@ module Stationery
|
|
|
28
28
|
transform: style.matrix) { |path| Shapes.trace(path, element) }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Paints the block inside `region` (a clip path's or a viewport's) and
|
|
32
|
+
# inside the regions around it.
|
|
33
|
+
def clip(region, &)
|
|
34
|
+
return clip(region.outer) { clip(region.with(outer: nil), &) } if region.outer
|
|
35
|
+
|
|
36
|
+
outlines = region.parts.map do |part|
|
|
37
|
+
@canvas.outline(transform: part.matrix) { |path| part.shape.trace(path) }
|
|
38
|
+
end
|
|
39
|
+
@canvas.clip_to(outlines, even_odd: region.even_odd, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
31
42
|
# A paint as one colour: a gradient's middle colour, nil for none.
|
|
32
43
|
def color(paint, style)
|
|
33
44
|
paint = resolve(paint)
|
|
@@ -8,10 +8,15 @@ module Stationery
|
|
|
8
8
|
# Comments, processing instructions and doctypes are skipped; text content
|
|
9
9
|
# (CDATA included) is kept only inside text, tspan and style, as String
|
|
10
10
|
# children with entities decoded and each whitespace run one space.
|
|
11
|
+
#
|
|
12
|
+
# An element nested deeper than MAX_DEPTH is not descended into: it takes
|
|
13
|
+
# its place in the deepest element kept and so does what it holds, so
|
|
14
|
+
# nothing that walks the tree recurses deeper than that.
|
|
11
15
|
module Parser
|
|
12
16
|
Element = Data.define(:name, :attributes, :children)
|
|
13
17
|
|
|
14
18
|
TEXT = %w[text tspan style].freeze
|
|
19
|
+
MAX_DEPTH = 128
|
|
15
20
|
|
|
16
21
|
IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
|
|
17
22
|
CDATA = /<!\[CDATA\[(.*?)\]\]>/m
|
|
@@ -21,17 +26,19 @@ module Stationery
|
|
|
21
26
|
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
# The root element. The block is given how deep the source nested when
|
|
30
|
+
# that was deeper than `max_depth`.
|
|
31
|
+
def parse(source, max_depth: MAX_DEPTH)
|
|
25
32
|
source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
|
|
26
33
|
scanner = StringScanner.new(source)
|
|
27
|
-
|
|
28
|
-
stack = [root]
|
|
34
|
+
tree = Tree.new(max_depth)
|
|
29
35
|
while (content = scanner.scan_until(/(?=<)/))
|
|
30
|
-
keep(
|
|
36
|
+
keep(tree.open, content)
|
|
31
37
|
tag = scanner.scan(TAG) or (scanner.getch && next)
|
|
32
|
-
|
|
38
|
+
tree.tag(*tag.match(TAG).captures)
|
|
33
39
|
end
|
|
34
|
-
|
|
40
|
+
yield tree.deepest if block_given? && tree.deepest > max_depth
|
|
41
|
+
tree.root
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# Yields the element and every element below it, depth first.
|
|
@@ -46,12 +53,45 @@ module Stationery
|
|
|
46
53
|
element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
# The elements open while the source is read. `hoisted` counts the open
|
|
57
|
+
# ones too deep to descend into; their end tags are counted off again.
|
|
58
|
+
class Tree
|
|
59
|
+
attr_reader :deepest
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
def initialize(max_depth)
|
|
62
|
+
@document = Element.new("document", {}, [])
|
|
63
|
+
@stack = [@document]
|
|
64
|
+
@max_depth = max_depth
|
|
65
|
+
@hoisted = 0
|
|
66
|
+
@deepest = 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def root = @document.children.first
|
|
70
|
+
def open = @stack.last
|
|
71
|
+
|
|
72
|
+
def tag(closing, name, attributes, self_closing)
|
|
73
|
+
return close if closing == "/"
|
|
74
|
+
|
|
75
|
+
element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
|
|
76
|
+
open.children << element
|
|
77
|
+
descend(element) if self_closing.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def close
|
|
83
|
+
return @hoisted -= 1 if @hoisted.positive?
|
|
84
|
+
|
|
85
|
+
@stack.pop if @stack.size > 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def descend(element)
|
|
89
|
+
depth = @stack.size + @hoisted
|
|
90
|
+
@deepest = depth if depth > @deepest
|
|
91
|
+
return @stack << element if @stack.size <= @max_depth
|
|
92
|
+
|
|
93
|
+
@hoisted += 1
|
|
94
|
+
end
|
|
55
95
|
end
|
|
56
96
|
end
|
|
57
97
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# What an element is clipped to: the outlines of `parts`, joined into one
|
|
6
|
+
# clipping path (even-odd when `even_odd`), inside the `outer` region when
|
|
7
|
+
# there is one. A region without parts has nothing inside it.
|
|
8
|
+
Region = Data.define(:parts, :even_odd, :outer) do
|
|
9
|
+
def empty? = parts.empty?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Region
|
|
13
|
+
# One outline: anything that traces itself on a path (`trace(path)`),
|
|
14
|
+
# under the transform it is drawn with.
|
|
15
|
+
Part = Data.define(:matrix, :shape)
|
|
16
|
+
|
|
17
|
+
# A shape element as a part's outline.
|
|
18
|
+
Shape = Data.define(:element) do
|
|
19
|
+
def trace(path) = Shapes.trace(path, element)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
EMPTY = new([], false, nil).freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|