stationery 0.3.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +98 -4
  4. data/lib/stationery/canvas/debug.rb +2 -1
  5. data/lib/stationery/canvas/marking.rb +124 -0
  6. data/lib/stationery/canvas.rb +24 -8
  7. data/lib/stationery/document.rb +28 -10
  8. data/lib/stationery/elements/forms.rb +55 -0
  9. data/lib/stationery/elements/lists.rb +15 -5
  10. data/lib/stationery/elements.rb +10 -7
  11. data/lib/stationery/forms/acro_form.rb +115 -0
  12. data/lib/stationery/forms/appearance.rb +119 -0
  13. data/lib/stationery/forms/field.rb +122 -0
  14. data/lib/stationery/forms/metrics.rb +49 -0
  15. data/lib/stationery/layout/box.rb +11 -5
  16. data/lib/stationery/layout/field.rb +51 -0
  17. data/lib/stationery/layout/flow.rb +17 -9
  18. data/lib/stationery/layout/image.rb +4 -2
  19. data/lib/stationery/layout/list_item.rb +15 -6
  20. data/lib/stationery/layout/node.rb +2 -0
  21. data/lib/stationery/layout/paginator.rb +3 -2
  22. data/lib/stationery/layout/svg.rb +7 -3
  23. data/lib/stationery/layout/table/cell.rb +10 -2
  24. data/lib/stationery/layout/table.rb +42 -10
  25. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  26. data/lib/stationery/layout/table_of_contents.rb +1 -1
  27. data/lib/stationery/layout/text.rb +4 -3
  28. data/lib/stationery/minitest.rb +2 -0
  29. data/lib/stationery/page.rb +5 -2
  30. data/lib/stationery/page_templates.rb +7 -4
  31. data/lib/stationery/pdf/assembler.rb +36 -4
  32. data/lib/stationery/rich/renderer.rb +3 -3
  33. data/lib/stationery/structure.rb +15 -9
  34. data/lib/stationery/tagging/element.rb +74 -0
  35. data/lib/stationery/tagging/tree.rb +43 -0
  36. data/lib/stationery/tagging/writer.rb +81 -0
  37. data/lib/stationery/testing/inspector.rb +15 -0
  38. data/lib/stationery/testing/marked_text.rb +60 -0
  39. data/lib/stationery/testing/matchers.rb +25 -0
  40. data/lib/stationery/testing/structure_reader.rb +71 -0
  41. data/lib/stationery/text/paragraph.rb +27 -12
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +8 -0
  44. data/lib/stationery.rb +10 -0
  45. metadata +13 -1
@@ -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
- def draw(canvas, x, y)
47
- top = y
48
- @lines.each do |line|
49
- offset = x + Geometry.align_offset(@align, @width, line.width)
50
- extra = word_spacing(line)
51
- spaces = 0
52
- line.fragments.each do |fragment|
53
- draw_fragment(canvas, fragment, offset + (extra * spaces), top, line, extra)
54
- spaces += fragment.text.count(" ")
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
- top += line.height + @leading
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
@@ -105,7 +120,7 @@ module Stationery
105
120
  ligatures: style.ligatures,
106
121
  synthetic_bold: fragment.face.synthetic_bold,
107
122
  synthetic_oblique: fragment.face.synthetic_oblique, word_spacing:)
108
- 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
109
124
  end
110
125
 
111
126
  def count_missing(fragment)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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)
data/lib/stationery.rb CHANGED
@@ -49,11 +49,19 @@ require_relative "stationery/color"
49
49
  require_relative "stationery/geometry"
50
50
  require_relative "stationery/page"
51
51
  require_relative "stationery/resources"
52
+ require_relative "stationery/tagging/element"
53
+ require_relative "stationery/tagging/tree"
54
+ require_relative "stationery/tagging/writer"
52
55
  require_relative "stationery/canvas/path"
53
56
  require_relative "stationery/canvas/text"
54
57
  require_relative "stationery/canvas/debug"
