stationery 0.1.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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +145 -0
  5. data/lib/stationery/builder.rb +61 -0
  6. data/lib/stationery/canvas/path.rb +82 -0
  7. data/lib/stationery/canvas/text.rb +55 -0
  8. data/lib/stationery/canvas.rb +121 -0
  9. data/lib/stationery/color.rb +62 -0
  10. data/lib/stationery/component.rb +54 -0
  11. data/lib/stationery/document.rb +95 -0
  12. data/lib/stationery/elements.rb +90 -0
  13. data/lib/stationery/errors.rb +11 -0
  14. data/lib/stationery/fonts/cmap.rb +75 -0
  15. data/lib/stationery/fonts/family.rb +51 -0
  16. data/lib/stationery/fonts/font.rb +149 -0
  17. data/lib/stationery/fonts/font_book.rb +36 -0
  18. data/lib/stationery/fonts/name_table.rb +39 -0
  19. data/lib/stationery/fonts/registry.rb +38 -0
  20. data/lib/stationery/fonts/subset.rb +139 -0
  21. data/lib/stationery/fonts/true_type.rb +174 -0
  22. data/lib/stationery/geometry.rb +56 -0
  23. data/lib/stationery/images/cache.rb +32 -0
  24. data/lib/stationery/images/images.rb +45 -0
  25. data/lib/stationery/images/jpeg.rb +53 -0
  26. data/lib/stationery/images/png.rb +119 -0
  27. data/lib/stationery/images/scanlines.rb +56 -0
  28. data/lib/stationery/layout/box.rb +134 -0
  29. data/lib/stationery/layout/flow.rb +121 -0
  30. data/lib/stationery/layout/image.rb +53 -0
  31. data/lib/stationery/layout/leaves.rb +58 -0
  32. data/lib/stationery/layout/node.rb +46 -0
  33. data/lib/stationery/layout/paginator.rb +41 -0
  34. data/lib/stationery/layout/positioned.rb +24 -0
  35. data/lib/stationery/layout/row.rb +71 -0
  36. data/lib/stationery/layout/table/cell.rb +93 -0
  37. data/lib/stationery/layout/table/selection.rb +52 -0
  38. data/lib/stationery/layout/table/widths.rb +53 -0
  39. data/lib/stationery/layout/table.rb +118 -0
  40. data/lib/stationery/layout/text.rb +62 -0
  41. data/lib/stationery/page.rb +47 -0
  42. data/lib/stationery/page_templates.rb +36 -0
  43. data/lib/stationery/pdf/assembler.rb +53 -0
  44. data/lib/stationery/pdf/serializer.rb +56 -0
  45. data/lib/stationery/pdf/stream.rb +27 -0
  46. data/lib/stationery/pdf/types.rb +15 -0
  47. data/lib/stationery/pdf/writer.rb +59 -0
  48. data/lib/stationery/rails.rb +20 -0
  49. data/lib/stationery/resources.rb +34 -0
  50. data/lib/stationery/text/entities.rb +23 -0
  51. data/lib/stationery/text/line.rb +26 -0
  52. data/lib/stationery/text/markup.rb +103 -0
  53. data/lib/stationery/text/paragraph.rb +98 -0
  54. data/lib/stationery/text/runs_builder.rb +52 -0
  55. data/lib/stationery/text/style.rb +46 -0
  56. data/lib/stationery/text/wrapper.rb +131 -0
  57. data/lib/stationery/version.rb +5 -0
  58. data/lib/stationery.rb +58 -0
  59. metadata +108 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Table < Node
