stationery 0.7.0 → 0.8.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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # The archival and accessibility standards a render claims, and what each
6
+ # asks of the file: PDF/A-2b and PDF/A-3b (ISO 19005-2/3, level B) and
7
+ # PDF/UA-1 (ISO 14289-1). Levels combine: `Conformance.new(:pdf_a3b, :pdf_ua1)`.
8
+ #
9
+ # A claim is only written when the document keeps it: what cannot be made
10
+ # conformant raises (ArgumentError for options that contradict the level,
11
+ # ConformanceError for content), so a file is never mislabelled.
12
+ class Conformance
13
+ LEVELS = {
14
+ pdf_a2b: { standard: :pdf_a, part: 2, conformance: "B", label: "PDF/A-2b" },
15
+ pdf_a3b: { standard: :pdf_a, part: 3, conformance: "B", label: "PDF/A-3b" },
16
+ pdf_ua1: { standard: :pdf_ua, part: 1, label: "PDF/UA-1" }
17
+ }.freeze
18
+ PDFA_ID = "http://www.aiim.org/pdfa/ns/id/"
19
+ PDFUA_ID = "http://www.aiim.org/pdfua/ns/id/"
20
+ # PDF/A knows its own schema; PDF/UA's has to be described to it.
21
+ PDFUA_SCHEMA = {
22
+ name: "PDF/UA identification schema", uri: PDFUA_ID, prefix: "pdfuaid",
23
+ properties: [{ name: "part", type: "Integer", category: "internal",
24
+ description: "The part of ISO 14289 the document conforms to" }]
25
+ }.freeze
26
+ ICC_PROFILE = File.expand_path("icc/sRGB2014.icc", __dir__)
27
+ OUTPUT_CONDITION = "sRGB IEC61966-2.1"
28
+ PRINT = 4
29
+ CMYK_OPERATOR = /^(?:-?[\d.]+ ){4}[kK]$/
30
+
31
+ attr_reader :levels
32
+
33
+ def self.label(level) = LEVELS.dig(level.to_sym, :label) || level.to_s
34
+
35
+ # The Conformance for `levels` (a Symbol, an Array or nil); nil without any.
36
+ def self.for(levels)
37
+ levels = Array(levels).flatten.compact
38
+ levels.empty? ? nil : new(*levels)
39
+ end
40
+
41
+ def initialize(*levels)
42
+ @levels = levels.flatten.map(&:to_sym).uniq
43
+ unknown = @levels - LEVELS.keys
44
+ raise ArgumentError, "unknown conformance #{unknown.map(&:inspect).join(", ")} (use #{names})" if unknown.any?
45
+ raise ArgumentError, "conformance takes one PDF/A level, got #{archival.join(" and ")}" if archival.size > 1
46
+ end
47
+
48
+ def pdf_a? = archival.any?
49
+ def pdf_ua? = @levels.include?(:pdf_ua1)
50
+ # The PDF/A part (2 or 3), nil without one.
51
+ def part = archival.first && LEVELS.dig(archival.first, :part)
52
+
53
+ # What can be refused before anything is drawn: options and metadata.
54
+ def validate!(encrypt:, metadata:, attachments:)
55
+ raise ArgumentError, "PDF/A forbids encryption: drop encrypt: or the conformance level" if pdf_a? && encrypt
56
+ if part == 2 && attachments.any?
57
+ raise ArgumentError, "PDF/A-2b only embeds PDF/A files: use conformance :pdf_a3b to attach " \
58
+ "#{attachments.map(&:name).join(", ")}"
59
+ end
60
+
61
+ missing = pdf_ua? ? %i[title lang].select { |key| metadata[key].to_s.strip.empty? } : []
62
+ raise ConformanceError.new(@levels, missing.map { |key| "metadata #{key}: is missing" }) if missing.any?
63
+ end
64
+
65
+ # What only the finished pages show. Colour that the sRGB output intent
66
+ # does not cover is reported; what breaks the claim outright raises.
67
+ def audit!(pages, resources:, warnings:)
68
+ cmyk(pages, resources).each { |subject| warnings << Warnings::ConformanceIssue.new(level: archival.first, subject:) }
69
+ issues = fields(pages) + alternatives(warnings)
70
+ raise ConformanceError.new(@levels, issues) if issues.any?
71
+ end
72
+
73
+ # The identification schemas for the XMP packet (see XMP.packet).
74
+ def xmp_extensions
75
+ extensions = {}
76
+ extensions[PDFA_ID] = { prefix: "pdfaid", "part" => part, "conformance" => "B" } if pdf_a?
77
+ extensions[PDFUA_ID] = { prefix: "pdfuaid", "part" => 1 } if pdf_ua?
78
+ extensions
79
+ end
80
+
81
+ # Schemas PDF/A has to be told about: PDF/UA's, when both are claimed.
82
+ def xmp_schemas = pdf_a? && pdf_ua? ? [PDFUA_SCHEMA] : []
83
+
84
+ # The catalog's entries: the sRGB output intent of a PDF/A file.
85
+ def catalog_entries(writer)
86
+ return {} unless pdf_a?
87
+
88
+ profile = writer.add(Stream.new(File.binread(ICC_PROFILE), { N: 3 }))
89
+ { OutputIntents: [{ Type: :OutputIntent, S: :GTS_PDFA1, DestOutputProfile: profile,
90
+ OutputConditionIdentifier: TextString.new(OUTPUT_CONDITION),
91
+ Info: TextString.new(OUTPUT_CONDITION) }] }
92
+ end
93
+
94
+ # A page's entries: PDF/UA tabs through annotations in structure order.
95
+ def page_entries(page) = pdf_ua? && page.annotations.any? ? { Tabs: :S } : {}
96
+
97
+ # A link annotation's entries: printable, and described for PDF/UA.
98
+ def annotation_entries(link)
99
+ entries = { F: PRINT }
100
+ entries[:Contents] = TextString.new(description(link)) if pdf_ua?
101
+ entries
102
+ end
103
+
104
+ private
105
+
106
+ def archival = @levels.select { |level| LEVELS.dig(level, :standard) == :pdf_a }
107
+ def names = LEVELS.keys.map(&:inspect).join(", ")
108
+
109
+ def description(link)
110
+ link[:url] || "Page #{link[:dest].page + 1}"
111
+ end
112
+
113
+ # Form fields draw with the standard Helvetica and ZapfDingbats, which
114
+ # are not embedded, and ask the viewer to regenerate appearances:
115
+ # neither PDF/A nor PDF/UA accepts that.
116
+ def fields(pages)
117
+ names = pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }.uniq
118
+ names.map { |name| %(form field "#{name}" draws with a font that is not embedded) }
119
+ end
120
+
121
+ def alternatives(warnings)
122
+ return [] unless pdf_ua?
123
+
124
+ warnings.grep(Warnings::MissingAlt).map(&:message)
125
+ end
126
+
127
+ def cmyk(pages, resources)
128
+ return [] unless pdf_a?
129
+
130
+ images = resources.images.any? { |image| image.respond_to?(:color_space) && image.color_space == :DeviceCMYK }
131
+ subjects = images ? ["a CMYK image"] : []
132
+ pages.each_with_index do |page, index|
133
+ subjects << "CMYK colour on page #{index + 1}" if page.content.match?(CMYK_OPERATOR)
134
+ end
135
+ subjects
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # A Factur-X / ZUGFeRD e-invoice: the invoice's Cross Industry Invoice XML
6
+ # embedded in a PDF/A-3b file and identified in XMP, so the one document
7
+ # is read by people and booked by machines.
8
+ #
9
+ # Stationery carries the XML, it does not write or validate it: `xml` is
10
+ # the finished document as a String, or a method name, block or callable
11
+ # that answers it for the document being rendered.
12
+ class FacturX
13
+ NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
14
+ PROFILES = { minimum: "MINIMUM", basic_wl: "BASIC WL", basic: "BASIC", en16931: "EN 16931",
15
+ extended: "EXTENDED", xrechnung: "XRECHNUNG" }.freeze
16
+ # Profiles too small to stand for the invoice: their XML is data beside
17
+ # the page, not an alternative to it.
18
+ DATA_ONLY = %i[minimum basic_wl].freeze
19
+ FILENAMES = { xrechnung: "xrechnung.xml" }.freeze
20
+ DEFAULT_FILENAME = "factur-x.xml"
21
+ DESCRIPTION = "Factur-X Invoice"
22
+ ROOTS = ["<?xml", "<rsm:CrossIndustryInvoice"].freeze
23
+ SCHEMA = {
24
+ name: "Factur-X PDFA Extension Schema", uri: NAMESPACE, prefix: "fx",
25
+ properties: [
26
+ { name: "DocumentFileName", type: "Text", category: "external",
27
+ description: "The name of the embedded XML document" },
28
+ { name: "DocumentType", type: "Text", category: "external",
29
+ description: "The type of the hybrid document in capital letters, e.g. INVOICE or ORDER" },
30
+ { name: "Version", type: "Text", category: "external",
31
+ description: "The actual version of the standard applying to the embedded XML document" },
32
+ { name: "ConformanceLevel", type: "Text", category: "external",
33
+ description: "The conformance level of the embedded XML document" }
34
+ ].freeze
35
+ }.freeze
36
+
37
+ attr_reader :xml, :profile, :filename, :version
38
+
39
+ # The invoice for `spec` (`{ xml:, profile:, filename:, version:,
40
+ # relationship: }`, as `factur_x` and `to_pdf(factur_x:)` take it)
41
+ # resolved for `document`; nil without a spec.
42
+ def self.for(spec, document)
43
+ return unless spec
44
+
45
+ spec = spec.to_h.transform_keys(&:to_sym)
46
+ new(resolve(spec[:xml], document), **spec.except(:xml))
47
+ end
48
+
49
+ def self.resolve(xml, document)
50
+ case xml
51
+ when Symbol then document.send(xml)
52
+ when Proc then xml.arity.zero? ? document.instance_exec(&xml) : xml.call(document)
53
+ else xml.respond_to?(:call) ? xml.call(document) : xml
54
+ end
55
+ end
56
+
57
+ def self.profile(name)
58
+ name = name.to_s.downcase.to_sym
59
+ return name if PROFILES.key?(name)
60
+
61
+ raise ArgumentError, "unknown Factur-X profile #{name.inspect} (use #{PROFILES.keys.map(&:inspect).join(", ")})"
62
+ end
63
+
64
+ def self.invoice?(xml) = xml.is_a?(String) && xml.delete_prefix("").lstrip.start_with?(*ROOTS)
65
+
66
+ def initialize(xml, profile: :en16931, filename: nil, version: "1.0", relationship: nil)
67
+ @profile = self.class.profile(profile)
68
+ unless self.class.invoice?(xml)
69
+ raise ArgumentError, "factur_x needs the invoice XML (a String starting with #{ROOTS.join(" or ")}), " \
70
+ "#{got(xml)}"
71
+ end
72
+
73
+ @xml = xml
74
+ @filename = (filename || FILENAMES.fetch(@profile, DEFAULT_FILENAME)).to_s
75
+ @version = version.to_s
76
+ @relationship = relationship || (DATA_ONLY.include?(@profile) ? :data : :alternative)
77
+ end
78
+
79
+ # The fx:ConformanceLevel of the profile ("EN 16931").
80
+ def level = PROFILES.fetch(@profile)
81
+
82
+ # The declared conformance levels with the PDF/A-3b the invoice needs.
83
+ def conformance(levels)
84
+ levels = Array(levels).flatten.compact.map(&:to_sym)
85
+ if levels.include?(:pdf_a2b)
86
+ raise ArgumentError, "Factur-X embeds its XML, which needs PDF/A-3b: drop conformance :pdf_a2b"
87
+ end
88
+
89
+ levels.include?(:pdf_a3b) ? levels : levels + [:pdf_a3b]
90
+ end
91
+
92
+ # The XML as the file to embed, stamped `at` (its /ModDate).
93
+ def attachment(at: Time.now)
94
+ Attachments.build(@filename, @xml, mime: "text/xml", description: DESCRIPTION, relationship: @relationship,
95
+ modified_at: at)
96
+ end
97
+
98
+ # The invoice's identification for the XMP packet (see XMP.packet).
99
+ def xmp_extensions
100
+ { NAMESPACE => { prefix: "fx", "DocumentType" => "INVOICE", "DocumentFileName" => @filename,
101
+ "Version" => @version, "ConformanceLevel" => level } }
102
+ end
103
+
104
+ # The description of the `fx` schema PDF/A asks for.
105
+ def xmp_schema = SCHEMA
106
+
107
+ private
108
+
109
+ def got(xml)
110
+ return "got #{xml.nil? ? "nil" : "a #{xml.class}"}" unless xml.is_a?(String)
111
+ return "got an empty String" if xml.strip.empty?
112
+
113
+ "got #{xml[0, 40].inspect}"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,14 @@
1
+ # sRGB2014.icc
2
+
3
+ `sRGB2014.icc` is the ICC's sRGB v2 profile ("sRGB2014", copyright International Color
4
+ Consortium, 2015), downloaded unmodified from
5
+ <https://registry.color.org/rgb-registry/profiles/sRGB2014.icc>
6
+ (SHA-256 `384b832de3412066743b52a75ee906b6fb9fb8d9e09e936fc2c43223815c6e0a`).
7
+ Stationery embeds it as the output intent of PDF/A documents.
8
+
9
+ The ICC's terms for profiles it owns (<https://www.color.org/profiles2.xalter#license>):
10
+
11
+ > This profile is made available by the International Color Consortium, and may be copied,
12
+ > distributed, embedded, made, used, and sold without restriction. Altered versions of this
13
+ > profile shall have the original identification and copyright information removed and shall
14
+ > not be misrepresented as the original profile.
Binary file
@@ -5,15 +5,17 @@ require "zlib"
5
5
  module Stationery
