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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# Lays out a <text> element: its strings and tspans become pieces along
|
|
6
|
+
# the baseline, a new chunk starting wherever x or y is set, and each
|
|
7
|
+
# chunk is shifted by its text-anchor. Glyphs are drawn upright at the
|
|
8
|
+
# transformed origin, sized by the transform's uniform scale, so rotated,
|
|
9
|
+
# skewed or unevenly scaled text is approximated.
|
|
10
|
+
class Text
|
|
11
|
+
ANCHORS = { "middle" => 0.5, "end" => 1.0 }.freeze
|
|
12
|
+
DEFAULT_SIZE = 16
|
|
13
|
+
|
|
14
|
+
Piece = Data.define(:runs, :style, :x, :y, :chunk, :advance)
|
|
15
|
+
|
|
16
|
+
def initialize(canvas, painter, book, family)
|
|
17
|
+
@canvas = canvas
|
|
18
|
+
@painter = painter
|
|
19
|
+
@book = book
|
|
20
|
+
@family = family
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def draw(element, style)
|
|
24
|
+
@pieces = []
|
|
25
|
+
@x = @y = @chunk = 0
|
|
26
|
+
@space = true
|
|
27
|
+
place(element, style)
|
|
28
|
+
trim
|
|
29
|
+
@pieces.group_by(&:chunk).each_value { |chunk| draw_chunk(chunk) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def place(element, style)
|
|
35
|
+
move(element.attributes)
|
|
36
|
+
element.children.each do |child|
|
|
37
|
+
if child.is_a?(String) then add(child, style)
|
|
38
|
+
elsif child.name == "tspan" then place(child, style.child(child))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def move(attributes)
|
|
44
|
+
x, y, dx, dy = attributes.values_at("x", "y", "dx", "dy").map { |value| first(value) }
|
|
45
|
+
@chunk += 1 if x || y
|
|
46
|
+
@x = (x || @x) + (dx || 0)
|
|
47
|
+
@y = (y || @y) + (dy || 0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def first(value) = value.to_s.split(/[\s,]+/).find { |part| !part.empty? }&.to_f
|
|
51
|
+
|
|
52
|
+
# Collapses the space between pieces and at the start of the text.
|
|
53
|
+
def add(string, style)
|
|
54
|
+
string = string.lstrip if @space
|
|
55
|
+
return if string.empty?
|
|
56
|
+
|
|
57
|
+
@space = string.end_with?(" ")
|
|
58
|
+
@pieces << piece(string, style, @x, @y)
|
|
59
|
+
@x += @pieces.last.advance
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def piece(string, style, x, y, chunk = @chunk)
|
|
63
|
+
italic = style.values["font-style"].to_s.match?(/italic|oblique/)
|
|
64
|
+
text_style = Stationery::Text::Style.new(family: family(style), size: size(style), weight: weight(style),
|
|
65
|
+
style: italic ? :italic : :normal)
|
|
66
|
+
runs = @book.fallback([Stationery::Text::Run.new(string, text_style)])
|
|
67
|
+
advance = runs.sum { |run| font(run).width_of(run.text, text_style.size) }
|
|
68
|
+
Piece.new(runs, style, x, y, chunk, advance)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def trim
|
|
72
|
+
last = @pieces.last or return
|
|
73
|
+
text = last.runs.map(&:text).join
|
|
74
|
+
return unless text.end_with?(" ")
|
|
75
|
+
|
|
76
|
+
@pieces[-1] = piece(text.rstrip, last.style, last.x, last.y, last.chunk)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def draw_chunk(pieces)
|
|
80
|
+
width = pieces.last.x + pieces.last.advance - pieces.first.x
|
|
81
|
+
shift = -width * ANCHORS.fetch(pieces.first.style.values["text-anchor"].to_s, 0)
|
|
82
|
+
pieces.each { |piece| draw_piece(piece, shift) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def draw_piece(piece, shift)
|
|
86
|
+
color = @painter.color(piece.style.fill, piece.style)
|
|
87
|
+
return unless color
|
|
88
|
+
|
|
89
|
+
x = piece.x + shift
|
|
90
|
+
scale = Transform.scale(piece.style.matrix)
|
|
91
|
+
piece.runs.each do |run|
|
|
92
|
+
font, face = @book.resolve(run.style)
|
|
93
|
+
page_x, page_y = Transform.apply(piece.style.matrix, x, piece.y)
|
|
94
|
+
@canvas.text(run.text, x: page_x, y: page_y, font:, size: run.style.size * scale, color:,
|
|
95
|
+
opacity: Painter.translucent(piece.style.opacity),
|
|
96
|
+
synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
|
|
97
|
+
x += font.width_of(run.text, run.style.size)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def font(run) = @book.resolve(run.style).first
|
|
102
|
+
|
|
103
|
+
# The first family of the list the book knows, else the document's.
|
|
104
|
+
def family(style)
|
|
105
|
+
names = style.values["font-family"].to_s.split(",").map { |name| name.strip.delete("'\"") }
|
|
106
|
+
names.find { |name| !name.empty? && @book.known?(name) } || @family
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def size(style)
|
|
110
|
+
size = style.values["font-size"].to_f
|
|
111
|
+
size.positive? ? size : DEFAULT_SIZE
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def weight(style)
|
|
115
|
+
value = style.values["font-weight"].to_s.strip
|
|
116
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
117
|
+
|
|
118
|
+
%w[bold bolder].include?(value) ? :bold : :regular
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -31,6 +31,17 @@ module Stationery
|
|
|
31
31
|
multiply(multiply([1, 0, 0, 1, cx, cy], rotation), [1, 0, 0, 1, -cx, -cy])
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def apply(matrix, x, y)
|
|
35
|
+
a, b, c, d, e, f = matrix
|
|
36
|
+
[(a * x) + (c * y) + e, (b * x) + (d * y) + f]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The uniform scale a matrix applies to areas, as a length factor.
|
|
40
|
+
def scale(matrix)
|
|
41
|
+
a, b, c, d, = matrix
|
|
42
|
+
Math.sqrt(((a * d) - (b * c)).abs)
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
# The transform that applies `inner` first, then `outer`.
|
|
35
46
|
def multiply(outer, inner)
|
|
36
47
|
a, b, c, d, e, f = outer
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Tagged (accessible) PDF: a structure tree of standard structure types
|
|
5
|
+
# (ISO 32000-1 §14.8.4) whose leaves point at marked content on the pages.
|
|
6
|
+
module Tagging
|
|
7
|
+
# Grouping roles a box can take, as standard structure types.
|
|
8
|
+
ROLES = { section: :Sect, div: :Div, blockquote: :BlockQuote, note: :Note, caption: :Caption,
|
|
9
|
+
article: :Art, part: :Part }.freeze
|
|
10
|
+
|
|
11
|
+
# One marked-content sequence: the page it is on and its MCID there.
|
|
12
|
+
MarkedContent = Data.define(:page, :mcid)
|
|
13
|
+
|
|
14
|
+
# An annotation (the page's link Hash) that belongs to an element.
|
|
15
|
+
ObjectRef = Data.define(:page, :annotation)
|
|
16
|
+
|
|
17
|
+
def self.role(role)
|
|
18
|
+
ROLES.fetch(role) { raise ArgumentError, "unknown role #{role.inspect} (use #{ROLES.keys.join(", ")})" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.heading(level)
|
|
22
|
+
return :P unless level
|
|
23
|
+
raise ArgumentError, "heading: takes 1 to 6, got #{level.inspect}" unless (1..6).cover?(level)
|
|
24
|
+
|
|
25
|
+
:"H#{level}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A structure element. Layout nodes create one per source node and share
|
|
29
|
+
# it with every fragment a split produces, so content continued on the
|
|
30
|
+
# next page stays one element. It joins the tree where it first paints.
|
|
31
|
+
class Element
|
|
32
|
+
CELLS = %i[TD TH].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :type, :alt, :kind, :kids, :parent, :attributes
|
|
35
|
+
|
|
36
|
+
# `alt` is a Figure's alternate text; `kind` names what drew it in
|
|
37
|
+
# warnings; `attributes` are { owner => { key => value } } (/A entries).
|
|
38
|
+
def initialize(type, alt: nil, kind: nil, attributes: {})
|
|
39
|
+
@type = type
|
|
40
|
+
@alt = alt
|
|
41
|
+
@kind = kind
|
|
42
|
+
@attributes = attributes.transform_values(&:dup)
|
|
43
|
+
@kids = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def attached? = !@parent.nil?
|
|
47
|
+
|
|
48
|
+
def attach(parent)
|
|
49
|
+
return if @parent
|
|
50
|
+
|
|
51
|
+
@parent = parent
|
|
52
|
+
parent.kids << self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A bounding box in PDF space, kept from the first fragment painted.
|
|
56
|
+
def place(bbox)
|
|
57
|
+
(@attributes[:Layout] ||= {})[:BBox] ||= bbox
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bbox = @attributes.dig(:Layout, :BBox)
|
|
62
|
+
def marked_content = @kids.grep(MarkedContent)
|
|
63
|
+
def elements = @kids.grep(Element)
|
|
64
|
+
|
|
65
|
+
# Holds no content, so the writer leaves it out; an empty table cell
|
|
66
|
+
# stays, keeping its row's columns in place.
|
|
67
|
+
def empty?
|
|
68
|
+
return false if CELLS.include?(@type)
|
|
69
|
+
|
|
70
|
+
@kids.none?(MarkedContent) && @kids.none?(ObjectRef) && elements.all?(&:empty?)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Tagging
|
|
5
|
+
# One render's structure: the Document element and, per page, the element
|
|
6
|
+
# each MCID belongs to (what the parent tree maps back from).
|
|
7
|
+
class Tree
|
|
8
|
+
attr_reader :root
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@root = Element.new(:Document)
|
|
12
|
+
@pages = {}.compare_by_identity
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The next MCID on `page`, recorded as marked content of `element`.
|
|
16
|
+
def mark(page, element)
|
|
17
|
+
marked = (@pages[page] ||= [])
|
|
18
|
+
marked << element
|
|
19
|
+
element.kids << MarkedContent.new(page, marked.size - 1)
|
|
20
|
+
marked.size - 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def marked(page) = @pages.fetch(page, [])
|
|
24
|
+
|
|
25
|
+
# Records the accessibility gaps `strict` should catch.
|
|
26
|
+
def audit(pages, warnings, lang:)
|
|
27
|
+
warnings << Warnings::MissingLanguage.new unless lang
|
|
28
|
+
figures(@root).each do |figure|
|
|
29
|
+
next if figure.alt
|
|
30
|
+
|
|
31
|
+
page = pages.index(figure.marked_content.first.page) + 1
|
|
32
|
+
warnings << Warnings::MissingAlt.new(kind: figure.kind, page:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def figures(element)
|
|
39
|
+
element.elements.flat_map { |kid| kid.type == :Figure ? [kid] : figures(kid) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Tagging
|
|
5
|
+
# Writes a Tree as the catalog's /StructTreeRoot: one StructElem per
|
|
6
|
+
# element that holds content, and the /ParentTree mapping each page's
|
|
7
|
+
# /StructParents key to its elements by MCID.
|
|
8
|
+
class Writer
|
|
9
|
+
def initialize(tree, pages:, refs:)
|
|
10
|
+
@tree = tree
|
|
11
|
+
@page_refs = pages.zip(refs).to_h.compare_by_identity
|
|
12
|
+
@keys = pages.select { |page| tree.marked(page).any? }.each_with_index.to_h.compare_by_identity
|
|
13
|
+
@annotations = {}.compare_by_identity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# An annotation's entries: the /StructParent key of its element, when it
|
|
17
|
+
# belongs to one. `ref` is the annotation's reference, for the /OBJR.
|
|
18
|
+
def annotation(link, ref)
|
|
19
|
+
return {} unless link[:tag]
|
|
20
|
+
|
|
21
|
+
@annotations[link] = [ref, @keys.size + @annotations.size]
|
|
22
|
+
{ StructParent: @annotations[link][1] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The page dictionary's entries: its parent tree key, when it has one.
|
|
26
|
+
def page_entries(page)
|
|
27
|
+
key = @keys[page]
|
|
28
|
+
key ? { StructParents: key } : {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Writes the tree and returns the catalog entries pointing at it.
|
|
32
|
+
def write(writer)
|
|
33
|
+
root = writer.reserve
|
|
34
|
+
@refs = {}.compare_by_identity
|
|
35
|
+
reserve(writer, @tree.root)
|
|
36
|
+
@refs.each_key { |element| writer.set(@refs[element], dictionary(element, root)) }
|
|
37
|
+
writer.set(root, { Type: :StructTreeRoot, K: [@refs[@tree.root]], ParentTree: { Nums: parent_tree },
|
|
38
|
+
ParentTreeNextKey: @keys.size + @annotations.size })
|
|
39
|
+
{ MarkInfo: { Marked: true }, StructTreeRoot: root }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def reserve(writer, element)
|
|
45
|
+
@refs[element] = writer.reserve
|
|
46
|
+
element.elements.each { |kid| reserve(writer, kid) unless kid.empty? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def dictionary(element, root)
|
|
50
|
+
page = element.marked_content.first&.page
|
|
51
|
+
{
|
|
52
|
+
Type: :StructElem, S: element.type, P: element.parent ? @refs[element.parent] : root,
|
|
53
|
+
Pg: page && @page_refs[page], K: element.kids.filter_map { |kid| kid_entry(kid, page) },
|
|
54
|
+
Alt: element.alt && PDF::TextString.new(element.alt),
|
|
55
|
+
A: attributes(element)
|
|
56
|
+
}.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kid_entry(kid, page)
|
|
60
|
+
case kid
|
|
61
|
+
when Element then @refs[kid]
|
|
62
|
+
when MarkedContent
|
|
63
|
+
kid.page.equal?(page) ? kid.mcid : { Type: :MCR, Pg: @page_refs[kid.page], MCID: kid.mcid }
|
|
64
|
+
when ObjectRef
|
|
65
|
+
ref, = @annotations[kid.annotation]
|
|
66
|
+
ref && { Type: :OBJR, Obj: ref, Pg: @page_refs[kid.page] }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def attributes(element)
|
|
71
|
+
list = element.attributes.map { |owner, values| { O: owner, **values } }
|
|
72
|
+
list.size > 1 ? list : list.first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parent_tree
|
|
76
|
+
@keys.flat_map { |page, key| [key, @tree.marked(page).map { |element| @refs[element] }] } +
|
|
77
|
+
@annotations.flat_map { |link, (_, key)| [key, @refs[link[:tag]]] }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require "stringio"
|
|
5
5
|
require "stationery"
|
|
6
|
+
require_relative "structure_reader"
|
|
6
7
|
|
|
7
8
|
module Stationery
|
|
8
9
|
module Testing
|
|
@@ -63,6 +64,20 @@ module Stationery
|
|
|
63
64
|
outlines ? titles(outlines[:First]) : []
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
68
|
+
def structure = @structure ||= StructureReader.new(reader).tree
|
|
69
|
+
|
|
70
|
+
# Whether the catalog marks the PDF as tagged and holds a structure tree.
|
|
71
|
+
def tagged?
|
|
72
|
+
catalog = objects.deref!(objects.trailer[:Root])
|
|
73
|
+
catalog.dig(:MarkInfo, :Marked) == true && !catalog[:StructTreeRoot].nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Text shown outside any marked content (neither tagged nor an artifact).
|
|
77
|
+
def untagged_text
|
|
78
|
+
@untagged_text ||= reader.pages.flat_map { |page| MarkedText.read(page).unmarked }
|
|
79
|
+
end
|
|
80
|
+
|
|
66
81
|
private
|
|
67
82
|
|
|
68
83
|
def objects = reader.objects
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Testing
|
|
7
|
+
# A pdf-reader receiver that collects a page's text by MCID, and the text
|
|
8
|
+
# shown outside any marked content. Text on a new baseline starts after
|
|
9
|
+
# a space, so a paragraph's lines read as one string.
|
|
10
|
+
class MarkedText
|
|
11
|
+
extend Forwardable
|
|
12
|
+
|
|
13
|
+
STATE = %i[save_graphics_state restore_graphics_state concatenate_matrix begin_text_object end_text_object
|
|
14
|
+
set_text_font_and_size move_text_position move_text_position_and_set_leading
|
|
15
|
+
set_text_matrix_and_text_line_matrix move_to_start_of_next_line set_text_leading
|
|
16
|
+
set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
|
|
17
|
+
set_text_rendering_mode].freeze
|
|
18
|
+
|
|
19
|
+
def_delegators :@state, *STATE
|
|
20
|
+
|
|
21
|
+
# { MCID => text } and the strings shown outside marked content.
|
|
22
|
+
attr_reader :texts, :unmarked
|
|
23
|
+
|
|
24
|
+
def self.read(page) = new(page).tap { |receiver| page.walk(receiver) }
|
|
25
|
+
|
|
26
|
+
def initialize(page)
|
|
27
|
+
@state = ::PDF::Reader::PageState.new(page)
|
|
28
|
+
@open = []
|
|
29
|
+
@texts = {}
|
|
30
|
+
@baselines = {}
|
|
31
|
+
@unmarked = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def begin_marked_content_with_pl(_tag, properties)
|
|
35
|
+
@open << (properties[:MCID] if properties.is_a?(Hash))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def begin_marked_content(_tag) = @open << nil
|
|
39
|
+
def end_marked_content = @open.pop
|
|
40
|
+
def show_text(string) = append(string)
|
|
41
|
+
def show_text_with_positioning(params) = params.grep(String).each { |string| append(string) }
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def append(string)
|
|
46
|
+
font = @state.current_font
|
|
47
|
+
text = font.unpack(string).map { |code| font.to_utf8(code) }.join
|
|
48
|
+
return @unmarked << text if @open.empty?
|
|
49
|
+
|
|
50
|
+
mcid = @open.compact.last
|
|
51
|
+
return unless mcid
|
|
52
|
+
|
|
53
|
+
baseline = @state.trm_transform_point(0, 0).y.round(2)
|
|
54
|
+
separator = @texts.key?(mcid) && @baselines[mcid] != baseline ? " " : ""
|
|
55
|
+
@texts[mcid] = "#{@texts[mcid]}#{separator}#{text}"
|
|
56
|
+
@baselines[mcid] = baseline
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -111,6 +111,29 @@ module Stationery
|
|
|
111
111
|
def actual = messages.empty? ? "got none" : "got:\n#{messages.map { |m| " - #{m}" }.join("\n")}"
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
class HaveStructure < Base
|
|
115
|
+
def description = "have structure #{@expected.inspect}"
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def match?(pdf) = pdf.structure == @expected
|
|
120
|
+
def actual = "got #{@inspector.structure.inspect}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class HaveTaggedContent < Base
|
|
124
|
+
def description = "be tagged with every text in marked content"
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def match?(pdf) = pdf.tagged? && pdf.untagged_text.empty?
|
|
129
|
+
|
|
130
|
+
def actual
|
|
131
|
+
return "but it is not tagged" unless @inspector.tagged?
|
|
132
|
+
|
|
133
|
+
"got untagged text #{@inspector.untagged_text.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
114
137
|
def have_pdf_text(expected) = HaveText.new(expected)
|
|
115
138
|
def have_pdf_text_on_page(page, expected) = HaveTextOnPage.new(page, expected)
|
|
116
139
|
def have_page_count(expected) = HavePageCount.new(expected)
|
|
@@ -118,6 +141,8 @@ module Stationery
|
|
|
118
141
|
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
119
142
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
120
143
|
def have_no_warnings = HaveNoWarnings.new
|
|
144
|
+
def have_structure(expected) = HaveStructure.new(expected)
|
|
145
|
+
def have_tagged_content = HaveTaggedContent.new
|
|
121
146
|
end
|
|
122
147
|
end
|
|
123
148
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "marked_text"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Testing
|
|
7
|
+
# Reads a tagged PDF's structure tree as nested arrays, each element's
|
|
8
|
+
# text taken from the marked content it owns:
|
|
9
|
+
#
|
|
10
|
+
# [[:Document, [[:H1, "Intro"], [:P, ["Read", [:Link, "the docs"], "now"]], [:Figure, "Logo"]]]]
|
|
11
|
+
#
|
|
12
|
+
# An element is [type, text] when it holds only text, [type, [kids]]
|
|
13
|
+
# when it holds elements (with its own text between them), [type] when
|
|
14
|
+
# empty. A Figure reads as its alt text. Untagged PDFs read as [].
|
|
15
|
+
class StructureReader
|
|
16
|
+
def initialize(reader)
|
|
17
|
+
@reader = reader
|
|
18
|
+
@objects = reader.objects
|
|
19
|
+
@texts = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tree
|
|
23
|
+
root = @objects.deref(@objects.deref(@objects.trailer[:Root])[:StructTreeRoot])
|
|
24
|
+
root ? kids_of(root).grep(Array) : []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def element(dictionary)
|
|
30
|
+
return [dictionary[:S], dictionary[:Alt]].compact if dictionary[:Alt]
|
|
31
|
+
|
|
32
|
+
kids = merge(kids_of(dictionary, dictionary[:Pg]))
|
|
33
|
+
return [dictionary[:S]] if kids.empty?
|
|
34
|
+
|
|
35
|
+
[dictionary[:S], kids.size == 1 && kids.first.is_a?(String) ? kids.first : kids]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def kids_of(dictionary, page = nil)
|
|
39
|
+
Array(@objects.deref(dictionary[:K])).filter_map do |kid|
|
|
40
|
+
kid = @objects.deref(kid)
|
|
41
|
+
case kid
|
|
42
|
+
when Integer then text(page, kid)
|
|
43
|
+
when Hash then kid[:Type] == :MCR ? text(kid[:Pg], kid[:MCID]) : kid_element(kid)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def kid_element(kid) = kid[:Type] == :OBJR ? nil : element(kid)
|
|
49
|
+
|
|
50
|
+
# Adjacent strings (one element's marked content on two lines or pages) join with a space.
|
|
51
|
+
def merge(kids)
|
|
52
|
+
kids.each_with_object([]) do |kid, merged|
|
|
53
|
+
next if kid.is_a?(String) && kid.strip.empty?
|
|
54
|
+
|
|
55
|
+
if kid.is_a?(String) && merged.last.is_a?(String)
|
|
56
|
+
merged[-1] = "#{merged.last} #{kid.strip}"
|
|
57
|
+
else
|
|
58
|
+
merged << (kid.is_a?(String) ? kid.strip : kid)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def text(page, mcid) = page_texts(page)[mcid]
|
|
64
|
+
|
|
65
|
+
def page_texts(page)
|
|
66
|
+
index = @objects.page_references.index(page)
|
|
67
|
+
@texts[index] ||= MarkedText.read(@reader.page(index + 1)).texts
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -43,17 +43,32 @@ module Stationery
|
|
|
43
43
|
shrink(max_height)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
# `tag` is the structure element the text belongs to in a tagged PDF;
|
|
47
|
+
# linked runs become Link elements inside it.
|
|
48
|
+
def draw(canvas, x, y, tag: nil)
|
|
49
|
+
links = Links.new
|
|
50
|
+
canvas.tag_runs(tag) do |mark|
|
|
51
|
+
top = y
|
|
52
|
+
@lines.each do |line|
|
|
53
|
+
offset = x + Geometry.align_offset(@align, @width, line.width)
|
|
54
|
+
extra = word_spacing(line)
|
|
55
|
+
spaces = 0
|
|
56
|
+
line.fragments.each do |fragment|
|
|
57
|
+
link = links.for(fragment.style.link)
|
|
58
|
+
mark.call(link) { draw_fragment(canvas, fragment, offset + (extra * spaces), top, line, extra, link:) }
|
|
59
|
+
spaces += fragment.text.count(" ")
|
|
60
|
+
end
|
|
61
|
+
top += line.height + @leading
|
|
55
62
|
end
|
|
56
|
-
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# One Link element per run of fragments with the same link target.
|
|
67
|
+
class Links
|
|
68
|
+
def for(target)
|
|
69
|
+
@element = nil unless target == @target
|
|
70
|
+
@target = target
|
|
71
|
+
@element = target && (@element || Tagging::Element.new(:Link))
|
|
57
72
|
end
|
|
58
73
|
end
|
|
59
74
|
|
|
@@ -94,7 +109,7 @@ module Stationery
|
|
|
94
109
|
[(@width - line.width) / line.space_count, 0].max
|
|
95
110
|
end
|
|
96
111
|
|
|
97
|
-
def draw_fragment(canvas, fragment, offset, top, line, word_spacing)
|
|
112
|
+
def draw_fragment(canvas, fragment, offset, top, line, word_spacing, link: nil)
|
|
98
113
|
style = fragment.style
|
|
99
114
|
count_missing(fragment)
|
|
100
115
|
x = offset + fragment.x
|
|
@@ -102,9 +117,10 @@ module Stationery
|
|
|
102
117
|
color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
|
|
103
118
|
opacity: style.opacity, underline: style.underline,
|
|
104
119
|
strikethrough: style.strikethrough, kerning: style.kerning,
|
|
120
|
+
ligatures: style.ligatures,
|
|
105
121
|
synthetic_bold: fragment.face.synthetic_bold,
|
|
106
122
|
synthetic_oblique: fragment.face.synthetic_oblique, word_spacing:)
|
|
107
|
-
canvas.link(x, top, width, line.height, style.link) if style.link
|
|
123
|
+
canvas.link(x, top, width, line.height, style.link, tag: link) if style.link
|
|
108
124
|
end
|
|
109
125
|
|
|
110
126
|
def count_missing(fragment)
|
|
@@ -6,9 +6,11 @@ module Stationery
|
|
|
6
6
|
|
|
7
7
|
# How a run of text looks. Immutable; derive variants with #with.
|
|
8
8
|
Style = Data.define(:family, :size, :weight, :style, :color, :letter_spacing,
|
|
9
|
-
:underline, :strikethrough, :script, :link, :opacity, :kerning
|
|
9
|
+
:underline, :strikethrough, :script, :link, :opacity, :kerning,
|
|
10
|
+
:ligatures) do
|
|
10
11
|
def initialize(family:, size: 10, weight: :regular, style: :normal, color: "#000000", letter_spacing: 0,
|
|
11
|
-
underline: false, strikethrough: false, script: nil, link: nil, opacity: nil, kerning: true
|
|
12
|
+
underline: false, strikethrough: false, script: nil, link: nil, opacity: nil, kerning: true,
|
|
13
|
+
ligatures: true)
|
|
12
14
|
super
|
|
13
15
|
end
|
|
14
16
|
|
|
@@ -125,7 +125,8 @@ module Stationery
|
|
|
125
125
|
|
|
126
126
|
def measure(text, style)
|
|
127
127
|
font = @book.resolve(style).first
|
|
128
|
-
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning
|
|
128
|
+
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
|
129
|
+
ligatures: style.ligatures)
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
end
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -37,6 +37,14 @@ module Stationery
|
|
|
37
37
|
def message = %(anchor "#{name}" on page #{page} is already defined)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
MissingAlt = Data.define(:kind, :page) do
|
|
41
|
+
def message = "#{kind} on page #{page} has no alt: text"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
MissingLanguage = Data.define do
|
|
45
|
+
def message = "tagged PDF has no language: set metadata lang:"
|
|
46
|
+
end
|
|
47
|
+
|
|
40
48
|
def initialize
|
|
41
49
|
@items = []
|
|
42
50
|
@glyphs = Hash.new(0)
|