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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Text
|
|
7
|
+
# Parses Prawn-style inline markup into runs:
|
|
8
|
+
#
|
|
9
|
+
# <b> <strong> <i> <em> <u> <strikethrough> <s> <del> <sub> <sup> <br>
|
|
10
|
+
# <color rgb="#hex"> <color c="" m="" y="" k="">
|
|
11
|
+
# <font size="n" name="Family"> <link href="…"> <a href="…">
|
|
12
|
+
#
|
|
13
|
+
# Unknown tags are dropped and their content kept. A "<" that does not open
|
|
14
|
+
# a well-formed tag is text, so "1 < 2" survives.
|
|
15
|
+
class Markup
|
|
16
|
+
TAG = %r{<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[a-zA-Z_:-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"']+))*)\s*/?>}
|
|
17
|
+
ATTRIBUTE = /([a-zA-Z_:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/
|
|
18
|
+
STYLES = {
|
|
19
|
+
"b" => { weight: :bold }, "strong" => { weight: :bold },
|
|
20
|
+
"i" => { style: :italic }, "em" => { style: :italic },
|
|
21
|
+
"u" => { underline: true },
|
|
22
|
+
"strikethrough" => { strikethrough: true }, "s" => { strikethrough: true }, "del" => { strikethrough: true },
|
|
23
|
+
"sub" => { script: :sub }, "sup" => { script: :sup }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def self.parse(source, style)
|
|
27
|
+
new(source, style).runs
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(source, style)
|
|
31
|
+
@scanner = StringScanner.new(source.to_s)
|
|
32
|
+
@stack = [[nil, style]]
|
|
33
|
+
@runs = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def runs
|
|
37
|
+
until @scanner.eos?
|
|
38
|
+
if (text = @scanner.scan(/[^<]+/))
|
|
39
|
+
emit(Entities.decode(text))
|
|
40
|
+
elsif @scanner.check(TAG)
|
|
41
|
+
tag(@scanner.scan(TAG))
|
|
42
|
+
else
|
|
43
|
+
emit(@scanner.getch)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
Run.merge(@runs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def emit(text)
|
|
52
|
+
@runs << Run.new(text, @stack.last.last)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tag(source)
|
|
56
|
+
closing, name, attributes = source.match(TAG).captures
|
|
57
|
+
name = name.downcase
|
|
58
|
+
return emit("\n") if name == "br"
|
|
59
|
+
return close(name) if closing == "/"
|
|
60
|
+
|
|
61
|
+
overrides = overrides_for(name, attributes(attributes))
|
|
62
|
+
@stack << [name, @stack.last.last.with(**overrides)] if overrides
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close(name)
|
|
66
|
+
index = @stack.rindex { |tag_name, _| tag_name == name }
|
|
67
|
+
@stack.slice!(index..) if index&.positive?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def overrides_for(name, attributes)
|
|
71
|
+
return STYLES[name] if STYLES.key?(name)
|
|
72
|
+
|
|
73
|
+
case name
|
|
74
|
+
when "color" then color(attributes)
|
|
75
|
+
when "font" then font(attributes)
|
|
76
|
+
when "link", "a" then attributes["href"] ? { link: attributes["href"] } : {}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def color(attributes)
|
|
81
|
+
return { color: attributes["rgb"] } if attributes["rgb"]
|
|
82
|
+
return {} unless %w[c m y k].all? { |key| attributes.key?(key) }
|
|
83
|
+
|
|
84
|
+
{ color: %w[c m y k].map { |key| number(attributes[key]) } }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def font(attributes)
|
|
88
|
+
overrides = {}
|
|
89
|
+
overrides[:size] = number(attributes["size"]) if attributes["size"]
|
|
90
|
+
overrides[:family] = attributes["name"] if attributes["name"]
|
|
91
|
+
overrides
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def attributes(source)
|
|
95
|
+
source.scan(ATTRIBUTE).to_h { |key, *values| [key.downcase, Entities.decode(values.compact.first)] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def number(value)
|
|
99
|
+
value.include?(".") ? value.to_f : value.to_i
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
# Wrapped lines of runs at a width, ready to measure, split across pages
|
|
6
|
+
# and draw.
|
|
7
|
+
class Paragraph
|
|
8
|
+
MIN_SHRINK_SIZE = 4
|
|
9
|
+
|
|
10
|
+
attr_reader :runs, :lines, :width, :align, :leading
|
|
11
|
+
|
|
12
|
+
def initialize(runs, book:, width:, align: :left, leading: 0, lines: nil, fallback_style: nil)
|
|
13
|
+
@runs = runs
|
|
14
|
+
@book = book
|
|
15
|
+
@width = width
|
|
16
|
+
@align = align
|
|
17
|
+
@leading = leading
|
|
18
|
+
@fallback_style = fallback_style || runs.first&.style
|
|
19
|
+
@lines = lines || Wrapper.new(book).wrap(runs, width, fallback_style: @fallback_style)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def height
|
|
23
|
+
return 0 if @lines.empty?
|
|
24
|
+
|
|
25
|
+
@lines.sum(&:height) + (@leading * (@lines.size - 1))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# [fits, rest] by whole lines; nil for an empty side.
|
|
29
|
+
def split(max_height)
|
|
30
|
+
count = fitting_lines(max_height)
|
|
31
|
+
return [self, nil] if count == @lines.size
|
|
32
|
+
return [nil, self] if count.zero?
|
|
33
|
+
|
|
34
|
+
[with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A paragraph that fits `max_height`: truncated to whole lines, shrunk
|
|
38
|
+
# (never below 4pt) or left as is for :visible.
|
|
39
|
+
def fit(max_height, overflow:)
|
|
40
|
+
return self if overflow == :visible || height <= max_height
|
|
41
|
+
return split(max_height).first || with_lines([]) if overflow == :truncate
|
|
42
|
+
|
|
43
|
+
shrink(max_height)
|
|
44
|
+
end
|
|
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
|
+
line.fragments.each { |fragment| draw_fragment(canvas, fragment, offset, top, line) }
|
|
51
|
+
top += line.height + @leading
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fitting_lines(max_height)
|
|
58
|
+
used = 0
|
|
59
|
+
@lines.take_while.with_index do |line, index|
|
|
60
|
+
used += line.height + (index.zero? ? 0 : @leading)
|
|
61
|
+
used <= max_height + 0.0001
|
|
62
|
+
end.size
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def with_lines(lines)
|
|
66
|
+
self.class.new(@runs, book: @book, width: @width, align: @align, leading: @leading, lines:,
|
|
67
|
+
fallback_style: @fallback_style)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def shrink(max_height)
|
|
71
|
+
factor = 1.0
|
|
72
|
+
loop do
|
|
73
|
+
factor -= 0.05
|
|
74
|
+
scaled = scaled_runs(factor)
|
|
75
|
+
candidate = self.class.new(scaled, book: @book, width: @width, align: @align, leading: @leading * factor,
|
|
76
|
+
fallback_style: scaled.first&.style || @fallback_style)
|
|
77
|
+
return candidate if candidate.height <= max_height || scaled.all? { |run| run.style.size <= MIN_SHRINK_SIZE }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def scaled_runs(factor)
|
|
82
|
+
@runs.map { |run| Run.new(run.text, run.style.with(size: [run.style.size * factor, MIN_SHRINK_SIZE].max)) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def draw_fragment(canvas, fragment, offset, top, line)
|
|
86
|
+
style = fragment.style
|
|
87
|
+
x = offset + fragment.x
|
|
88
|
+
canvas.text(fragment.text, x:, y: top + line.ascent, font: fragment.font, size: style.render_size,
|
|
89
|
+
color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
|
|
90
|
+
opacity: style.opacity, underline: style.underline,
|
|
91
|
+
strikethrough: style.strikethrough,
|
|
92
|
+
synthetic_bold: fragment.face.synthetic_bold,
|
|
93
|
+
synthetic_oblique: fragment.face.synthetic_oblique)
|
|
94
|
+
canvas.link(x, top, fragment.width, line.height, style.link) if style.link
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
# Builds runs with Ruby instead of markup:
|
|
6
|
+
#
|
|
7
|
+
# text { plain "Total "; b "due"; link("https://…") { i "pay" } }
|
|
8
|
+
class RunsBuilder
|
|
9
|
+
def self.build(style, &)
|
|
10
|
+
new(style).tap { |builder| builder.capture(&) }.runs
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(style)
|
|
14
|
+
@styles = [style]
|
|
15
|
+
@runs = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def runs = Run.merge(@runs)
|
|
19
|
+
|
|
20
|
+
# A block taking an argument is called with the builder (so it keeps its
|
|
21
|
+
# own self and instance variables); one without is evaluated on it.
|
|
22
|
+
def capture(&block)
|
|
23
|
+
before = @runs.size
|
|
24
|
+
result = block.arity == 1 ? yield(self) : instance_exec(&block)
|
|
25
|
+
plain(result) if result.is_a?(String) && @runs.size == before
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def plain(text) = @runs << Run.new(text.to_s, @styles.last)
|
|
29
|
+
def br = plain("\n")
|
|
30
|
+
|
|
31
|
+
def b(text = nil, &) = styled({ weight: :bold }, text, &)
|
|
32
|
+
def i(text = nil, &) = styled({ style: :italic }, text, &)
|
|
33
|
+
def u(text = nil, &) = styled({ underline: true }, text, &)
|
|
34
|
+
def strikethrough(text = nil, &) = styled({ strikethrough: true }, text, &)
|
|
35
|
+
def sub(text = nil, &) = styled({ script: :sub }, text, &)
|
|
36
|
+
def sup(text = nil, &) = styled({ script: :sup }, text, &)
|
|
37
|
+
def color(value, text = nil, &) = styled({ color: value }, text, &)
|
|
38
|
+
def size(value, text = nil, &) = styled({ size: value }, text, &)
|
|
39
|
+
def font(family, text = nil, &) = styled({ family: family.to_s }, text, &)
|
|
40
|
+
def link(url, text = nil, &) = styled({ link: url.to_s }, text, &)
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def styled(overrides, text, &)
|
|
45
|
+
@styles << @styles.last.with(**overrides)
|
|
46
|
+
text ? plain(text) : capture(&)
|
|
47
|
+
ensure
|
|
48
|
+
@styles.pop
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
SCRIPT_SCALE = 0.583
|
|
6
|
+
|
|
7
|
+
# How a run of text looks. Immutable; derive variants with #with.
|
|
8
|
+
Style = Data.define(:family, :size, :weight, :style, :color, :letter_spacing,
|
|
9
|
+
:underline, :strikethrough, :script, :link, :opacity) do
|
|
10
|
+
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)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The size glyphs are drawn at: smaller for sub- and superscript.
|
|
16
|
+
def render_size
|
|
17
|
+
script ? size * SCRIPT_SCALE : size
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Baseline shift for sub- and superscript.
|
|
21
|
+
def rise
|
|
22
|
+
case script
|
|
23
|
+
when :sup then size * 0.33
|
|
24
|
+
when :sub then -size * 0.15
|
|
25
|
+
else 0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A piece of text in one style.
|
|
31
|
+
Run = Data.define(:text, :style) do
|
|
32
|
+
# Joins neighbouring runs that share a style.
|
|
33
|
+
def self.merge(runs)
|
|
34
|
+
runs.each_with_object([]) do |run, merged|
|
|
35
|
+
next if run.text.empty?
|
|
36
|
+
|
|
37
|
+
if merged.last&.style == run.style
|
|
38
|
+
merged[-1] = Run.new(merged.last.text + run.text, run.style)
|
|
39
|
+
else
|
|
40
|
+
merged << run
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
# Greedy line breaking over styled runs. Breaks at spaces (the space is
|
|
6
|
+
# dropped at the break) and after hyphens; a word wider than the line is
|
|
7
|
+
# broken between characters. A word that changes style midway ("<b>Tot</b>al")
|
|
8
|
+
# is still one word.
|
|
9
|
+
class Wrapper
|
|
10
|
+
TOKEN = /\n|[ \t]+|[^ \t\n-]*-+|[^ \t\n-]+/
|
|
11
|
+
|
|
12
|
+
Segment = Data.define(:text, :style)
|
|
13
|
+
|
|
14
|
+
def initialize(book)
|
|
15
|
+
@book = book
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def wrap(runs, max_width, fallback_style: runs.first&.style)
|
|
19
|
+
@max = max_width
|
|
20
|
+
@lines = []
|
|
21
|
+
@current = []
|
|
22
|
+
@pending_space = []
|
|
23
|
+
@fallback = fallback_style
|
|
24
|
+
items(runs).each { |item| place(item) }
|
|
25
|
+
finish unless @current.empty? && @lines.any? && !@ended_with_newline
|
|
26
|
+
@lines
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# [:word, segments] | [:space, segments] | [:newline]
|
|
32
|
+
def items(runs)
|
|
33
|
+
items = []
|
|
34
|
+
runs.each do |run|
|
|
35
|
+
run.text.scan(TOKEN) do |token|
|
|
36
|
+
if token == "\n" then items << [:newline]
|
|
37
|
+
elsif token.match?(/\A[ \t]/) then items << [:space, [Segment.new(token, run.style)]]
|
|
38
|
+
elsif items.last&.first == :word && !items.last.last.last.text.end_with?("-")
|
|
39
|
+
items.last.last << Segment.new(token, run.style)
|
|
40
|
+
else items << [:word, [Segment.new(token, run.style)]]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
items
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def place((kind, segments))
|
|
48
|
+
@ended_with_newline = kind == :newline
|
|
49
|
+
case kind
|
|
50
|
+
when :newline then finish
|
|
51
|
+
when :space then @pending_space += segments
|
|
52
|
+
else place_word(segments)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def place_word(word)
|
|
57
|
+
needed = width(@pending_space) + width(word)
|
|
58
|
+
if @current.empty? || line_width + needed <= @max + 0.0001
|
|
59
|
+
@current.concat(@pending_space, word)
|
|
60
|
+
else
|
|
61
|
+
finish(wrapped: true)
|
|
62
|
+
@current.concat(word)
|
|
63
|
+
end
|
|
64
|
+
@pending_space = []
|
|
65
|
+
break_long_word while line_width > @max + 0.0001 && characters(@current) > 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Moves characters that overflow the line onto following lines.
|
|
69
|
+
def break_long_word
|
|
70
|
+
fitting = []
|
|
71
|
+
@current.each do |segment|
|
|
72
|
+
segment.text.each_char do |char|
|
|
73
|
+
piece = Segment.new(char, segment.style)
|
|
74
|
+
return keep_overflow(fitting, piece) if fitting.any? && width(fitting + [piece]) > @max + 0.0001
|
|
75
|
+
|
|
76
|
+
fitting << piece
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def keep_overflow(fitting, piece)
|
|
82
|
+
rest = remaining_after(fitting.size)
|
|
83
|
+
@current = fitting
|
|
84
|
+
finish(wrapped: true)
|
|
85
|
+
@current = [piece, *rest.drop(1)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def remaining_after(count)
|
|
89
|
+
@current.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }.drop(count)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def finish(wrapped: false)
|
|
93
|
+
segments = wrapped ? @current : @current + @pending_space
|
|
94
|
+
@lines << Line.new(fragments(trim_trailing(segments)), fallback_metrics)
|
|
95
|
+
@current = []
|
|
96
|
+
@pending_space = []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def trim_trailing(segments)
|
|
100
|
+
segments = segments.dup
|
|
101
|
+
segments.pop while segments.last && segments.last.text.strip.empty?
|
|
102
|
+
segments
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fragments(segments)
|
|
106
|
+
x = 0
|
|
107
|
+
segments.chunk_while { |a, b| a.style == b.style }.map do |group|
|
|
108
|
+
text = group.map(&:text).join
|
|
109
|
+
font, face = @book.resolve(group.first.style)
|
|
110
|
+
fragment_width = measure(text, group.first.style)
|
|
111
|
+
Fragment.new(text, group.first.style, font, face, fragment_width, x).tap { x += fragment_width }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def fallback_metrics
|
|
116
|
+
[@book.resolve(@fallback).first, @fallback.render_size]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def line_width = width(@current)
|
|
120
|
+
def characters(segments) = segments.sum { |segment| segment.text.length }
|
|
121
|
+
|
|
122
|
+
def width(segments)
|
|
123
|
+
segments.sum { |segment| measure(segment.text, segment.style) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def measure(text, style)
|
|
127
|
+
@book.resolve(style).first.width_of(text, style.render_size, letter_spacing: style.letter_spacing)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/stationery.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stationery/version"
|
|
4
|
+
require_relative "stationery/errors"
|
|
5
|
+
require_relative "stationery/pdf/types"
|
|
6
|
+
require_relative "stationery/pdf/serializer"
|
|
7
|
+
require_relative "stationery/pdf/stream"
|
|
8
|
+
require_relative "stationery/pdf/writer"
|
|
9
|
+
require_relative "stationery/fonts/true_type"
|
|
10
|
+
require_relative "stationery/fonts/cmap"
|
|
11
|
+
require_relative "stationery/fonts/name_table"
|
|
12
|
+
require_relative "stationery/fonts/subset"
|
|
13
|
+
require_relative "stationery/fonts/registry"
|
|
14
|
+
require_relative "stationery/fonts/font"
|
|
15
|
+
require_relative "stationery/fonts/family"
|
|
16
|
+
require_relative "stationery/images/images"
|
|
17
|
+
require_relative "stationery/images/cache"
|
|
18
|
+
require_relative "stationery/images/scanlines"
|
|
19
|
+
require_relative "stationery/images/jpeg"
|
|
20
|
+
require_relative "stationery/images/png"
|
|
21
|
+
require_relative "stationery/color"
|
|
22
|
+
require_relative "stationery/geometry"
|
|
23
|
+
require_relative "stationery/page"
|
|
24
|
+
require_relative "stationery/resources"
|
|
25
|
+
require_relative "stationery/canvas/path"
|
|
26
|
+
require_relative "stationery/canvas/text"
|
|
27
|
+
require_relative "stationery/canvas"
|
|
28
|
+
require_relative "stationery/pdf/assembler"
|
|
29
|
+
require_relative "stationery/text/style"
|
|
30
|
+
require_relative "stationery/fonts/font_book"
|
|
31
|
+
require_relative "stationery/text/entities"
|
|
32
|
+
require_relative "stationery/text/markup"
|
|
33
|
+
require_relative "stationery/text/runs_builder"
|
|
34
|
+
require_relative "stationery/text/line"
|
|
35
|
+
require_relative "stationery/text/wrapper"
|
|
36
|
+
require_relative "stationery/text/paragraph"
|
|
37
|
+
require_relative "stationery/layout/node"
|
|
38
|
+
require_relative "stationery/layout/flow"
|
|
39
|
+
require_relative "stationery/layout/box"
|
|
40
|
+
require_relative "stationery/layout/row"
|
|
41
|
+
require_relative "stationery/layout/text"
|
|
42
|
+
require_relative "stationery/layout/image"
|
|
43
|
+
require_relative "stationery/layout/leaves"
|
|
44
|
+
require_relative "stationery/layout/table"
|
|
45
|
+
require_relative "stationery/layout/table/widths"
|
|
46
|
+
require_relative "stationery/layout/table/cell"
|
|
47
|
+
require_relative "stationery/layout/table/selection"
|
|
48
|
+
require_relative "stationery/layout/paginator"
|
|
49
|
+
require_relative "stationery/layout/positioned"
|
|
50
|
+
require_relative "stationery/builder"
|
|
51
|
+
require_relative "stationery/elements"
|
|
52
|
+
require_relative "stationery/component"
|
|
53
|
+
require_relative "stationery/page_templates"
|
|
54
|
+
require_relative "stationery/document"
|
|
55
|
+
|
|
56
|
+
# Pure-Ruby PDF documents built from Phlex-style components.
|
|
57
|
+
module Stationery
|
|
58
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: stationery
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mikael Henriksson
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Stationery renders PDF documents from Phlex-style Ruby components. Describe
|
|
14
|
+
the page with rows, columns, boxes, tables, text and images; a box-layout
|
|
15
|
+
engine measures, places and paginates them, and a small PDF writer embeds
|
|
16
|
+
subsetted TrueType fonts and JPEG/PNG images. It has no runtime dependencies,
|
|
17
|
+
no native extensions and never starts another process — no Prawn, no
|
|
18
|
+
headless browser.
|
|
19
|
+
email:
|
|
20
|
+
- mikael@mhenrixon.com
|
|
21
|
+
executables: []
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- LICENSE.txt
|
|
27
|
+
- README.md
|
|
28
|
+
- lib/stationery.rb
|
|
29
|
+
- lib/stationery/builder.rb
|
|
30
|
+
- lib/stationery/canvas.rb
|
|
31
|
+
- lib/stationery/canvas/path.rb
|
|
32
|
+
- lib/stationery/canvas/text.rb
|
|
33
|
+
- lib/stationery/color.rb
|
|
34
|
+
- lib/stationery/component.rb
|
|
35
|
+
- lib/stationery/document.rb
|
|
36
|
+
- lib/stationery/elements.rb
|
|
37
|
+
- lib/stationery/errors.rb
|
|
38
|
+
- lib/stationery/fonts/cmap.rb
|
|
39
|
+
- lib/stationery/fonts/family.rb
|
|
40
|
+
- lib/stationery/fonts/font.rb
|
|
41
|
+
- lib/stationery/fonts/font_book.rb
|
|
42
|
+
- lib/stationery/fonts/name_table.rb
|
|
43
|
+
- lib/stationery/fonts/registry.rb
|
|
44
|
+
- lib/stationery/fonts/subset.rb
|
|
45
|
+
- lib/stationery/fonts/true_type.rb
|
|
46
|
+
- lib/stationery/geometry.rb
|
|
47
|
+
- lib/stationery/images/cache.rb
|
|
48
|
+
- lib/stationery/images/images.rb
|
|
49
|
+
- lib/stationery/images/jpeg.rb
|
|
50
|
+
- lib/stationery/images/png.rb
|
|
51
|
+
- lib/stationery/images/scanlines.rb
|
|
52
|
+
- lib/stationery/layout/box.rb
|
|
53
|
+
- lib/stationery/layout/flow.rb
|
|
54
|
+
- lib/stationery/layout/image.rb
|
|
55
|
+
- lib/stationery/layout/leaves.rb
|
|
56
|
+
- lib/stationery/layout/node.rb
|
|
57
|
+
- lib/stationery/layout/paginator.rb
|
|
58
|
+
- lib/stationery/layout/positioned.rb
|
|
59
|
+
- lib/stationery/layout/row.rb
|
|
60
|
+
- lib/stationery/layout/table.rb
|
|
61
|
+
- lib/stationery/layout/table/cell.rb
|
|
62
|
+
- lib/stationery/layout/table/selection.rb
|
|
63
|
+
- lib/stationery/layout/table/widths.rb
|
|
64
|
+
- lib/stationery/layout/text.rb
|
|
65
|
+
- lib/stationery/page.rb
|
|
66
|
+
- lib/stationery/page_templates.rb
|
|
67
|
+
- lib/stationery/pdf/assembler.rb
|
|
68
|
+
- lib/stationery/pdf/serializer.rb
|
|
69
|
+
- lib/stationery/pdf/stream.rb
|
|
70
|
+
- lib/stationery/pdf/types.rb
|
|
71
|
+
- lib/stationery/pdf/writer.rb
|
|
72
|
+
- lib/stationery/rails.rb
|
|
73
|
+
- lib/stationery/resources.rb
|
|
74
|
+
- lib/stationery/text/entities.rb
|
|
75
|
+
- lib/stationery/text/line.rb
|
|
76
|
+
- lib/stationery/text/markup.rb
|
|
77
|
+
- lib/stationery/text/paragraph.rb
|
|
78
|
+
- lib/stationery/text/runs_builder.rb
|
|
79
|
+
- lib/stationery/text/style.rb
|
|
80
|
+
- lib/stationery/text/wrapper.rb
|
|
81
|
+
- lib/stationery/version.rb
|
|
82
|
+
homepage: https://github.com/zoolutions/stationery
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
homepage_uri: https://github.com/zoolutions/stationery
|
|
87
|
+
source_code_uri: https://github.com/zoolutions/stationery/tree/main
|
|
88
|
+
changelog_uri: https://github.com/zoolutions/stationery/blob/main/CHANGELOG.md
|
|
89
|
+
rubygems_mfa_required: 'true'
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: 3.4.0
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 4.0.19
|
|
105
|
+
specification_version: 4
|
|
106
|
+
summary: 'Pure-Ruby, Phlex-style PDF documents: box layout, TrueType fonts, images.
|
|
107
|
+
No Prawn, no Chrome.'
|
|
108
|
+
test_files: []
|