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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. 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
- 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
@@ -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) do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.2.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)