6
6
  module PDF
7
7
  # A stream object. Data is Flate-compressed unless the dictionary already
8
- # names a filter (JPEG's DCTDecode, or PNG data that is already zlib).
8
+ # names a filter (JPEG's DCTDecode, or PNG data that is already zlib) or
9
+ # `compress: false` asks for it plain (XMP metadata, which PDF/A readers
10
+ # scan for without decoding).
9
11
  class Stream
10
12
  attr_reader :dictionary, :data
11
13
 
12
- def initialize(data, dictionary = {})
14
+ def initialize(data, dictionary = {}, compress: !dictionary.key?(:Filter))
13
15
  data = data.b
14
16
  dictionary = dictionary.dup
15
17
 
16
- unless dictionary.key?(:Filter)
18
+ if compress
17
19
  data = Zlib::Deflate.deflate(data)
18
20
  dictionary[:Filter] = :FlateDecode
19
21
  end
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
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
@@ -11,6 +11,9 @@ module Stationery
11
11
  @shadings = {}
12
12
  end
13
13
 
14
+ # Every image drawn so far.
15
+ def images = @images.keys
16
+
14
17
  def font(font)
15
18
  @fonts[font] ||= :"F#{@fonts.size + 1}"
16
19
  end
@@ -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,14 +3,17 @@
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, polyline, polygon
7
- # and g, with fill, stroke, stroke width/caps/joins, fill-rule, opacity,
8
- # inline styles, <style> stylesheets, transforms, linear/radial gradients
9
- # and text/tspan. `currentColor` takes the colour you pass.
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 = %w[path rect circle ellipse line polyline polygon].freeze
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
  root = Parser.parse(source)
