stationery 0.2.0 → 0.4.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 +100 -1
- data/README.md +143 -12
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +37 -8
- data/lib/stationery/document.rb +35 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/forms/acro_form.rb +115 -0
- data/lib/stationery/forms/appearance.rb +119 -0
- data/lib/stationery/forms/field.rb +122 -0
- data/lib/stationery/forms/metrics.rb +49 -0
- data/lib/stationery/layout/box.rb +34 -11
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +9 -2
- data/lib/stationery/layout/table/cell.rb +10 -2
- data/lib/stationery/layout/table.rb +42 -10
- data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +38 -5
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +28 -12
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +26 -0
- metadata +29 -1
|
@@ -8,19 +8,26 @@ module Stationery
|
|
|
8
8
|
# `:avoid` never). A fragment's cut sides are `open`: `decoration: :slice`
|
|
9
9
|
# drops their padding and border, `:clone` keeps the padding. A
|
|
10
10
|
# fixed-height box can truncate or shrink text that does not fit
|
|
11
|
-
# (`overflow:`) and never splits.
|
|
11
|
+
# (`overflow:`) and never splits. `min_height:` is a floor that does split:
|
|
12
|
+
# the first fragment keeps as much of it as the page holds and the next
|
|
13
|
+
# one carries only what is left of it.
|
|
12
14
|
class Box < Node
|
|
13
15
|
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
|
|
14
16
|
|
|
15
17
|
attr_reader :content, :width_spec
|
|
16
18
|
|
|
17
19
|
def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
|
|
18
|
-
height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
19
|
-
open: [], decoration: :slice)
|
|
20
|
+
height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
21
|
+
open: [], decoration: :slice, role: nil)
|
|
22
|
+
raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
|
|
23
|
+
|
|
20
24
|
super()
|
|
25
|
+
@tag = role && Tagging::Element.new(Tagging.role(role))
|
|
26
|
+
@min_height = min_height
|
|
21
27
|
@open = open
|
|
22
28
|
@decoration = decoration
|
|
23
29
|
@link = link
|
|
30
|
+
@link_tag = link && Tagging::Element.new(:Link)
|
|
24
31
|
@outset = Geometry.box(outset)
|
|
25
32
|
@content = content
|
|
26
33
|
@padding = Geometry.box(padding)
|
|
@@ -58,38 +65,54 @@ module Stationery
|
|
|
58
65
|
available = height - vertical(cut)
|
|
59
66
|
head, tail = @content.split(inner_width(width), available, fresh:)
|
|
60
67
|
return [nil, self] unless head
|
|
61
|
-
return [with_content(head), nil] unless tail
|
|
68
|
+
return [with_content(head), nil] unless tail || @min_height.to_f > height + EPSILON
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
fragments(width, height, head, tail || Flow.new, cut)
|
|
64
71
|
end
|
|
65
72
|
|
|
73
|
+
# An empty continuation: open at the top, without a floor.
|
|
74
|
+
def continued = fragment(Flow.new, @open | [:top], nil)
|
|
75
|
+
|
|
66
76
|
# A copy holding other content and open sides, every option kept.
|
|
67
77
|
def with_content(content, open = @open) = dup.reopen(content, open)
|
|
68
78
|
def with_open(*sides) = with_content(@content, @open | sides)
|
|
69
79
|
|
|
70
80
|
def measure(width)
|
|
71
|
-
@height || memoize_by_width(width) { @content.measure(inner_width(width)) + vertical }
|
|
81
|
+
@height || memoize_by_width(width) { [@content.measure(inner_width(width)) + vertical, @min_height || 0].max }
|
|
72
82
|
end
|
|
73
83
|
|
|
74
84
|
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
75
85
|
height ||= measure(width)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
canvas.structure(@tag) do
|
|
87
|
+
canvas.structure(@link_tag) do
|
|
88
|
+
paint_background(canvas, x, y, width, height)
|
|
89
|
+
paint_border(canvas, x, y, width, height)
|
|
90
|
+
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
|
|
80
94
|
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
81
95
|
end
|
|
82
96
|
|
|
83
97
|
protected
|
|
84
98
|
|
|
85
|
-
def reopen(content, open)
|
|
99
|
+
def reopen(content, open, min_height = @min_height)
|
|
86
100
|
@content = content
|
|
87
101
|
@open = open
|
|
102
|
+
@min_height = min_height
|
|
88
103
|
self
|
|
89
104
|
end
|
|
90
105
|
|
|
91
106
|
private
|
|
92
107
|
|
|
108
|
+
def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
|
|
109
|
+
|
|
110
|
+
def fragments(width, height, head, tail, cut)
|
|
111
|
+
first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
|
|
112
|
+
rest = @min_height.to_f - first.measure(width)
|
|
113
|
+
[first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
|
|
114
|
+
end
|
|
115
|
+
|
|
93
116
|
def insets(open = @open)
|
|
94
117
|
border = @border ? @border[:width] : 0
|
|
95
118
|
sides = @border ? @border[:sides] - open : []
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
# An interactive form field: a widget `height` tall that fills the width
|
|
6
|
+
# (`width: :full`) or takes `width` points. Its look comes from the
|
|
7
|
+
# widget's own appearance, so it paints nothing into the page but an
|
|
8
|
+
# optional `label` node to the right of the widget.
|
|
9
|
+
class Field < Node
|
|
10
|
+
LABEL_GAP = 6
|
|
11
|
+
|
|
12
|
+
def initialize(field, height:, width: :full, label: nil)
|
|
13
|
+
super()
|
|
14
|
+
@field = field
|
|
15
|
+
@height = height
|
|
16
|
+
@width = width
|
|
17
|
+
@label = label
|
|
18
|
+
@tag = Tagging::Element.new(:Form, kind: :field)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def measure(width)
|
|
22
|
+
@label ? [@height, @label.measure(label_width(width))].max : @height
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def natural_width = @label ? label_offset + @label.natural_width : fixed_points || 0
|
|
26
|
+
def min_width = @label ? label_offset + @label.min_width : fixed_points || 0
|
|
27
|
+
|
|
28
|
+
def fixed_width(available)
|
|
29
|
+
return [natural_width, available].min if @label
|
|
30
|
+
|
|
31
|
+
fixed_points && [fixed_points, available].min
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def paint(canvas, x, y, width, _height = nil, **)
|
|
35
|
+
own = @label ? @width : width
|
|
36
|
+
total = measure(width)
|
|
37
|
+
top = y + ((total - @height) / 2.0)
|
|
38
|
+
canvas.widget(@field, x, top, own, @height, tag: @tag)
|
|
39
|
+
@label&.paint(canvas, x + label_offset, y + ((total - @label.measure(label_width(width))) / 2.0),
|
|
40
|
+
label_width(width))
|
|
41
|
+
canvas.debug_rect(x, top, own, @height, :field) if canvas.debug?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def fixed_points = @width.is_a?(Numeric) ? @width : nil
|
|
47
|
+
def label_offset = @width + LABEL_GAP
|
|
48
|
+
def label_width(width) = [width - label_offset, 0].max
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -10,11 +10,13 @@ module Stationery
|
|
|
10
10
|
class Flow < Node
|
|
11
11
|
attr_reader :children, :gap, :align
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# `tag` groups the children in a tagged PDF (a list's L).
|
|
14
|
+
def initialize(children = [], gap: 0, align: :left, tag: nil)
|
|
14
15
|
super()
|
|
15
16
|
@children = children
|
|
16
17
|
@gap = gap
|
|
17
18
|
@align = align
|
|
19
|
+
@tag = tag
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def <<(child)
|
|
@@ -35,6 +37,20 @@ module Stationery
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
40
|
+
canvas.structure(@tag) { paint_children(canvas, x, y, width) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def split(width, height, fresh: false)
|
|
44
|
+
Splitter.new(self, width, height, fresh).call
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_children(children)
|
|
48
|
+
self.class.new(children, gap: @gap, align: @align, tag: @tag)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def paint_children(canvas, x, y, width)
|
|
38
54
|
cursor = y
|
|
39
55
|
@children.reject(&:page_break?).each_with_index do |child, index|
|
|
40
56
|
cursor += @gap unless index.zero?
|
|
@@ -46,14 +62,6 @@ module Stationery
|
|
|
46
62
|
cursor += height
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
|
-
|
|
50
|
-
def split(width, height, fresh: false)
|
|
51
|
-
Splitter.new(self, width, height, fresh).call
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def with_children(children)
|
|
55
|
-
self.class.new(children, gap: @gap, align: @align)
|
|
56
|
-
end
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
# One pass of Flow#split, kept apart so the rules read top to bottom.
|
|
@@ -6,8 +6,10 @@ module Stationery
|
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
7
|
# point when no size is given.
|
|
8
8
|
class Image < Node
|
|
9
|
-
|
|
9
|
+
# `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
|
|
10
|
+
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil)
|
|
10
11
|
super()
|
|
12
|
+
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
11
13
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
12
14
|
@width = width
|
|
13
15
|
@height = height
|
|
@@ -29,7 +31,7 @@ module Stationery
|
|
|
29
31
|
|
|
30
32
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
31
33
|
w, h = size(width)
|
|
32
|
-
canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
|
|
34
|
+
canvas.tag(@tag, bbox: [x, y, w, h]) { canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity) }
|
|
33
35
|
canvas.debug_rect(x, y, w, h, :image)
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -9,8 +9,12 @@ module Stationery
|
|
|
9
9
|
class ListItem < Node
|
|
10
10
|
attr_reader :marker, :body, :indent, :marker_gap
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# `tag` and `body_tag` are the item's LI and LBody; a Text marker carries its own Lbl.
|
|
13
|
+
def initialize(marker, body, indent:, marker_gap:, tag: Tagging::Element.new(:LI),
|
|
14
|
+
body_tag: Tagging::Element.new(:LBody))
|
|
13
15
|
super()
|
|
16
|
+
@tag = tag
|
|
17
|
+
@body_tag = body_tag
|
|
14
18
|
@marker = marker
|
|
15
19
|
@body = body
|
|
16
20
|
@indent = indent
|
|
@@ -31,11 +35,13 @@ module Stationery
|
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
canvas.structure(@tag) do
|
|
39
|
+
if @marker
|
|
40
|
+
own = @marker.width_in(column)
|
|
41
|
+
@marker.paint(canvas, x + column - own, y, own)
|
|
42
|
+
end
|
|
43
|
+
canvas.structure(@body_tag) { @body.paint(canvas, x + @indent, y, body_width(width)) }
|
|
37
44
|
end
|
|
38
|
-
@body.paint(canvas, x + @indent, y, body_width(width))
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def split(width, height, fresh: false)
|
|
@@ -51,7 +57,10 @@ module Stationery
|
|
|
51
57
|
|
|
52
58
|
def column = [@indent - @marker_gap, 0].max
|
|
53
59
|
def body_width(width) = [width - @indent, 0].max
|
|
54
|
-
|
|
60
|
+
|
|
61
|
+
def with(marker, body)
|
|
62
|
+
self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap, tag: @tag, body_tag: @body_tag)
|
|
63
|
+
end
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
# A drawn bullet (:disc, :circle or :square) sized to the text style and
|
|
@@ -26,6 +26,8 @@ module Stationery
|
|
|
26
26
|
# - fixed_width(available) → its own width, or nil when it fills the width
|
|
27
27
|
class Node
|
|
28
28
|
attr_accessor :keep_with_next, :break_inside
|
|
29
|
+
# The Tagging::Element this node paints into, shared by its fragments.
|
|
30
|
+
attr_reader :tag
|
|
29
31
|
|
|
30
32
|
def measure(_width) = raise(NotImplementedError, "#{self.class} must implement measure")
|
|
31
33
|
def paint(_canvas, _x, _y, _width, _height = nil, **) = raise(NotImplementedError, "#{self.class}#paint")
|
|
@@ -7,8 +7,9 @@ module Stationery
|
|
|
7
7
|
class Paginator
|
|
8
8
|
attr_reader :warnings
|
|
9
9
|
|
|
10
|
-
def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil)
|
|
10
|
+
def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil, tagging: nil)
|
|
11
11
|
@resources = resources
|
|
12
|
+
@tagging = tagging
|
|
12
13
|
@debug = debug
|
|
13
14
|
@page_options = page
|
|
14
15
|
@regions = regions
|
|
@@ -61,7 +62,7 @@ module Stationery
|
|
|
61
62
|
box = page.content_box
|
|
62
63
|
height = head.measure(box.width)
|
|
63
64
|
@warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
|
|
64
|
-
canvas = Canvas.new(page, @resources, debug: @debug)
|
|
65
|
+
canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging)
|
|
65
66
|
head.paint(canvas, box.x, box.y, box.width)
|
|
66
67
|
canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
|
|
67
68
|
end
|
|
@@ -36,7 +36,7 @@ module Stationery
|
|
|
36
36
|
return [nil, self] if parts.any? { |head, _| head.nil? }
|
|
37
37
|
|
|
38
38
|
heads = parts.map { |head, _| head.with_open(:bottom) }
|
|
39
|
-
tails = parts.zip(@columns).map { |(_, tail), column| tail || column.
|
|
39
|
+
tails = parts.zip(@columns).map { |(_, tail), column| tail || column.continued }
|
|
40
40
|
[with_columns(heads).tap { |row| row.keep_with_next = nil }, with_columns(tails)]
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -5,9 +5,13 @@ module Stationery
|
|
|
5
5
|
# A vector drawing at a fixed size: `width:` and/or `height:` (the other
|
|
6
6
|
# follows the viewBox's aspect ratio), never wider than the space given.
|
|
7
7
|
class Svg < Node
|
|
8
|
-
|
|
8
|
+
# `context` gives SVG text its font book and default family.
|
|
9
|
+
# `alt:` describes the drawing in a tagged PDF; `alt: false` marks it decorative.
|
|
10
|
+
def initialize(document, width: nil, height: nil, color: "#000000", context: nil, alt: nil)
|
|
9
11
|
super()
|
|
12
|
+
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :svg)
|
|
10
13
|
@document = document
|
|
14
|
+
@context = context
|
|
11
15
|
@width = width
|
|
12
16
|
@height = height
|
|
13
17
|
@color = color
|
|
@@ -26,7 +30,10 @@ module Stationery
|
|
|
26
30
|
|
|
27
31
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
28
32
|
w, h = size(width)
|
|
29
|
-
|
|
33
|
+
canvas.tag(@tag, bbox: [x, y, w, h]) do
|
|
34
|
+
@document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
|
|
35
|
+
family: @context&.style&.family)
|
|
36
|
+
end
|
|
30
37
|
canvas.debug_rect(x, y, w, h, :image)
|
|
31
38
|
end
|
|
32
39
|
|
|
@@ -9,7 +9,7 @@ module Stationery
|
|
|
9
9
|
TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
|
|
10
10
|
letter_spacing: :letter_spacing }.freeze
|
|
11
11
|
|
|
12
|
-
attr_reader :content, :options, :colspan, :rowspan
|
|
12
|
+
attr_reader :content, :options, :colspan, :rowspan, :tag, :row_tag
|
|
13
13
|
|
|
14
14
|
def initialize(content, options, colspan: 1, rowspan: 1)
|
|
15
15
|
@content = content
|
|
@@ -23,7 +23,15 @@ module Stationery
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# The same cell holding another node, as a row split into two parts needs.
|
|
26
|
-
def with_content(node) = Cell.new(node, @options, colspan:, rowspan:)
|
|
26
|
+
def with_content(node) = Cell.new(node, @options, colspan:, rowspan:).tagged(@tag, @row_tag)
|
|
27
|
+
|
|
28
|
+
# Its TH or TD and its row's TR in a tagged PDF; set once, by the
|
|
29
|
+
# table the cell is first laid out in.
|
|
30
|
+
def tagged(tag, row_tag)
|
|
31
|
+
@tag ||= tag
|
|
32
|
+
@row_tag ||= row_tag
|
|
33
|
+
self
|
|
34
|
+
end
|
|
27
35
|
|
|
28
36
|
# Changes an option and forgets the node built from the old ones.
|
|
29
37
|
def []=(name, value)
|
|
@@ -10,8 +10,13 @@ module Stationery
|
|
|
10
10
|
DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
|
|
11
11
|
border_color: "#000000" }.freeze
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# `tag` is the Table element its fragments share; a `continued` fragment
|
|
14
|
+
# repeats header rows already read on an earlier page.
|
|
15
|
+
def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {},
|
|
16
|
+
tag: Tagging::Element.new(:Table), continued: false)
|
|
14
17
|
super()
|
|
18
|
+
@tag = tag
|
|
19
|
+
@continued = continued
|
|
15
20
|
@split_rows = split_rows
|
|
16
21
|
@context = context
|
|
17
22
|
@widths = widths
|
|
@@ -20,6 +25,7 @@ module Stationery
|
|
|
20
25
|
defaults = DEFAULT_CELL.merge(cell)
|
|
21
26
|
@cells = rows.map { |row| row.map { |content| build_cell(content, defaults) } }
|
|
22
27
|
check_header
|
|
28
|
+
tag_cells
|
|
23
29
|
yield self if block_given?
|
|
24
30
|
end
|
|
25
31
|
|
|
@@ -74,11 +80,12 @@ module Stationery
|
|
|
74
80
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
75
81
|
widths = column_widths(width)
|
|
76
82
|
heights = row_heights(width)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
canvas.structure(@tag) do
|
|
84
|
+
grid.placements.each do |p|
|
|
85
|
+
rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
|
|
86
|
+
widths[p.columns].sum, heights[p.rows].sum)
|
|
87
|
+
paint_cell(canvas, p, rect)
|
|
88
|
+
end
|
|
82
89
|
end
|
|
83
90
|
end
|
|
84
91
|
|
|
@@ -97,10 +104,34 @@ module Stationery
|
|
|
97
104
|
|
|
98
105
|
def grid = @grid ||= Grid.new(@cells)
|
|
99
106
|
|
|
107
|
+
def paint_cell(canvas, placement, rect)
|
|
108
|
+
cell = placement.cell
|
|
109
|
+
paint = lambda do
|
|
110
|
+
cell.paint(canvas, @context, rect, last_column: placement.columns.end == column_count,
|
|
111
|
+
last_row: placement.rows.end == row_count)
|
|
112
|
+
end
|
|
113
|
+
return canvas.artifact(type: :pagination, &paint) if @continued && placement.row < @header
|
|
114
|
+
|
|
115
|
+
canvas.structure(cell.row_tag) { canvas.structure(cell.tag, &paint) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def tag_cells
|
|
119
|
+
@cells.each_with_index do |row, index|
|
|
120
|
+
row_tag = Tagging::Element.new(:TR)
|
|
121
|
+
row.each { |cell| cell.tagged(cell_tag(cell, index < @header), row_tag) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cell_tag(cell, header)
|
|
126
|
+
attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
|
|
127
|
+
RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
|
|
128
|
+
Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
|
|
129
|
+
end
|
|
130
|
+
|
|
100
131
|
def split_before(count)
|
|
101
132
|
return [nil, self] if count == @header
|
|
102
133
|
|
|
103
|
-
[with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count))]
|
|
134
|
+
[with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count), continued: true)]
|
|
104
135
|
end
|
|
105
136
|
|
|
106
137
|
def split_row(row, width, space, fresh:)
|
|
@@ -113,7 +144,7 @@ module Stationery
|
|
|
113
144
|
return unless heads
|
|
114
145
|
|
|
115
146
|
[with_rows(@cells.first(row) + [heads], widths:),
|
|
116
|
-
with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:)]
|
|
147
|
+
with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:, continued: true)]
|
|
117
148
|
end
|
|
118
149
|
|
|
119
150
|
def build_cell(content, defaults)
|
|
@@ -156,8 +187,9 @@ module Stationery
|
|
|
156
187
|
@row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
|
|
157
188
|
end
|
|
158
189
|
|
|
159
|
-
def with_rows(rows, widths: @widths)
|
|
160
|
-
self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows
|
|
190
|
+
def with_rows(rows, widths: @widths, continued: @continued)
|
|
191
|
+
self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows,
|
|
192
|
+
tag: @tag, continued:)
|
|
161
193
|
end
|
|
162
194
|
end
|
|
163
195
|
end
|
|
@@ -5,7 +5,8 @@ module Stationery
|
|
|
5
5
|
class TableOfContents
|
|
6
6
|
# One contents row. The title wraps within the room the number slot
|
|
7
7
|
# leaves; the leader and the number sit on its last line, and the whole
|
|
8
|
-
# row links to the entry's anchor once that anchor has painted.
|
|
8
|
+
# row links to the entry's anchor once that anchor has painted. Tagged,
|
|
9
|
+
# it is a TOCI holding the title as a Link and the number as a Reference.
|
|
9
10
|
class Entry < Node
|
|
10
11
|
LEADERS = { dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } }.freeze
|
|
11
12
|
|
|
@@ -16,20 +17,27 @@ module Stationery
|
|
|
16
17
|
@options = options
|
|
17
18
|
@slot = slot
|
|
18
19
|
@paragraphs = {}
|
|
20
|
+
@tag = Tagging::Element.new(:TOCI)
|
|
21
|
+
@link = Tagging::Element.new(:Link)
|
|
22
|
+
@reference = Tagging::Element.new(:Reference)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def measure(width) = paragraph(width).height
|
|
22
26
|
def natural_width = paragraph(Float::INFINITY).lines.map(&:width).max.to_f + offset + @slot + @options.gap
|
|
23
27
|
|
|
24
28
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
canvas.structure(@tag) do
|
|
30
|
+
text = paragraph(width)
|
|
31
|
+
text.draw(canvas, x + offset, y, tag: @link)
|
|
32
|
+
last = text.lines.last
|
|
33
|
+
baseline = y + text.height - last.height + last.ascent
|
|
34
|
+
slot_x = x + width - @slot
|
|
35
|
+
draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
|
|
36
|
+
canvas.structure(@reference) { nil } # its place in reading order; Structure fills it
|
|
37
|
+
canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
|
|
38
|
+
link: [x + offset, y, width - offset, text.height],
|
|
39
|
+
tags: [@link, @reference])
|
|
40
|
+
end
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
private
|
|
@@ -6,8 +6,9 @@ module Stationery
|
|
|
6
6
|
class Text < Node
|
|
7
7
|
attr_reader :runs
|
|
8
8
|
|
|
9
|
-
def initialize(runs, context:, align: :left, leading: 0, paragraph: nil)
|
|
9
|
+
def initialize(runs, context:, align: :left, leading: 0, paragraph: nil, tag: Tagging::Element.new(:P))
|
|
10
10
|
super()
|
|
11
|
+
@tag = tag
|
|
11
12
|
@runs = context.book.fallback(runs)
|
|
12
13
|
@context = context
|
|
13
14
|
@align = align
|
|
@@ -21,7 +22,7 @@ module Stationery
|
|
|
21
22
|
def measure(width) = paragraph(width).height
|
|
22
23
|
|
|
23
24
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
24
|
-
paragraph(width).draw(canvas, x, y)
|
|
25
|
+
paragraph(width).draw(canvas, x, y, tag: @tag)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def split(width, height, **)
|
|
@@ -42,7 +43,8 @@ module Stationery
|
|
|
42
43
|
style = run.style
|
|
43
44
|
font = @context.book.resolve(style).first
|
|
44
45
|
run.text.split(/[ \t\n]+/).map do |word|
|
|
45
|
-
font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning
|
|
46
|
+
font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
|
47
|
+
ligatures: style.ligatures)
|
|
46
48
|
end
|
|
47
49
|
end.max || 0
|
|
48
50
|
end
|
|
@@ -56,7 +58,7 @@ module Stationery
|
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def from(paragraph)
|
|
59
|
-
self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:)
|
|
61
|
+
self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:, tag: @tag)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -17,6 +17,8 @@ module Stationery
|
|
|
17
17
|
def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
|
|
18
18
|
def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
|
|
19
19
|
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
20
|
+
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
21
|
+
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
|
20
22
|
|
|
21
23
|
def refute_pdf_text(subject, expected, msg = nil)
|
|
22
24
|
matcher = Matchers::HaveText.new(expected)
|
data/lib/stationery/page.rb
CHANGED
|
@@ -8,8 +8,11 @@ module Stationery
|
|
|
8
8
|
class Page
|
|
9
9
|
# Room for the page number of `anchor`, right-aligned in `width` from `x`
|
|
10
10
|
# on `baseline` (top-left coordinates), drawn in `style`. `link` is an
|
|
11
|
-
# [x, y, w, h] area linked to the anchor only once it resolves.
|
|
12
|
-
|
|
11
|
+
# [x, y, w, h] area linked to the anchor only once it resolves. `tags` are
|
|
12
|
+
# its [link, number] structure elements in a tagged PDF.
|
|
13
|
+
Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link, :tags) do
|
|
14
|
+
def initialize(anchor:, x:, baseline:, width:, style:, link:, tags: nil) = super
|
|
15
|
+
end
|
|
13
16
|
|
|
14
17
|
SIZES = {
|
|
15
18
|
a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28],
|
|
@@ -7,8 +7,9 @@ module Stationery
|
|
|
7
7
|
# Paints a document's headers, footers and page templates on every
|
|
8
8
|
# finished page.
|
|
9
9
|
class PageTemplates
|
|
10
|
-
def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [])
|
|
10
|
+
def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [], tagging: nil)
|
|
11
11
|
@document = document
|
|
12
|
+
@tagging = tagging
|
|
12
13
|
@debug = debug
|
|
13
14
|
@book = book
|
|
14
15
|
@resources = resources
|
|
@@ -38,7 +39,7 @@ module Stationery
|
|
|
38
39
|
reserved = @regions.height_of(region, info.number)
|
|
39
40
|
overflow(info.number, height, reserved)
|
|
40
41
|
y = slot == :header ? box.y : box.bottom - height
|
|
41
|
-
paint(page, root, Rect.new(box.x, y, box.width, height))
|
|
42
|
+
paint(page, root, Rect.new(box.x, y, box.width, height), subtype: slot)
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -50,9 +51,11 @@ module Stationery
|
|
|
50
51
|
|
|
51
52
|
def root(info, block) = @document.template_root(info, book: @book, &block)
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
# Everything a template paints is a pagination artifact in a tagged PDF.
|
|
55
|
+
def paint(page, root, rect, layer = :foreground, subtype: nil)
|
|
54
56
|
mark = page.content.bytesize
|
|
55
|
-
|
|
57
|
+
canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging)
|
|
58
|
+
canvas.artifact(type: :pagination, subtype:) { root.paint(canvas, rect.x, rect.y, rect.width) }
|
|
56
59
|
page.content.prepend(page.content.slice!(mark..)) if layer == :background
|
|
57
60
|
end
|
|
58
61
|
end
|