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
|
@@ -6,28 +6,49 @@ module Stationery
|
|
|
6
6
|
# a form looks right without relying on the viewer to regenerate it.
|
|
7
7
|
# Variable text sits between `/Tx BMC … EMC`, the part a viewer redraws
|
|
8
8
|
# when the value changes.
|
|
9
|
+
#
|
|
10
|
+
# It is drawn when the widget is placed, so the glyphs it uses are in the
|
|
11
|
+
# fonts before they are subset, and written as streams once the fonts
|
|
12
|
+
# have their references (#streams).
|
|
9
13
|
class Appearance
|
|
10
14
|
PADDING = 2
|
|
11
15
|
LEADING = 1.15
|
|
12
|
-
|
|
16
|
+
# The check mark's corners in the unit square (y down) and its stroke.
|
|
17
|
+
CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }.freeze
|
|
13
18
|
DOT = 0.45
|
|
14
19
|
SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
# `resources` are the render's, where the field's fonts get their names.
|
|
22
|
+
def initialize(field, width, height, resources = nil)
|
|
17
23
|
@field = field
|
|
18
24
|
@width = width
|
|
19
25
|
@height = height
|
|
20
|
-
@
|
|
26
|
+
@type = field.typeface.with(resources)
|
|
27
|
+
@contents = contents
|
|
28
|
+
keep
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether everything it draws with is embedded in the file.
|
|
32
|
+
def embedded? = @type.names.empty? || @type.embedded?
|
|
33
|
+
|
|
34
|
+
# The resource names of the fonts it draws with or keeps for editing.
|
|
35
|
+
def font_names = @type.names
|
|
36
|
+
|
|
37
|
+
# What the field's /DA says: the font and size a viewer redraws the
|
|
38
|
+
# value with. nil for a field without variable text.
|
|
39
|
+
def default_appearance
|
|
40
|
+
return unless @field.variable_text?
|
|
41
|
+
|
|
42
|
+
"/#{@type.name} #{num(@field.font_size)} Tf 0 g"
|
|
21
43
|
end
|
|
22
44
|
|
|
23
45
|
# One stream, or a Hash of streams by appearance state for buttons.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
46
|
+
# `fonts` are the references by resource name.
|
|
47
|
+
def streams(fonts)
|
|
48
|
+
resources = font_names.empty? ? {} : { Font: fonts.slice(*font_names) }
|
|
49
|
+
return stream(@contents, resources) unless @contents.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
@contents.transform_values { |content| stream(content, resources) }
|
|
31
52
|
end
|
|
32
53
|
|
|
33
54
|
private
|
|
@@ -36,9 +57,29 @@ module Stationery
|
|
|
36
57
|
def size = @field.font_size
|
|
37
58
|
def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
|
|
38
59
|
|
|
39
|
-
def
|
|
60
|
+
def contents
|
|
61
|
+
case @field.kind
|
|
62
|
+
when :text, :select then frame + variable_text(text_lines)
|
|
63
|
+
when :checkbox then { @field.on_state => frame + check, Off: frame }
|
|
64
|
+
when :radio then { @field.on_state => circle + dot, Off: circle }
|
|
65
|
+
when :signature then signature
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# What a viewer may draw after an edit: the other options of a select,
|
|
70
|
+
# and the base repertoire of a field that can be typed into.
|
|
71
|
+
def keep
|
|
72
|
+
return unless @field.variable_text?
|
|
73
|
+
|
|
74
|
+
@type.name
|
|
75
|
+
return if options[:read_only]
|
|
76
|
+
|
|
77
|
+
@type.keep(Array(options[:options]).join, size, repertoire: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stream(content, resources)
|
|
40
81
|
PDF::Stream.new(content, { Type: :XObject, Subtype: :Form, BBox: [0, 0, @width, @height],
|
|
41
|
-
Resources:
|
|
82
|
+
Resources: resources })
|
|
42
83
|
end
|
|
43
84
|
|
|
44
85
|
def frame
|
|
@@ -50,34 +91,49 @@ module Stationery
|
|
|
50
91
|
end
|
|
51
92
|
end
|
|
52
93
|
|
|
53
|
-
|
|
94
|
+
def middle_baseline = (@height / 2.0) - ((@type.ascent(size) - @type.descent(size)) / 2)
|
|
95
|
+
|
|
96
|
+
# [x, baseline, text] runs in PDF space.
|
|
54
97
|
def text_lines
|
|
55
98
|
return comb_cells if options[:comb]
|
|
56
99
|
return multiline_runs if options[:multiline]
|
|
57
100
|
|
|
58
|
-
[[PADDING,
|
|
101
|
+
[[PADDING, middle_baseline, @field.value.to_s]]
|
|
59
102
|
end
|
|
60
103
|
|
|
61
104
|
def multiline_runs
|
|
62
|
-
top = @height - PADDING - (size
|
|
63
|
-
|
|
105
|
+
top = @height - PADDING - @type.ascent(size)
|
|
106
|
+
wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
|
|
64
107
|
[PADDING, top - (index * size * LEADING), line]
|
|
65
108
|
end
|
|
66
109
|
end
|
|
67
110
|
|
|
111
|
+
# The value broken into lines no wider than `width`, keeping its own
|
|
112
|
+
# line breaks; a word wider than a line stands alone.
|
|
113
|
+
def wrap(text, width)
|
|
114
|
+
text.split("\n", -1).flat_map do |paragraph|
|
|
115
|
+
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
116
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
117
|
+
if lines.last.empty? || @type.width(candidate, size) <= width
|
|
118
|
+
lines[-1] = candidate
|
|
119
|
+
else
|
|
120
|
+
lines << word
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
68
126
|
def comb_cells
|
|
69
127
|
cell = @width.fdiv(@field.max_length)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
[(index * cell) + ((cell - Metrics.width(char, size)) / 2), baseline, char]
|
|
128
|
+
@field.value.to_s[0, @field.max_length].chars.each_with_index.map do |char, index|
|
|
129
|
+
[(index * cell) + ((cell - @type.width(char, size)) / 2), middle_baseline, char]
|
|
73
130
|
end
|
|
74
131
|
end
|
|
75
132
|
|
|
76
133
|
def variable_text(runs)
|
|
77
134
|
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", "0 g"]
|
|
78
|
-
runs.reject { |_, _,
|
|
79
|
-
ops.push("BT", "
|
|
80
|
-
"#{PDF::Serializer.literal(bytes)} Tj", "ET")
|
|
135
|
+
runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
|
|
136
|
+
ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
|
|
81
137
|
end
|
|
82
138
|
ops.push("Q", "EMC").join("\n") << "\n"
|
|
83
139
|
end
|
|
@@ -93,11 +149,11 @@ module Stationery
|
|
|
93
149
|
def signature
|
|
94
150
|
y = @height - SIGNATURE[:rule]
|
|
95
151
|
content = draw { |canvas| canvas.line(PADDING, y, @width - PADDING, y, color: options[:border], width: 0.75) }
|
|
96
|
-
label =
|
|
152
|
+
label = options[:label].to_s
|
|
97
153
|
return content if label.empty?
|
|
98
154
|
|
|
99
|
-
ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "
|
|
100
|
-
|
|
155
|
+
ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "#{PADDING} #{SIGNATURE[:label_baseline]} Td",
|
|
156
|
+
*@type.show(label, SIGNATURE[:label_size]), "ET", "Q"]
|
|
101
157
|
"#{content}#{ops.join("\n")}\n"
|
|
102
158
|
end
|
|
103
159
|
|
|
@@ -107,12 +163,18 @@ module Stationery
|
|
|
107
163
|
page.content
|
|
108
164
|
end
|
|
109
165
|
|
|
166
|
+
# A check mark stroked as a path, so no symbol font is needed.
|
|
110
167
|
def check
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
168
|
+
side = [@width, @height].min * 0.8
|
|
169
|
+
left = (@width - side) / 2.0
|
|
170
|
+
top = (@height - side) / 2.0
|
|
171
|
+
first, *rest = CHECK[:points].map { |x, y| [left + (x * side), top + (y * side)] }
|
|
172
|
+
draw do |canvas|
|
|
173
|
+
canvas.path(stroke: "#000000", line_width: side * CHECK[:stroke], cap: :round, join: :round) do |path|
|
|
174
|
+
path.move_to(*first)
|
|
175
|
+
rest.each { |point| path.line_to(*point) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
116
178
|
end
|
|
117
179
|
end
|
|
118
180
|
end
|
|
@@ -6,26 +6,29 @@ module Stationery
|
|
|
6
6
|
module Forms
|
|
7
7
|
# One form field widget: its kind, full (dotted) name, value and options.
|
|
8
8
|
# Widgets sharing a name form one field (a radio group); dotted names are
|
|
9
|
-
# grouped under parent fields.
|
|
9
|
+
# grouped under parent fields. `typeface:` is what its appearance is set
|
|
10
|
+
# in: the document's fonts from the element DSL, the standard Helvetica
|
|
11
|
+
# otherwise. `tooltip:` is its accessible name (/TU), the name by default.
|
|
10
12
|
class Field
|
|
11
13
|
TYPES = { text: :Tx, checkbox: :Btn, radio: :Btn, select: :Ch, signature: :Sig }.freeze
|
|
12
14
|
# Field flag bit positions (PDF 32000-1, 12.7.3.1 and 12.7.4).
|
|
13
15
|
BITS = { read_only: 1, required: 2, multiline: 13, no_toggle_to_off: 15, radio: 16, combo: 18, edit: 19,
|
|
14
16
|
comb: 25 }.freeze
|
|
15
17
|
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
16
|
-
radius: 2 }.freeze
|
|
18
|
+
radius: 2, tooltip: nil }.freeze
|
|
17
19
|
OPTIONS = {
|
|
18
20
|
text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
|
|
19
21
|
signature: %i[label]
|
|
20
22
|
}.freeze
|
|
21
|
-
|
|
23
|
+
VARIABLE_TEXT = %i[text select].freeze
|
|
22
24
|
|
|
23
|
-
attr_reader :kind, :name, :value, :options
|
|
25
|
+
attr_reader :kind, :name, :value, :options, :typeface
|
|
24
26
|
|
|
25
|
-
def initialize(kind, name, value: nil, **options)
|
|
27
|
+
def initialize(kind, name, value: nil, typeface: Standard.new, **options)
|
|
26
28
|
@kind = kind
|
|
27
29
|
@name = validate_name(name.to_s)
|
|
28
30
|
@value = value
|
|
31
|
+
@typeface = typeface
|
|
29
32
|
unknown = options.keys - DEFAULTS.keys - OPTIONS.fetch(kind)
|
|
30
33
|
raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
|
|
31
34
|
|
|
@@ -36,7 +39,10 @@ module Stationery
|
|
|
36
39
|
def segments = @name.split(".")
|
|
37
40
|
def type = TYPES.fetch(@kind)
|
|
38
41
|
def font_size = @options[:font_size]
|
|
39
|
-
|
|
42
|
+
# Whether a viewer redraws its text when the value changes.
|
|
43
|
+
def variable_text? = VARIABLE_TEXT.include?(@kind)
|
|
44
|
+
# The accessible name: `tooltip:`, a signature's label, else the name.
|
|
45
|
+
def tooltip = (@options[:tooltip] || @options[:label]).to_s.strip.then { |text| text.empty? ? @name : text }
|
|
40
46
|
def max_length = @options[:comb].is_a?(Integer) ? @options[:comb] : @options[:max_length]
|
|
41
47
|
def radio? = @kind == :radio
|
|
42
48
|
def checked? = radio? ? @options[:checked] == true : @value == true
|
|
@@ -52,8 +58,10 @@ module Stationery
|
|
|
52
58
|
|
|
53
59
|
# The field-level entries; the widget's come from #widget_entries.
|
|
54
60
|
# `value` overrides this widget's own: a radio group's checked choice.
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
# `default_appearance` is the /DA of a field with variable text.
|
|
62
|
+
def field_entries(value = field_value, default_appearance: nil)
|
|
63
|
+
entries = { FT: type, TU: PDF::TextString.new(tooltip) }
|
|
64
|
+
entries[:DA] = default_appearance if default_appearance
|
|
57
65
|
entries[:Ff] = flags if flags.positive?
|
|
58
66
|
entries[:V] = value unless value.nil?
|
|
59
67
|
entries[:MaxLen] = max_length if max_length
|
|
@@ -70,9 +78,10 @@ module Stationery
|
|
|
70
78
|
end
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
# `
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
# `appearance` is the widget's Appearance and `fonts` the references by
|
|
82
|
+
# resource name; `state` overrides the button's own appearance state.
|
|
83
|
+
def widget_entries(appearance, fonts, state: nil)
|
|
84
|
+
normal = appearance.streams(fonts)
|
|
76
85
|
entries = { Type: :Annot, Subtype: :Widget, F: 4, AP: { N: normal } }
|
|
77
86
|
entries[:MK] = appearance_characteristics unless @kind == :signature
|
|
78
87
|
entries[:AS] = state || (checked? ? on_state : :Off) if normal.is_a?(Hash)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Forms
|
|
5
5
|
# Helvetica's advance widths (the standard-14 AFM, in 1/1000 em) for the
|
|
6
|
-
# printable ASCII range, enough to lay out a field
|
|
7
|
-
# WinAnsi characters count as a
|
|
6
|
+
# printable ASCII range, enough to lay out the appearance of a field made
|
|
7
|
+
# without a font book (see Standard); other WinAnsi characters count as a
|
|
8
|
+
# digit's width.
|
|
8
9
|
module Metrics
|
|
9
10
|
ASCII = [
|
|
10
11
|
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, # space - /
|
|
@@ -29,21 +30,6 @@ module Stationery
|
|
|
29
30
|
def width(bytes, size)
|
|
30
31
|
bytes.each_byte.sum { |byte| (byte in 32..126) ? ASCII[byte - 32] : DEFAULT } * size / 1000.0
|
|
31
32
|
end
|
|
32
|
-
|
|
33
|
-
# `text` broken into WinAnsi lines no wider than `width`, keeping its own
|
|
34
|
-
# line breaks; a word wider than a line stands alone.
|
|
35
|
-
def wrap(text, width, size)
|
|
36
|
-
encode(text).split("\n", -1).flat_map do |paragraph|
|
|
37
|
-
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
38
|
-
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
39
|
-
if lines.last.empty? || width(candidate, size) <= width
|
|
40
|
-
lines[-1] = candidate
|
|
41
|
-
else
|
|
42
|
-
lines << word
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
33
|
end
|
|
48
34
|
end
|
|
49
35
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# What a field's appearance is set in. A field built by the element DSL
|
|
6
|
+
# draws with the document's own fonts (Typeface): embedded, subset and
|
|
7
|
+
# Identity-H encoded like any other text, with the book's fallbacks per
|
|
8
|
+
# character, so a value in any script the fonts cover renders. A field
|
|
9
|
+
# made without a font book (`Forms::Field.new` on a bare canvas) draws
|
|
10
|
+
# with the standard Helvetica in Windows-1252 (Standard).
|
|
11
|
+
#
|
|
12
|
+
# Both answer the same questions for an Appearance once bound to a
|
|
13
|
+
# render's resources with #with.
|
|
14
|
+
class Typeface
|
|
15
|
+
# What an editable field's font keeps beyond the value it shows, so the
|
|
16
|
+
# text a viewer redraws after an edit has its glyphs: printable ASCII
|
|
17
|
+
# and Latin-1. A read-only field keeps its value's glyphs only.
|
|
18
|
+
REPERTOIRE = [*0x20..0x7E, *0xA0..0xFF].pack("U*").freeze
|
|
19
|
+
|
|
20
|
+
def initialize(book, style, resources = nil)
|
|
21
|
+
@book = book
|
|
22
|
+
@style = style
|
|
23
|
+
@resources = resources
|
|
24
|
+
@names = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# This typeface naming its fonts in `resources` (a render's Resources).
|
|
28
|
+
def with(resources) = self.class.new(@book, @style, resources)
|
|
29
|
+
|
|
30
|
+
def embedded? = true
|
|
31
|
+
def ascent(size) = primary.ascender(size)
|
|
32
|
+
def descent(size) = primary.descender(size)
|
|
33
|
+
def width(text, size) = runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false) }
|
|
34
|
+
|
|
35
|
+
# The resource names of the fonts drawn or kept so far.
|
|
36
|
+
def names = @names.values
|
|
37
|
+
|
|
38
|
+
# The name the field's /DA selects: the font of the surrounding text.
|
|
39
|
+
def name = name_of(primary)
|
|
40
|
+
|
|
41
|
+
# The operators showing `text` at the current text position: a font
|
|
42
|
+
# selection and a string per run of one font. Characters no font has
|
|
43
|
+
# draw as .notdef and are reported, as in any other text.
|
|
44
|
+
def show(text, size)
|
|
45
|
+
runs(text, size).flat_map do |font, run|
|
|
46
|
+
report(font, run)
|
|
47
|
+
["/#{name_of(font)} #{PDF::Serializer.number(size)} Tf", glyphs(font, run.text).to_operator]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keeps the glyphs of `text` in the embedded fonts without drawing it
|
|
52
|
+
# (a select's other options), and the REPERTOIRE when `repertoire:`.
|
|
53
|
+
def keep(text, size, repertoire: false)
|
|
54
|
+
runs(text, size).each do |font, run|
|
|
55
|
+
name_of(font)
|
|
56
|
+
glyphs(font, run.text)
|
|
57
|
+
end
|
|
58
|
+
return unless repertoire
|
|
59
|
+
|
|
60
|
+
name
|
|
61
|
+
glyphs(primary, REPERTOIRE.each_char.select { |char| primary.glyph?(char) }.join)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def primary = @primary ||= @book.resolve(@style).first
|
|
67
|
+
def name_of(font) = @names[font] ||= @resources.font(font)
|
|
68
|
+
|
|
69
|
+
# Marks the glyphs as used, so the subset embeds them.
|
|
70
|
+
def glyphs(font, text) = font.glyph_run(text, ligatures: false)
|
|
71
|
+
|
|
72
|
+
# [font, run] per stretch of `text` one font draws.
|
|
73
|
+
def runs(text, size)
|
|
74
|
+
return [] if text.empty?
|
|
75
|
+
|
|
76
|
+
@book.fallback([Text::Run.new(text, @style.with(size:))]).map { |run| [@book.resolve(run.style).first, run] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def report(font, run)
|
|
80
|
+
run.text.each_char do |char|
|
|
81
|
+
next if Fonts::Fallback.carried?(char) || font.glyph?(char)
|
|
82
|
+
|
|
83
|
+
@book.warnings.missing_glyph(char, run.style.family)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The standard Helvetica, not embedded: text is Windows-1252 bytes and
|
|
89
|
+
# characters outside it become "?".
|
|
90
|
+
class Standard
|
|
91
|
+
NAME = :Helv
|
|
92
|
+
FONT = { Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding }.freeze
|
|
93
|
+
|
|
94
|
+
def with(_resources) = self.class.new
|
|
95
|
+
def embedded? = false
|
|
96
|
+
def ascent(size) = size * Metrics::ASCENT
|
|
97
|
+
def descent(size) = size * -Metrics::DESCENT
|
|
98
|
+
def width(text, size) = Metrics.width(Metrics.encode(text), size)
|
|
99
|
+
def names = @named ? [NAME] : []
|
|
100
|
+
# Nothing to keep: a standard font is the viewer's, whole.
|
|
101
|
+
def keep(*, **) = nil
|
|
102
|
+
|
|
103
|
+
def name
|
|
104
|
+
@named = true
|
|
105
|
+
NAME
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def show(text, size)
|
|
109
|
+
["/#{name} #{PDF::Serializer.number(size)} Tf", "#{PDF::Serializer.literal(Metrics.encode(text))} Tj"]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../rich/nesting"
|
|
3
4
|
require_relative "tokenizer"
|
|
4
5
|
require_relative "tree_builder"
|
|
5
6
|
|
|
6
7
|
module Stationery
|
|
7
8
|
# Lenient HTML to rich-text blocks (see Stationery::Rich).
|
|
8
9
|
module HTML
|
|
9
|
-
|
|
10
|
+
# Elements nested deeper than `max_depth` are flattened into the deepest
|
|
11
|
+
# one kept; the block is then given how deep the source went.
|
|
12
|
+
def self.parse(source, max_depth: Rich::Nesting::DEFAULT)
|
|
13
|
+
nesting = Rich::Nesting.new(max_depth)
|
|
10
14
|
Stationery.instrument("parse.stationery", format: :html, bytes: source.bytesize) do
|
|
11
|
-
TreeBuilder.parse(Tokenizer.tokenize(source))
|
|
15
|
+
TreeBuilder.parse(Tokenizer.tokenize(source), nesting).tap do
|
|
16
|
+
yield nesting.deepest if nesting.exceeded? && block_given?
|
|
17
|
+
end
|
|
12
18
|
end
|
|
13
19
|
end
|
|
14
20
|
end
|
|
@@ -6,7 +6,9 @@ module Stationery
|
|
|
6
6
|
module HTML
|
|
7
7
|
# Builds a lenient element tree from tokens, closing what HTML5 closes implicitly (a p before a block,
|
|
8
8
|
# an li before the next li, cells and rows before the next), ignoring stray end tags, then converts
|
|
9
|
-
# the tree into rich-text blocks.
|
|
9
|
+
# the tree into rich-text blocks. An element nested deeper than the Rich::Nesting limit is left out
|
|
10
|
+
# of the tree: what it holds goes to the deepest element kept, so everything that walks the tree
|
|
11
|
+
# (and the layout it becomes) recurses no deeper than the limit.
|
|
10
12
|
class TreeBuilder
|
|
11
13
|
Element = Data.define(:name, :attributes, :children)
|
|
12
14
|
|
|
@@ -16,6 +18,7 @@ module Stationery
|
|
|
16
18
|
].freeze
|
|
17
19
|
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
18
20
|
SECTIONS = %w[thead tbody tfoot].freeze
|
|
21
|
+
BLOCKS = (CLOSES_P + SECTIONS + %w[tr td th caption]).freeze
|
|
19
22
|
IMPLIED = {
|
|
20
23
|
"li" => [%w[li], %w[ul ol]],
|
|
21
24
|
"dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
|
|
@@ -24,11 +27,13 @@ module Stationery
|
|
|
24
27
|
"thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
|
|
25
28
|
}.freeze
|
|
26
29
|
|
|
27
|
-
def self.parse(tokens) = Blocks.convert(new.build(tokens))
|
|
30
|
+
def self.parse(tokens, nesting = Rich::Nesting.new) = Blocks.convert(new(nesting).build(tokens))
|
|
28
31
|
|
|
29
|
-
def initialize
|
|
32
|
+
def initialize(nesting = Rich::Nesting.new)
|
|
30
33
|
@root = Element.new("#root", {}, [])
|
|
31
34
|
@stack = [@root]
|
|
35
|
+
@nesting = nesting
|
|
36
|
+
@flattened = [] # the names of the open elements left out of the tree
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def build(tokens)
|
|
@@ -45,9 +50,13 @@ module Stationery
|
|
|
45
50
|
private
|
|
46
51
|
|
|
47
52
|
def start(name, attributes, self_closing)
|
|
53
|
+
return flatten(name, attributes, self_closing) unless @flattened.empty?
|
|
54
|
+
|
|
48
55
|
close_nearest(%w[p], %w[table td th caption]) if CLOSES_P.include?(name)
|
|
49
56
|
close_nearest(HEADINGS, []) if HEADINGS.include?(name) && HEADINGS.include?(@stack.last.name)
|
|
50
57
|
close_nearest(*IMPLIED[name]) if IMPLIED.key?(name)
|
|
58
|
+
return flatten(name, attributes, self_closing) if @stack.size > @nesting.limit
|
|
59
|
+
|
|
51
60
|
push("tr", {}, false) if %w[td th].include?(name) && %w[table thead tbody tfoot].include?(@stack.last.name)
|
|
52
61
|
push(name, attributes, self_closing)
|
|
53
62
|
end
|
|
@@ -58,9 +67,30 @@ module Stationery
|
|
|
58
67
|
@stack << element unless self_closing
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
# Too deep for the tree: a void element (br, img, hr) still takes its place in the deepest
|
|
71
|
+
# element kept, anything else is only remembered until its end tag, its content going there too.
|
|
72
|
+
def flatten(name, attributes, self_closing)
|
|
73
|
+
return push(name, attributes, true) if self_closing
|
|
74
|
+
|
|
75
|
+
@flattened << name
|
|
76
|
+
@nesting.record(@stack.size - 1 + @flattened.size)
|
|
77
|
+
separate(name)
|
|
78
|
+
end
|
|
79
|
+
|
|
61
80
|
def finish(name)
|
|
81
|
+
flattened = @flattened.rindex(name)
|
|
82
|
+
return @flattened.slice!(flattened..).each { |left_out| separate(left_out) } if flattened
|
|
83
|
+
|
|
62
84
|
index = @stack.rindex { |element| element.name == name }
|
|
63
|
-
|
|
85
|
+
return unless index&.positive?
|
|
86
|
+
|
|
87
|
+
@stack.slice!(index..)
|
|
88
|
+
@flattened.clear
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A flattened block no longer parts its text from its neighbours'; a space does.
|
|
92
|
+
def separate(name)
|
|
93
|
+
@stack.last.children << " " if BLOCKS.include?(name)
|
|
64
94
|
end
|
|
65
95
|
|
|
66
96
|
def close_nearest(names, boundary)
|
|
@@ -21,9 +21,10 @@ module Stationery
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def inspect = "#<#{self.class} #{@width}x#{@height}>"
|
|
24
|
+
def color_space = COLOR_SPACES.fetch(@components)
|
|
24
25
|
|
|
25
26
|
def build(writer)
|
|
26
|
-
dictionary = Images.xobject(@width, @height,
|
|
27
|
+
dictionary = Images.xobject(@width, @height, color_space, @bits).merge(Filter: :DCTDecode)
|
|
27
28
|
# Adobe CMYK JPEGs store inverted values.
|
|
28
29
|
dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
|
|
29
30
|
writer.add(PDF::Stream.new(@data, dictionary))
|
|
@@ -25,7 +25,7 @@ module Stationery
|
|
|
25
25
|
super()
|
|
26
26
|
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
27
27
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
28
|
-
@name =
|
|
28
|
+
@name = name_of(source)
|
|
29
29
|
@max_ppi = max_ppi
|
|
30
30
|
@downscale = downscale
|
|
31
31
|
@width = width
|
|
@@ -58,6 +58,14 @@ module Stationery
|
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# What a warning calls the image: the file's name for a path, a
|
|
62
|
+
# Pathname or an open File (anything with a path), else "inline image".
|
|
63
|
+
# Asked by duck type: Pathname is not loaded in plain Ruby.
|
|
64
|
+
def name_of(source)
|
|
65
|
+
path = source.is_a?(String) ? source : (source.to_path if source.respond_to?(:to_path))
|
|
66
|
+
path ? File.basename(path) : "inline image"
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def paint_clipped(canvas, x, y, w, h)
|
|
62
70
|
return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
|
|
63
71
|
|
|
@@ -6,6 +6,7 @@ module Stationery
|
|
|
6
6
|
# One table cell: content (a String, markup String or layout node) and its
|
|
7
7
|
# options, which selections change until the table is laid out.
|
|
8
8
|
class Cell
|
|
9
|
+
SIDES = %i[top right bottom left].freeze
|
|
9
10
|
TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
|
|
10
11
|
letter_spacing: :letter_spacing }.freeze
|
|
11
12
|
|
|
@@ -103,14 +104,23 @@ module Stationery
|
|
|
103
104
|
canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :cell_padding)
|
|
104
105
|
end
|
|
105
106
|
|
|
107
|
+
# One path for the cell's border, centred on its edges: the rectangle
|
|
108
|
+
# when all four sides are drawn, else a line per side.
|
|
106
109
|
def paint_borders(canvas, rect)
|
|
107
110
|
width = @options[:border_width]
|
|
108
111
|
return if width.nil? || width.zero?
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
sides = Array(@options[:borders]).uniq
|
|
114
|
+
return if sides.empty?
|
|
115
|
+
|
|
116
|
+
canvas.path(stroke: @options[:border_color], line_width: width) do |path|
|
|
117
|
+
next path.rect(rect.x, rect.y, rect.width, rect.height) if (SIDES - sides).empty?
|
|
118
|
+
|
|
119
|
+
sides.each do |side|
|
|
120
|
+
x1, y1, x2, y2 = edge(side, rect)
|
|
121
|
+
path.move_to(x1, y1)
|
|
122
|
+
path.line_to(x2, y2)
|
|
123
|
+
end
|
|
114
124
|
end
|
|
115
125
|
end
|
|
116
126
|
|