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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +28 -0
- data/README.md +145 -0
- data/lib/stationery/builder.rb +61 -0
- data/lib/stationery/canvas/path.rb +82 -0
- data/lib/stationery/canvas/text.rb +55 -0
- data/lib/stationery/canvas.rb +121 -0
- data/lib/stationery/color.rb +62 -0
- data/lib/stationery/component.rb +54 -0
- data/lib/stationery/document.rb +95 -0
- data/lib/stationery/elements.rb +90 -0
- data/lib/stationery/errors.rb +11 -0
- data/lib/stationery/fonts/cmap.rb +75 -0
- data/lib/stationery/fonts/family.rb +51 -0
- data/lib/stationery/fonts/font.rb +149 -0
- data/lib/stationery/fonts/font_book.rb +36 -0
- data/lib/stationery/fonts/name_table.rb +39 -0
- data/lib/stationery/fonts/registry.rb +38 -0
- data/lib/stationery/fonts/subset.rb +139 -0
- data/lib/stationery/fonts/true_type.rb +174 -0
- data/lib/stationery/geometry.rb +56 -0
- data/lib/stationery/images/cache.rb +32 -0
- data/lib/stationery/images/images.rb +45 -0
- data/lib/stationery/images/jpeg.rb +53 -0
- data/lib/stationery/images/png.rb +119 -0
- data/lib/stationery/images/scanlines.rb +56 -0
- data/lib/stationery/layout/box.rb +134 -0
- data/lib/stationery/layout/flow.rb +121 -0
- data/lib/stationery/layout/image.rb +53 -0
- data/lib/stationery/layout/leaves.rb +58 -0
- data/lib/stationery/layout/node.rb +46 -0
- data/lib/stationery/layout/paginator.rb +41 -0
- data/lib/stationery/layout/positioned.rb +24 -0
- data/lib/stationery/layout/row.rb +71 -0
- data/lib/stationery/layout/table/cell.rb +93 -0
- data/lib/stationery/layout/table/selection.rb +52 -0
- data/lib/stationery/layout/table/widths.rb +53 -0
- data/lib/stationery/layout/table.rb +118 -0
- data/lib/stationery/layout/text.rb +62 -0
- data/lib/stationery/page.rb +47 -0
- data/lib/stationery/page_templates.rb +36 -0
- data/lib/stationery/pdf/assembler.rb +53 -0
- data/lib/stationery/pdf/serializer.rb +56 -0
- data/lib/stationery/pdf/stream.rb +27 -0
- data/lib/stationery/pdf/types.rb +15 -0
- data/lib/stationery/pdf/writer.rb +59 -0
- data/lib/stationery/rails.rb +20 -0
- data/lib/stationery/resources.rb +34 -0
- data/lib/stationery/text/entities.rb +23 -0
- data/lib/stationery/text/line.rb +26 -0
- data/lib/stationery/text/markup.rb +103 -0
- data/lib/stationery/text/paragraph.rb +98 -0
- data/lib/stationery/text/runs_builder.rb +52 -0
- data/lib/stationery/text/style.rb +46 -0
- data/lib/stationery/text/wrapper.rb +131 -0
- data/lib/stationery/version.rb +5 -0
- data/lib/stationery.rb +58 -0
- 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
|