stationery 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +136 -14
  4. data/lib/stationery/builder.rb +12 -0
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas.rb +50 -8
  8. data/lib/stationery/document.rb +28 -10
  9. data/lib/stationery/elements/forms.rb +55 -0
  10. data/lib/stationery/elements/lists.rb +15 -5
  11. data/lib/stationery/elements/rich.rb +7 -5
  12. data/lib/stationery/elements.rb +34 -7
  13. data/lib/stationery/fonts/fallback.rb +6 -4
  14. data/lib/stationery/fonts/font.rb +51 -8
  15. data/lib/stationery/forms/acro_form.rb +115 -0
  16. data/lib/stationery/forms/appearance.rb +119 -0
  17. data/lib/stationery/forms/field.rb +122 -0
  18. data/lib/stationery/forms/metrics.rb +49 -0
  19. data/lib/stationery/layout/box.rb +64 -12
  20. data/lib/stationery/layout/field.rb +51 -0
  21. data/lib/stationery/layout/flow.rb +17 -9
  22. data/lib/stationery/layout/image.rb +35 -4
  23. data/lib/stationery/layout/list_item.rb +15 -6
  24. data/lib/stationery/layout/node.rb +2 -0
  25. data/lib/stationery/layout/paginator.rb +3 -2
  26. data/lib/stationery/layout/stack.rb +81 -0
  27. data/lib/stationery/layout/svg.rb +7 -3
  28. data/lib/stationery/layout/table/cell.rb +10 -2
  29. data/lib/stationery/layout/table.rb +42 -10
  30. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  31. data/lib/stationery/layout/table_of_contents.rb +1 -1
  32. data/lib/stationery/layout/text.rb +4 -3
  33. data/lib/stationery/minitest.rb +3 -0
  34. data/lib/stationery/page.rb +5 -2
  35. data/lib/stationery/page_templates.rb +7 -4
  36. data/lib/stationery/pdf/assembler.rb +36 -4
  37. data/lib/stationery/preview.rb +23 -2
  38. data/lib/stationery/rails/previews_controller.rb +2 -7
  39. data/lib/stationery/rich/renderer/inlines.rb +10 -2
  40. data/lib/stationery/rich/renderer/links.rb +28 -0
  41. data/lib/stationery/rich/renderer.rb +9 -6
  42. data/lib/stationery/rich/styles.rb +2 -0
  43. data/lib/stationery/rspec.rb +4 -0
  44. data/lib/stationery/structure.rb +15 -9
  45. data/lib/stationery/tagging/element.rb +74 -0
  46. data/lib/stationery/tagging/tree.rb +43 -0
  47. data/lib/stationery/tagging/writer.rb +81 -0
  48. data/lib/stationery/testing/inspector.rb +22 -1
  49. data/lib/stationery/testing/marked_text.rb +60 -0
  50. data/lib/stationery/testing/matchers.rb +35 -0
  51. data/lib/stationery/testing/structure_reader.rb +71 -0
  52. data/lib/stationery/text/paragraph.rb +27 -12
  53. data/lib/stationery/version.rb +1 -1
  54. data/lib/stationery/warnings.rb +12 -0
  55. data/lib/stationery.rb +11 -0
  56. metadata +15 -1
@@ -18,6 +18,8 @@ module Stationery
18
18
  blockquote: { border: { sides: [:left], width: 3, color: "#E5E7EB" }, padding: [0, 0, 0, 10] },
19
19
  hr: { height: 1, color: "#E5E7EB" },
20
20
  table: { cell: { padding: 4 }, header: { weight: :bold } },
21
+ ul: {},
22
+ ol: {},
21
23
  li: {},
22
24
  img: { max_width: nil }
23
25
  }.freeze
@@ -3,4 +3,8 @@
3
3
  require "stationery"
4
4
  require_relative "testing/matchers"
5
5
 