@@ -26,6 +29,7 @@ module Stationery
26
29
  @view_box = read_view_box
27
30
  @sheet = Stylesheet.parse(style_text)
28
31
  @gradients = Gradient.collect(root, @sheet)
32
+ @ids = index
29
33
  @unsupported = (element_names(root).uniq - QUIET).sort + approximations
30
34
  end
31
35
 
@@ -41,7 +45,7 @@ module Stationery
41
45
  style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:, sheet: @sheet).child(@root)
42
46
  painter = Painter.new(canvas, @gradients, @view_box.last(2))
43
47
  text = Text.new(canvas, painter, book || Fonts::FontBook.new, family || Fonts::Bundled::DEFAULT)
44
- shapes(@root, style) do |element, own|
48
+ walker(painter.method(:clip)).children(@root, style) do |element, own|
45
49
  element.name == "text" ? text.draw(element, own) : painter.paint(element, own)
46
50
  end
47
51
  end
@@ -61,6 +65,14 @@ module Stationery
61
65
  styles.flat_map(&:children).join(" ")
62
66
  end
63
67
 
68
+ # Every element with an id, the first of a repeated id.
69
+ def index
70
+ ids = {}
71
+ Parser.walk(@root) { |element| ids[element.attributes["id"]] ||= element }
72
+ ids.delete(nil)
73
+ ids
74
+ end
75
+
64
76
  def element_names(element)
