stationery 0.6.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 +21 -0
- data/README.md +198 -18
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +24 -2
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +72 -11
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +17 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +40 -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/page_labels.rb +71 -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 +81 -0
- data/lib/stationery/testing/matchers.rb +77 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -0
- data/lib/stationery.rb +12 -0
- metadata +20 -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
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# The /PageLabels number tree: how viewers name pages ("i", "ii", "1",
|
|
6
|
+
# "A-1"). A spec maps a 1-based first page to the range starting there:
|
|
7
|
+
#
|
|
8
|
+
# { 1 => { style: :roman_lower }, 3 => { style: :decimal, start: 1, prefix: "A-" } }
|
|
9
|
+
#
|
|
10
|
+
# Styles are :decimal, :roman, :roman_lower, :alpha, :alpha_lower or nil
|
|
11
|
+
# (prefix only). Pages before the first range have no label; viewers show
|
|
12
|
+
# their physical number.
|
|
13
|
+
module PageLabels
|
|
14
|
+
STYLES = { decimal: :D, roman: :R, roman_lower: :r, alpha: :A, alpha_lower: :a }.freeze
|
|
15
|
+
NUMERALS = { D: :decimal, R: :upper_roman, r: :roman, A: :upper_alpha, a: :alpha }.freeze
|
|
16
|
+
KEYS = %i[style start prefix].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The number tree dictionary for a spec, or nil for none.
|
|
21
|
+
def entries(spec)
|
|
22
|
+
return if spec.nil? || spec.empty?
|
|
23
|
+
|
|
24
|
+
nums = spec.sort_by { |page, _| validate_page(page) }.flat_map do |page, range|
|
|
25
|
+
[page - 1, entry(page, range)]
|
|
26
|
+
end
|
|
27
|
+
{ Nums: nums }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The label of each of `count` pages from a /Nums array (as read back).
|
|
31
|
+
def labels(nums, count)
|
|
32
|
+
ranges = nums.each_slice(2).to_a
|
|
33
|
+
Array.new(count) do |index|
|
|
34
|
+
start, dict = ranges.reverse.find { |from, _| from <= index }
|
|
35
|
+
dict && label(dict, index - start)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_page(page)
|
|
40
|
+
return page if page.is_a?(Integer) && page >= 1
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "page labels start at page 1, not page #{page.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entry(page, range)
|
|
46
|
+
unknown = range.keys - KEYS
|
|
47
|
+
raise ArgumentError, "unknown page label option #{unknown.first.inspect} for page #{page}" if unknown.any?
|
|
48
|
+
|
|
49
|
+
style = range[:style]
|
|
50
|
+
raise ArgumentError, "unknown page label style #{style.inspect}" unless style.nil? || STYLES.key?(style)
|
|
51
|
+
|
|
52
|
+
start = range[:start]
|
|
53
|
+
if start && !(start.is_a?(Integer) && start >= 1)
|
|
54
|
+
raise ArgumentError, "page label start #{start.inspect} for page #{page} must be 1 or more"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
entry = {}
|
|
58
|
+
entry[:S] = STYLES[style] if style
|
|
59
|
+
entry[:St] = start if start
|
|
60
|
+
entry[:P] = TextString.new(range[:prefix].to_s) if range[:prefix]
|
|
61
|
+
entry
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def label(dict, offset)
|
|
65
|
+
numeral = NUMERALS[dict[:S]&.to_sym]
|
|
66
|
+
number = (dict[:St] || 1) + offset
|
|
67
|
+
"#{dict[:P]}#{ListMarkers.numeral(numeral, number) if numeral}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -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
|