6
+ # Composable adds `and`/`or` and lets a matcher sit inside `include`, `match`
7
+ # and `all`; Base stays framework-free for Minitest, so it is mixed in here.
8
+ Stationery::Testing::Matchers::Base.include(RSpec::Matchers::Composable)
9
+
6
10
  RSpec.configure { |config| config.include Stationery::Testing::Matchers }
@@ -11,10 +11,13 @@ module Stationery
11
11
 
12
12
  # Returns the destinations by name; unresolved links are dropped.
13
13
  # Runs before resources are written, so slot digits join the font subset.
14
- def self.resolve(pages, warnings:, resources: nil, book: nil) = new(pages, warnings, resources, book).resolve
14
+ def self.resolve(pages, warnings:, resources: nil, book: nil, tagging: nil)
15
+ new(pages, warnings, resources, book, tagging).resolve
16
+ end
15
17
 
16
- def initialize(pages, warnings, resources, book)
18
+ def initialize(pages, warnings, resources, book, tagging = nil)
17
19
  @pages = pages
20
+ @tagging = tagging
18
21
  @warnings = warnings
19
22
  @resources = resources
20
23
  @book = book
@@ -57,19 +60,22 @@ module Stationery
57
60
  font, face = @book.resolve(slot.style)
58
61
  style = slot.style
59
62
  width = font.width_of(label, style.render_size, letter_spacing: style.letter_spacing)
60
- canvas = Canvas.new(page, @resources)
61
- canvas.link(*slot.link, "##{slot.anchor}") if slot.link
62
- canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
63
- size: style.render_size, color: style.color,
64
- letter_spacing: style.letter_spacing, opacity: style.opacity,
65
- synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
63
+ canvas = Canvas.new(page, @resources, tagging: @tagging)
64
+ link, number = slot.tags
65
+ canvas.link(*slot.link, "##{slot.anchor}", tag: link) if slot.link
66
+ canvas.tag(number) do
67
+ canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
68
+ size: style.render_size, color: style.color,
69
+ letter_spacing: style.letter_spacing, opacity: style.opacity,
70
+ synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
71
+ end
66
72
  end
67
73
 
68
74
  def link_to(link, index)
69
75
  return link unless link.key?(:dest)
70
76
 
71
77
  destination = @destinations[link[:dest]]
72
- return { rect: link[:rect], dest: destination } if destination
78
+ return link.merge!(dest: destination) if destination
73
79
 
74
80
  @warnings << Warnings::UnresolvedLink.new(name: link[:dest], page: index + 1)
75
81
  nil
@@ -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
@@ -59,13 +60,33 @@ module Stationery
59
60
  end
60
61
 
61
62
  def bookmarks
62
- outlines = objects.deref!(objects.trailer[:Root])[:Outlines]
63
+ outlines = catalog[:Outlines]
63
64
  outlines ? titles(outlines[:First]) : []
64
65
  end
65
66
 
67
+ # The catalog /Lang written by `metadata lang:`, or nil.
68
+ def lang
69
+ lang = catalog[:Lang]
70
+ decode(lang) if lang
71
+ end
72
+
73
+ # The structure tree of a tagged PDF as nested arrays; see StructureReader.
74
+ def structure = @structure ||= StructureReader.new(reader).tree
75
+
76
+ # Whether the catalog marks the PDF as tagged and holds a structure tree.
77
+ def tagged?
78
+ catalog.dig(:MarkInfo, :Marked) == true && !catalog[:StructTreeRoot].nil?
79
+ end
80
+
81
+ # Text shown outside any marked content (neither tagged nor an artifact).
82
+ def untagged_text
83
+ @untagged_text ||= reader.pages.flat_map { |page| MarkedText.read(page).unmarked }
84
+ end
85
+
66
86
  private
67
87
 
68
88
  def objects = reader.objects
89
+ def catalog = objects.deref!(objects.trailer[:Root])
69
90
 
70
91
  def annotations
71
92
  reader.pages.flat_map do |page|
