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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +133 -10
- data/lib/stationery/canvas.rb +19 -0
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/minitest.rb +12 -0
- data/lib/stationery/pdf/assembler.rb +36 -6
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +139 -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/stream.rb +5 -3
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +26 -22
- data/lib/stationery/svg/painter.rb +11 -0
- 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 +142 -0
- data/lib/stationery/testing/inspector.rb +68 -0
- data/lib/stationery/testing/matchers.rb +67 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +8 -0
- metadata +11 -1
|
@@ -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
|
-
|
|
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("&", "&").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,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,
|
|
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
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|