6
+ # Column widths from a spec (points, a fraction of the table, or nil for
7
+ # flexible), each column's natural and minimum width, and a target width.
8
+ module Widths
9
+ module_function
10
+
11
+ def resolve(spec:, natural:, min:, target:)
12
+ widths = spec.map { |s| fixed(s, target) }
13
+ flexible = widths.each_index.select { |i| widths[i].nil? }
14
+ return widths if flexible.empty?
15
+
16
+ remaining = [target - widths.compact.sum, 0].max
17
+ shares = distribute(flexible.map { |i| natural[i] }, flexible.map { |i| min[i] }, remaining)
18
+ flexible.zip(shares).each { |i, width| widths[i] = width }
19
+ widths
20
+ end
21
+
22
+ def fixed(spec, target)
23
+ case spec
24
+ when nil then nil
25
+ when Float then spec <= 1 ? target * spec : spec
26
+ else spec
27
+ end
28
+ end
29
+
30
+ def distribute(natural, min, remaining)
31
+ total = natural.sum
32
+ return grow(natural, remaining, total) if total <= remaining
33
+ return shrink(natural, min, total - remaining) if min.sum <= remaining
34
+
35
+ min.map { |m| min.sum.zero? ? remaining.fdiv(min.size) : m * remaining.fdiv(min.sum) }
36
+ end
37
+
38
+ def grow(natural, remaining, total)
39
+ extra = remaining - total
40
+ return natural.map { |n| n + extra.fdiv(natural.size) } if total.zero?
41
+
42
+ natural.map { |n| n + (extra * n.fdiv(total)) }
43
+ end
44
+
45
+ def shrink(natural, min, excess)
46
+ slack = natural.zip(min).map { |n, m| n - m }
47
+ total = slack.sum
48
+ natural.zip(slack).map { |n, s| n - (excess * s.fdiv(total)) }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Rows of cells with column widths, per-cell styling through selections,
6
+ # header rows repeated after a page break, and splitting between rows.
7
+ class Table < Node
8
+ DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
9
+ border_color: "#000000" }.freeze
10
+
11
+ def initialize(rows, context:, widths: nil, width: :auto, header: false, cell: {})
12
+ super()
13
+ @context = context
14
+ @widths = widths
15
+ @width = width
16
+ @header = header == true ? 1 : (header || 0).to_i
17
+ defaults = DEFAULT_CELL.merge(cell)
18
+ @cells = rows.map { |row| row.map { |content| content.is_a?(Cell) ? content : Cell.new(content, defaults) } }
19
+ yield self if block_given?
20
+ end
21
+
22
+ def row_count = @cells.size
23
+ def column_count = @cells.map(&:size).max || 0
24
+ def cell(row, column) = @cells.dig(row, column)
25
+
26
+ def rows(spec) = Selection.new(self, Selection.indexes(spec, row_count), (0...column_count).to_a)
27
+ alias row rows
28
+
29
+ def columns(spec) = Selection.new(self, (0...row_count).to_a, Selection.indexes(spec, column_count))
30
+ alias column columns
31
+
32
+ def cells = Selection.new(self, (0...row_count).to_a, (0...column_count).to_a)
33
+
34
+ def zebra(color:, from: 0, to: nil, every: 2)
35
+ (from..(to || (row_count - 1))).step(every) { |r| rows(r).background = color }
36
+ self
37
+ end
38
+
39
+ def splittable? = true
40
+
41
+ def natural_width = column_metric(:natural_width).sum
42
+ def min_width = column_metric(:min_width).sum
43
+
44
+ def fixed_width(available)
45
+ case @width
46
+ when :full then nil
47
+ when Numeric then [@width, available].min
48
+ else [column_widths(available).sum, available].min
49
+ end
50
+ end
51
+
52
+ def column_widths(available)
53
+ @column_widths ||= {}
54
+ @column_widths[available] ||= Widths.resolve(
55
+ spec: Array.new(column_count) { |i| @widths&.[](i) },
56
+ natural: column_metric(:natural_width), min: column_metric(:min_width), target: target(available)
57
+ )
58
+ end
59
+
60
+ def measure(width) = row_heights(width).sum
61
+
62
+ def paint(canvas, x, y, width, _height = nil, **)
63
+ widths = column_widths(width)
64
+ top = y
65
+ @cells.zip(row_heights(width)).each_with_index do |(row, height), row_index|
66
+ left = x
67
+ row.each_with_index do |cell, index|
68
+ cell.paint(canvas, @context, Rect.new(left, top, widths[index], height),
69
+ last_column: index == row.size - 1, last_row: row_index == @cells.size - 1)
70
+ left += widths[index]
71
+ end
72
+ top += height
73
+ end
74
+ end
75
+
76
+ def split(width, height)
77
+ heights = row_heights(width)
78
+ used = heights.first(@header).sum
79
+ count = @header
80
+ count += 1 while count < row_count && used + heights[count] <= height + EPSILON && (used += heights[count])
81
+ return [self, nil] if count == row_count
82
+ return [nil, self] if count == @header
83
+
84
+ [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count))]
85
+ end
86
+
87
+ private
88
+
89
+ def target(available)
90
+ case @width
91
+ when :full then available
92
+ when Numeric then [@width, available].min
93
+ else [natural_total, available].min
94
+ end
95
+ end
96
+
97
+ def natural_total
98
+ natural = column_metric(:natural_width)
99
+ Array.new(column_count) { |i| @widths&.[](i) || natural[i] }.sum
100
+ end
101
+
102
+ def column_metric(metric)
103
+ Array.new(column_count) do |c|
104
+ @cells.filter_map { |row| row[c]&.public_send(metric, @context) }.max || 0
105
+ end
106
+ end
107
+
108
+ def row_heights(width)
109
+ widths = column_widths(width)
110
+ @cells.map { |row| row.each_with_index.map { |cell, i| cell.measure(@context, widths[i]) }.max || 0 }
111
+ end
112
+
113
+ def with_rows(rows)
114
+ self.class.new(rows, context: @context, widths: @widths, width: @width, header: @header)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # A paragraph of styled runs. Splits between lines.
6
+ class Text < Node
7
+ attr_reader :runs
8
+
9
+ def initialize(runs, context:, align: :left, leading: 0, paragraph: nil)
10
+ super()
11
+ @runs = runs
12
+ @context = context
13
+ @align = align
14
+ @leading = leading
15
+ @paragraph = paragraph
16
+ @paragraphs = {}
17
+ end
18
+
19
+ def splittable? = true
20
+
21
+ def measure(width) = paragraph(width).height
22
+
23
+ def paint(canvas, x, y, width, _height = nil, **)
24
+ paragraph(width).draw(canvas, x, y)
25
+ end
26
+
27
+ def split(width, height)
28
+ head, tail = paragraph(width).split(height)
29
+ [head && from(head), tail && from(tail)]
30
+ end
31
+
32
+ def fit(width, height, overflow:)
33
+ from(paragraph(width).fit(height, overflow:))
34
+ end
35
+
36
+ def natural_width
37
+ @natural_width ||= paragraph(Float::INFINITY).lines.map(&:width).max || 0
38
+ end
39
+
40
+ def min_width
41
+ @min_width ||= @runs.flat_map do |run|
42
+ font = @context.book.resolve(run.style).first
43
+ run.text.split(/[ \t\n]+/).map do |word|
44
+ font.width_of(word, run.style.render_size, letter_spacing: run.style.letter_spacing)
45
+ end
46
+ end.max || 0
47
+ end
48
+
49
+ private
50
+
51
+ def paragraph(width)
52
+ @paragraph || (@paragraphs[width] ||= ::Stationery::Text::Paragraph.new(
53
+ @runs, book: @context.book, width:, align: @align, leading: @leading, fallback_style: @context.style
54
+ ))
55
+ end
56
+
57
+ def from(paragraph)
58
+ self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # One page: its size and margins, the drawing operators written to it, the
5
+ # resources those operators reference and its link annotations.
6
+ class Page
7
+ SIZES = {
8
+ a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28],
9
+ letter: [612, 792], legal: [612, 1008], tabloid: [792, 1224]
10
+ }.freeze
11
+
12
+ attr_reader :size, :margin, :content, :annotations, :resource_names
13
+
14
+ def initialize(size: :letter, layout: :portrait, margin: 0)
15
+ @size = dimensions(size)
16
+ @size = @size.reverse if layout.to_sym == :landscape
17
+ @margin = Geometry.box(margin)
18
+ @content = String.new(encoding: Encoding::BINARY)
19
+ @annotations = []
20
+ @resource_names = Hash.new { |hash, key| hash[key] = [] }
21
+ end
22
+
23
+ def width = @size[0]
24
+ def height = @size[1]
25
+
26
+ def content_box
27
+ top, right, bottom, left = @margin
28
+ Rect.new(left, top, width - left - right, height - top - bottom)
29
+ end
30
+
31
+ def use(category, name)
32
+ names = @resource_names[category]
33
+ names << name unless names.include?(name)
34
+ name
35
+ end
36
+
37
+ private
38
+
39
+ def dimensions(size)
40
+ return size.map { |v| v } if size.is_a?(Array) && size.size == 2
41
+
42
+ SIZES.fetch(size.to_s.downcase.to_sym) do
43
+ raise ArgumentError, "unknown page size #{size.inspect} (use #{SIZES.keys.join(", ")} or [width, height])"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # What a page template knows about the page it draws on.
5
+ PageInfo = Data.define(:number, :count, :width, :height, :margin, :content_box)
6
+
7
+ # Runs a document's page templates on every finished page.
8
+ class PageTemplates
9
+ def initialize(document, book:, resources:)
10
+ @document = document
11
+ @book = book
12
+ @resources = resources
13
+ @templates = document.class.config[:templates]
14
+ end
15
+
16
+ def apply(pages)
17
+ return if @templates.empty?
18
+
19
+ pages.each_with_index do |page, index|
20
+ info = PageInfo.new(index + 1, pages.size, page.width, page.height, page.margin, page.content_box)
21
+ @templates.each { |layer, block| draw(page, info, layer, block) }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def draw(page, info, layer, block)
28
+ builder = Builder.new(book: @book, text: @document.class.config[:text])
29
+ @document.build_with(builder) { @document.instance_exec(info, &block) }
30
+ mark = page.content.bytesize
31
+ box = page.content_box
32
+ builder.root.paint(Canvas.new(page, @resources), box.x, box.y, box.width)
33
+ page.content.prepend(page.content.slice!(mark..)) if layer == :background
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # Writes finished pages, their shared resources and document info as PDF.
6
+ class Assembler
7
+ def initialize(pages:, resources:, info: {})
8
+ @pages = pages
9
+ @resources = resources
10
+ @info = info
11
+ end
12
+
13
+ def render
14
+ writer = Writer.new
15
+ tree = writer.reserve
16
+ refs = @resources.build(writer)
17
+ kids = @pages.map { |page| write_page(writer, page, tree, refs) }
18
+ writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
19
+ root = writer.add({ Type: :Catalog, Pages: tree })
20
+ writer.render(root:, info: writer.add(info_dictionary))
21
+ end
22
+
23
+ private
24
+
25
+ def write_page(writer, page, tree, refs)
26
+ dictionary = {
27
+ Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
28
+ Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
29
+ }
30
+ dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
31
+ writer.add(dictionary)
32
+ end
33
+
34
+ def page_resources(page, refs)
35
+ page.resource_names.to_h do |category, names|
36
+ [category, names.to_h { |name| [name, refs.fetch(category).fetch(name)] }]
37
+ end
38
+ end
39
+
40
+ def annotation(link)
41
+ { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0],
42
+ A: { Type: :Action, S: :URI, URI: link[:url].b } }
43
+ end
44
+
45
+ def info_dictionary
46
+ info = { Producer: "Stationery #{VERSION}" }.merge(@info.compact)
47
+ info = info.transform_values { |value| TextString.new(value.to_s) }
48
+ info[:CreationDate] = TextString.new(Time.now.utc.strftime("D:%Y%m%d%H%M%SZ"))
49
+ info
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # Turns Ruby values into PDF object syntax.
6
+ #
7
+ # A plain String is always written as a literal byte string `( … )` — never
8
+ # re-encoded — so a URI stays the 7-bit ASCII the spec requires. Use
9
+ # TextString for human-readable text that may need UTF-16.
10
+ module Serializer
11
+ NAME_ESCAPE = %r{[^\x21-\x7E]|[#%()/<>\[\]{}]}n
12
+ LITERAL_ESCAPE = /[\\()\r]/n
13
+ UTF16_BOM = "\xFE\xFF".b
14
+
15
+ module_function
16
+
17
+ def dump(value)
18
+ case value
19
+ when Reference then "#{value.id} 0 R"
20
+ when Symbol then name(value)
21
+ when Hash then "<<#{value.map { |k, v| "#{name(k)} #{dump(v)}" }.join(" ")}>>"
22
+ when Array then "[#{value.map { |v| dump(v) }.join(" ")}]"
23
+ when HexString then "<#{value.bytes.unpack1("H*").upcase}>"
24
+ when TextString then text(value.value)
25
+ when String then literal(value)
26
+ when Integer, true, false then value.to_s
27
+ when Float then number(value)
28
+ when nil then "null"
29
+ else raise ArgumentError, "cannot serialize #{value.class} to PDF"
30
+ end
31
+ end
32
+
33
+ def name(value)
34
+ "/#{value.to_s.b.gsub(NAME_ESCAPE) { |c| format("#%02X", c.ord) }}"
35
+ end
36
+
37
+ def literal(value)
38
+ "(#{value.b.gsub(LITERAL_ESCAPE) { |c| c == "\r" ? "\\r" : "\\#{c}" }})"
39
+ end
40
+
41
+ def number(value)
42
+ return value.to_s if value.is_a?(Integer)
43
+
44
+ str = format("%.4f", value).sub(/\.?0+\z/, "")
45
+ str == "-0" ? "0" : str
46
+ end
47
+
48
+ def text(value)
49
+ value = value.to_s
50
+ return literal(value) if value.ascii_only?
51
+
52
+ dump(HexString.new(UTF16_BOM + value.encode(Encoding::UTF_16BE).b))
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Stationery
6
+ module PDF
7
+ # A stream object. Data is Flate-compressed unless the dictionary already
8
+ # names a filter (JPEG's DCTDecode, or PNG data that is already zlib).
9
+ class Stream
10
+ attr_reader :dictionary, :data
11
+
12
+ def initialize(data, dictionary = {})
13
+ data = data.b
14
+ dictionary = dictionary.dup
15
+
16
+ unless dictionary.key?(:Filter)
17
+ data = Zlib::Deflate.deflate(data)
18
+ dictionary[:Filter] = :FlateDecode
19
+ end
20
+
21
+ dictionary[:Length] = data.bytesize
22
+ @data = data
23
+ @dictionary = dictionary
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # An indirect object reference: `12 0 R`.
6
+ Reference = Data.define(:id)
7
+
8
+ # Bytes written in hex form: `<48656C6C6F>`.
9
+ HexString = Data.define(:bytes)
10
+
11
+ # A human-readable string (document info, outline titles): written as a
12
+ # literal when ASCII, as UTF-16BE with a byte order mark otherwise.
13
+ TextString = Data.define(:value)
14
+ end
15
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module PDF
7
+ # Collects numbered objects and writes them as a PDF file with a classic
8
+ # cross-reference table.
9
+ class Writer
10
+ HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
11
+
12
+ def initialize
13
+ @objects = []
14
+ end
15
+
16
+ # Hands out a reference now and lets the object be set later, so a page
17
+ # can point at its parent before the page tree exists.
18
+ def reserve
19
+ @objects << nil
20
+ Reference.new(@objects.size)
21
+ end
22
+
23
+ def set(ref, value)
24
+ @objects[ref.id - 1] = value
25
+ ref
26
+ end
27
+
28
+ def add(value)
29
+ set(reserve, value)
30
+ end
31
+
32
+ def render(root:, info:)
33
+ out = HEADER.dup
34
+ offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
35
+ xref = out.bytesize
36
+ id = HexString.new(Digest::MD5.digest(out))
37
+
38
+ out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
39
+ offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
40
+ out << "trailer\n" << Serializer.dump({ Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] })
41
+ out << "\nstartxref\n#{xref}\n%%EOF\n"
42
+ end
43
+
44
+ private
45
+
46
+ def write_object(out, number, object)
47
+ offset = out.bytesize
48
+ out << "#{number} 0 obj\n"
49
+ if object.is_a?(Stream)
50
+ out << Serializer.dump(object.dictionary) << "\nstream\n" << object.data << "\nendstream"
51
+ else
52
+ out << Serializer.dump(object)
53
+ end
54
+ out << "\nendobj\n"
55
+ offset
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stationery"
4
+
5
+ module Stationery
6
+ # Controller helper:
7
+ #
8
+ # def show
9
+ # send_pdf InvoicePdf.new(@invoice), filename: "invoice.pdf"
10
+ # end
11
+ module Rails
12
+ def send_pdf(document, disposition: "inline", type: "application/pdf", **)
13
+ send_data(document.to_pdf, type:, disposition:, **)
14
+ end
15
+ end
16
+ end
17
+
18
+ if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
19
+ ActiveSupport.on_load(:action_controller) { include Stationery::Rails }
20
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Document-wide names for fonts, images and graphics states, so each is
5
+ # embedded once however many pages use it.
6
+ class Resources
7
+ def initialize
8
+ @fonts = {}
9
+ @images = {}
10
+ @states = {}
11
+ end
12
+
13
+ def font(font)
14
+ @fonts[font] ||= :"F#{@fonts.size + 1}"
15
+ end
16
+
17
+ def image(image)
18
+ @images[image] ||= :"Im#{@images.size + 1}"
19
+ end
20
+
21
+ def opacity(value)
22
+ @states[value.to_f.round(3)] ||= :"GS#{@states.size + 1}"
23
+ end
24
+
25
+ # Writes every resource once; returns { category => { name => ref } }.
26
+ def build(writer)
27
+ {
28
+ Font: @fonts.to_h { |font, name| [name, font.build(writer)] },
29
+ XObject: @images.to_h { |image, name| [name, image.build(writer)] },
30
+ ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] }
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # Decodes the HTML entities markup text may carry.
6
+ module Entities
7
+ NAMED = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => "\"", "apos" => "'", "nbsp" => " " }.freeze
8
+ PATTERN = /&(#x\h+|#\d+|[a-zA-Z]+);/
9
+
10
+ module_function
11
+
12
+ def decode(text)
13
+ text.gsub(PATTERN) do |entity|
14
+ name = Regexp.last_match(1)
15
+ if name.start_with?("#x") then [name[2..].to_i(16)].pack("U")
16
+ elsif name.start_with?("#") then [name[1..].to_i].pack("U")
17
+ else NAMED.fetch(name, entity)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # A measured piece of one line in one style, positioned at `x` from the
6
+ # line's start.
7
+ Fragment = Data.define(:text, :style, :font, :face, :width, :x)
8
+
9
+ # One laid-out line. Vertical metrics come from its tallest fragment (or
10
+ # from the paragraph's base style when the line is empty).
11
+ class Line
12
+ attr_reader :fragments, :width, :ascent, :descent, :height
13
+
14
+ def initialize(fragments, fallback)
15
+ @fragments = fragments
16
+ @width = fragments.sum(&:width)
17
+ metrics = fragments.empty? ? [fallback] : fragments.map { |f| [f.font, f.style.render_size] }
18
+ @ascent = metrics.map { |font, size| font.ascender(size) }.max
19
+ @descent = metrics.map { |font, size| font.descender(size) }.max
20
+ @height = metrics.map { |font, size| font.line_height(size) }.max
21
+ end
22
+
23
+ def text = fragments.map(&:text).join
24
+ end
25
+ end
26
+ end