65
77
  element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
66
78
  end
@@ -71,23 +83,15 @@ module Stationery
71
83
  end
72
84
 
73
85
  # Yields every drawn shape and text with its resolved style, in paint
74
- # order, skipping undisplayed subtrees and hidden elements.
75
- def shapes(parent, parent_style, &)
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
86
+ # order; `clipper` paints what is clipped (see Walker).
87
+ def walker(clipper = nil) = Walker.new(@ids, @view_box.last(2), clipper:)
85
88
 
86
- # Paint references to missing gradients, gradient spreads drawn as pad
87
- # and stylesheet selectors ignored.
89
+ # Paint references to missing gradients, gradient spreads drawn as pad,
90
+ # uses and clip paths that lead nowhere, and stylesheet selectors ignored.
88
91
  def approximations
92
+ walker = self.walker
89
93
  missing = []
90
- shapes(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
94
+ walker.children(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
91
95
  [style.fill, style.stroke].grep(Style::Reference).each do |reference|
92
96
  missing << "url(##{reference.id})" unless @gradients.key?(reference.id)
93
97
  end
@@ -96,7 +100,7 @@ module Stationery
96
100
  "#{gradient.kind}Gradient spreadMethod=#{gradient.approximated_spread}" if gradient.approximated_spread
97
101
  end
98
102
  selectors = @sheet.unsupported.empty? ? [] : ["style selectors: #{@sheet.unsupported.join(", ")}"]
99
- (missing + spreads).uniq.sort + selectors
103
+ (missing + spreads + walker.issues).uniq.sort + selectors
100
104
  end
101
105
  end
102
106
  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)
@@ -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
@@ -4,6 +4,8 @@ module Stationery
4
4
  module SVG
5
5
  # Traces one SVG shape element onto a canvas path.
6
6
  module Shapes
7
+ NAMES = %w[path rect circle ellipse line polyline polygon].freeze
8
+
7
9
  module_function
8
10
 
9
11
  def trace(path, element)