58
+ require_relative "stationery/canvas/marking"
55
59
  require_relative "stationery/canvas"
56
60
  require_relative "stationery/pdf/outline_writer"
61
+ require_relative "stationery/forms/metrics"
62
+ require_relative "stationery/forms/field"
63
+ require_relative "stationery/forms/appearance"
64
+ require_relative "stationery/forms/acro_form"
57
65
  require_relative "stationery/pdf/assembler"
58
66
  require_relative "stationery/text/style"
59
67
  require_relative "stationery/fonts/fallback"
@@ -99,6 +107,7 @@ require_relative "stationery/svg/document"
99
107
  require_relative "stationery/layout/svg"
100
108
  require_relative "stationery/layout/wrap"
101
109
  require_relative "stationery/layout/positioned"
110
+ require_relative "stationery/layout/field"
102
111
  require_relative "stationery/layout/list_item"
103
112
  require_relative "stationery/list_markers"
104
113
  require_relative "stationery/outline"
@@ -106,6 +115,7 @@ require_relative "stationery/builder"
106
115
  require_relative "stationery/elements"
107
116
  require_relative "stationery/elements/lists"
108
117
  require_relative "stationery/elements/rich"
118
+ require_relative "stationery/elements/forms"
109
119
  require_relative "stationery/component"
110
120
  require_relative "stationery/regions"
111
121
  require_relative "stationery/page_templates"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -32,6 +32,7 @@ files:
32
32
  - lib/stationery/builder.rb
33
33
  - lib/stationery/canvas.rb
34
34
  - lib/stationery/canvas/debug.rb
35
+ - lib/stationery/canvas/marking.rb
35
36
  - lib/stationery/canvas/path.rb
36
37
  - lib/stationery/canvas/text.rb
37
38
  - lib/stationery/cli.rb
@@ -41,6 +42,7 @@ files:
41
42
  - lib/stationery/component.rb
42
43
  - lib/stationery/document.rb
43
44
  - lib/stationery/elements.rb
45
+ - lib/stationery/elements/forms.rb
44
46
  - lib/stationery/elements/lists.rb
45
47
  - lib/stationery/elements/rich.rb
46
48
  - lib/stationery/errors.rb
@@ -81,6 +83,10 @@ files:
81
83
  - lib/stationery/fonts/to_unicode.rb
82
84
  - lib/stationery/fonts/true_type.rb
83
85
  - lib/stationery/fonts/woff.rb
86
+ - lib/stationery/forms/acro_form.rb
87
+ - lib/stationery/forms/appearance.rb
88
+ - lib/stationery/forms/field.rb
89
+ - lib/stationery/forms/metrics.rb
84
90
  - lib/stationery/geometry.rb
85
91
  - lib/stationery/html/document.rb
86
92
  - lib/stationery/html/tokenizer.rb
@@ -94,6 +100,7 @@ files:
94
100
  - lib/stationery/images/png.rb
95
101
  - lib/stationery/images/scanlines.rb
96
102
  - lib/stationery/layout/box.rb
103
+ - lib/stationery/layout/field.rb
97
104
  - lib/stationery/layout/flow.rb
98
105
  - lib/stationery/layout/image.rb
99
106
  - lib/stationery/layout/leaves.rb
@@ -164,8 +171,13 @@ files:
164
171
  - lib/stationery/svg/stylesheet.rb
165
172
  - lib/stationery/svg/text.rb
166
173
  - lib/stationery/svg/transform.rb
174
+ - lib/stationery/tagging/element.rb
175
+ - lib/stationery/tagging/tree.rb
176
+ - lib/stationery/tagging/writer.rb
167
177
  - lib/stationery/testing/inspector.rb
178
+ - lib/stationery/testing/marked_text.rb
168
179
  - lib/stationery/testing/matchers.rb
180
+ - lib/stationery/testing/structure_reader.rb
169
181
  - lib/stationery/text/entities.rb
170
182
  - lib/stationery/text/entities/html4.rb
171
183
  - lib/stationery/text/line.rb