@@ -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
@@ -101,6 +101,15 @@ module Stationery
101
101
  def actual = "got bookmarks #{@inspector.bookmarks.inspect}"
102
102
  end
103
103
 
104
+ class HaveLanguage < Base
105
+ def description = "have language #{show(@expected)}"
106
+
107
+ private
108
+
109
+ def match?(pdf) = pdf.lang == @expected.to_s
110
+ def actual = @inspector.lang ? "got #{@inspector.lang.inspect}" : "got none"
111
+ end
112
+
104
113
  class HaveNoWarnings < Base
105
114
  def description = "have no warnings"
106
115
 
@@ -111,13 +120,39 @@ module Stationery
111
120
  def actual = messages.empty? ? "got none" : "got:\n#{messages.map { |m| " - #{m}" }.join("\n")}"
112
121
  end
113
122
 
123
+ class HaveStructure < Base
124
+ def description = "have structure #{@expected.inspect}"
125
+
126
+ private
127
+
128
+ def match?(pdf) = pdf.structure == @expected
129
+ def actual = "got #{@inspector.structure.inspect}"
130
+ end
131
+
132
+ class HaveTaggedContent < Base
133
+ def description = "be tagged with every text in marked content"
134
+
135
+ private
136
+
137
+ def match?(pdf) = pdf.tagged? && pdf.untagged_text.empty?
138
+
139
+ def actual
140
+ return "but it is not tagged" unless @inspector.tagged?
141
+
142
+ "got untagged text #{@inspector.untagged_text.inspect}"
143
+ end
144
+ end
145
+
114
146
  def have_pdf_text(expected) = HaveText.new(expected)
115
147
  def have_pdf_text_on_page(page, expected) = HaveTextOnPage.new(page, expected)
116
148
  def have_page_count(expected) = HavePageCount.new(expected)
117
149
  def have_pdf_link(expected) = HaveLink.new(expected)
118
150
  def have_image_count(expected) = HaveImageCount.new(expected)
119
151
  def have_bookmark(title) = HaveBookmark.new(title)
152
+ def have_pdf_language(lang) = HaveLanguage.new(lang)
120
153
  def have_no_warnings = HaveNoWarnings.new
154
+ def have_structure(expected) = HaveStructure.new(expected)
155
+ def have_tagged_content = HaveTaggedContent.new
121
156
  end
122
157
  end
123
158
  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.5.0"
5
5
  end
@@ -33,10 +33,22 @@ module Stationery
33
33
  def message = %(link to "#{name}" on page #{page} has no matching anchor)
34
34
  end
35
35
 
36
+ DroppedLink = Data.define(:href) do
37
+ def message = %(link "#{href}" dropped: scheme not allowed)
38
+ end
39
+
36
40
  DuplicateAnchor = Data.define(:name, :page) do
37
41
  def message = %(anchor "#{name}" on page #{page} is already defined)
38
42
  end
39
43
 
44
+ MissingAlt = Data.define(:kind, :page) do
45
+ def message = "#{kind} on page #{page} has no alt: text"
46
+ end
47
+
48
+ MissingLanguage = Data.define do
49
+ def message = "tagged PDF has no language: set metadata lang:"
50
+ end
51
+
40
52
  def initialize
41
53
  @items = []
42
54
  @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,8 @@ 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/stack"
111
+ require_relative "stationery/layout/field"
102
112
  require_relative "stationery/layout/list_item"
103
113
  require_relative "stationery/list_markers"
104
114
  require_relative "stationery/outline"
@@ -106,6 +116,7 @@ require_relative "stationery/builder"
106
116
  require_relative "stationery/elements"
107
117
  require_relative "stationery/elements/lists"
108
118
  require_relative "stationery/elements/rich"
119
+ require_relative "stationery/elements/forms"
109
120
  require_relative "stationery/component"
110
121
  require_relative "stationery/regions"
111
122
  require_relative "stationery/page_templates"