stationery 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +60 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +102 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +48 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- metadata +101 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Loaded by stationery/rails inside a Rails app. Adds `render pdf:` and the
|
|
5
|
+
# app's font directories. Configure in config/application.rb:
|
|
6
|
+
#
|
|
7
|
+
# config.stationery.renderer = false # keep another gem's render pdf:
|
|
8
|
+
# config.stationery.font_paths = ["app/fonts"] # relative to Rails.root
|
|
9
|
+
# config.stationery.preview_paths = ["spec/pdfs/previews"] # see Stationery::Preview
|
|
10
|
+
# config.stationery.show_previews = true # default: development only
|
|
11
|
+
class Railtie < ::Rails::Railtie
|
|
12
|
+
config.stationery = ActiveSupport::OrderedOptions.new
|
|
13
|
+
config.stationery.renderer = true
|
|
14
|
+
config.stationery.font_paths = ["vendor/fonts"]
|
|
15
|
+
config.stationery.preview_paths = ["spec/pdfs/previews", "test/pdfs/previews"]
|
|
16
|
+
config.stationery.show_previews = ::Rails.env.development?
|
|
17
|
+
|
|
18
|
+
initializer "stationery.font_paths" do |app|
|
|
19
|
+
Railtie.add_font_paths(app.root, app.config.stationery.font_paths)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "stationery.renderer" do |app|
|
|
23
|
+
Railtie.add_renderer if app.config.stationery.renderer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "stationery.previews" do |app|
|
|
27
|
+
Railtie.add_previews(app.routes) if app.config.stationery.show_previews
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.add_font_paths(root, dirs)
|
|
31
|
+
dirs.each do |dir|
|
|
32
|
+
path = root.join(dir).to_s
|
|
33
|
+
Stationery.font_paths << path if File.directory?(path) && !Stationery.font_paths.include?(path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.preview_paths(root, dirs) = dirs.map { root.join(it).to_s }.select { File.directory?(it) }
|
|
38
|
+
|
|
39
|
+
def self.add_previews(routes)
|
|
40
|
+
ActiveSupport.on_load(:action_controller_base) { require "stationery/rails/previews_controller" }
|
|
41
|
+
routes.prepend do
|
|
42
|
+
get "/rails/stationery/previews" => "stationery/previews#index", :as => :stationery_previews
|
|
43
|
+
get "/rails/stationery/previews/*path" => "stationery/previews#show"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.add_renderer
|
|
48
|
+
ActiveSupport.on_load(:action_controller) do
|
|
49
|
+
ActionController::Renderers.add(:pdf) do |document, options|
|
|
50
|
+
unless document.respond_to?(:to_pdf)
|
|
51
|
+
raise ArgumentError, "render pdf: expects a Stationery::Document (got #{document.class})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
send_pdf(document, **{ filename: options[:filename],
|
|
55
|
+
disposition: options.fetch(:disposition, "inline") }.compact)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# A header or footer declaration: which slot, how much space it reserves
|
|
5
|
+
# (nil = measured), the gap to the body, the pages it applies to and the
|
|
6
|
+
# block that draws it.
|
|
7
|
+
Region = Data.define(:slot, :height, :gap, :on, :block)
|
|
8
|
+
|
|
9
|
+
# A document's header and footer declarations. Later declarations win for
|
|
10
|
+
# the pages they match; a region without a height is measured once, at the
|
|
11
|
+
# first page that asks for it.
|
|
12
|
+
class Regions
|
|
13
|
+
SELECTORS = %i[all first rest odd even last].freeze
|
|
14
|
+
SLOTS = %i[header footer].freeze
|
|
15
|
+
|
|
16
|
+
def self.validate!(on)
|
|
17
|
+
return on if SELECTORS.include?(on) || on.is_a?(Integer) || on.is_a?(Range) || on.respond_to?(:call)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, "on: takes #{SELECTORS.map(&:inspect).join(", ")}, a page number, a range or a proc, " \
|
|
20
|
+
"got #{on.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.matches?(on, number, last: false)
|
|
24
|
+
case on
|
|
25
|
+
when :all then true
|
|
26
|
+
when :first then number == 1
|
|
27
|
+
when :rest then number > 1
|
|
28
|
+
when :odd then number.odd?
|
|
29
|
+
when :even then number.even?
|
|
30
|
+
when :last then last
|
|
31
|
+
when Integer then number == on
|
|
32
|
+
when Range then on.cover?(number)
|
|
33
|
+
else on.call(number)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(entries, measure:)
|
|
38
|
+
@entries = entries
|
|
39
|
+
@measure = measure
|
|
40
|
+
@heights = {}.compare_by_identity
|
|
41
|
+
@last = entries.any? { |entry| entry.on == :last }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def entry_for(slot, number, last: false)
|
|
45
|
+
@entries.reverse_each.find { |entry| entry.slot == slot && self.class.matches?(entry.on, number, last:) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [top, bottom] points taken from the body on page `number`.
|
|
49
|
+
def reserve(number, last: false)
|
|
50
|
+
SLOTS.map do |slot|
|
|
51
|
+
entry = entry_for(slot, number, last:)
|
|
52
|
+
height = entry ? height_of(entry, number) : 0
|
|
53
|
+
height.positive? ? height + entry.gap : 0
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether page `number` would reserve different space as the last page.
|
|
58
|
+
def last_sensitive?(number) = @last && reserve(number, last: true) != reserve(number)
|
|
59
|
+
|
|
60
|
+
def height_of(entry, number)
|
|
61
|
+
return entry.height if entry.height
|
|
62
|
+
return 0 unless entry.block
|
|
63
|
+
|
|
64
|
+
@heights[entry] ||= @measure.call(entry, number)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/stationery/resources.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
|
-
# Document-wide names for fonts, images
|
|
4
|
+
# Document-wide names for fonts, images, graphics states and shadings, so each is
|
|
5
5
|
# embedded once however many pages use it.
|
|
6
6
|
class Resources
|
|
7
7
|
def initialize
|
|
8
8
|
@fonts = {}
|
|
9
9
|
@images = {}
|
|
10
10
|
@states = {}
|
|
11
|
+
@shadings = {}
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def font(font)
|
|
@@ -22,12 +23,17 @@ module Stationery
|
|
|
22
23
|
@states[value.to_f.round(3)] ||= :"GS#{@states.size + 1}"
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
def shading(dictionary)
|
|
27
|
+
@shadings[dictionary] ||= :"Sh#{@shadings.size + 1}"
|
|
28
|
+
end
|
|
29
|
+
|
|
25
30
|
# Writes every resource once; returns { category => { name => ref } }.
|
|
26
31
|
def build(writer)
|
|
27
32
|
{
|
|
28
33
|
Font: @fonts.to_h { |font, name| [name, font.build(writer)] },
|
|
29
34
|
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 })] }
|
|
35
|
+
ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] },
|
|
36
|
+
Shading: @shadings.to_h { |dictionary, name| [name, writer.add(dictionary)] }
|
|
31
37
|
}
|
|
32
38
|
end
|
|
33
39
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# The block model rich-text parsers (HTML, Markdown) produce and elements render.
|
|
5
|
+
module Rich
|
|
6
|
+
# A run of text with its marks: any of bold:, italic:, underline:, strike:, code:, link: (href),
|
|
7
|
+
# script: (:sub/:sup) and break: true, a hard line break whose text is "".
|
|
8
|
+
Inline = Data.define(:text, :marks) do
|
|
9
|
+
def self.break = new(text: "", marks: { break: true })
|
|
10
|
+
|
|
11
|
+
def initialize(text:, marks: {}) = super(text: text.to_s, marks: marks.frozen? ? marks : marks.dup.freeze)
|
|
12
|
+
def break? = marks[:break] == true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Paragraph = Data.define(:inlines)
|
|
16
|
+
Heading = Data.define(:level, :inlines)
|
|
17
|
+
List = Data.define(:ordered, :start, :items)
|
|
18
|
+
Blockquote = Data.define(:blocks)
|
|
19
|
+
CodeBlock = Data.define(:text, :language)
|
|
20
|
+
Rule = Data.define
|
|
21
|
+
Table = Data.define(:rows)
|
|
22
|
+
Cell = Data.define(:header, :align, :blocks)
|
|
23
|
+
Image = Data.define(:src, :alt, :width, :height)
|
|
24
|
+
|
|
25
|
+
# Operations on inline sequences shared by the parsers.
|
|
26
|
+
module Inlines
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Joins adjacent inlines with equal marks and drops empty ones; breaks and images pass through.
|
|
30
|
+
def merge(items)
|
|
31
|
+
items.each_with_object([]) do |item, merged|
|
|
32
|
+
next merged << item unless item.is_a?(Inline) && !item.break?
|
|
33
|
+
next if item.text.empty?
|
|
34
|
+
|
|
35
|
+
last = merged.last
|
|
36
|
+
if last.is_a?(Inline) && !last.break? && last.marks == item.marks
|
|
37
|
+
merged[-1] = last.with(text: last.text + item.text)
|
|
38
|
+
else
|
|
39
|
+
merged << item
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Groups inlines into paragraphs, with each image standing as a block between them.
|
|
45
|
+
def paragraphs(items)
|
|
46
|
+
items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
|
|
47
|
+
next run.first if run.first.is_a?(Image)
|
|
48
|
+
|
|
49
|
+
inlines = merge(block_given? ? yield(run) : run)
|
|
50
|
+
Paragraph.new(inlines:) unless inlines.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Writes inlines into a Text::RunsBuilder, nesting one builder call per mark.
|
|
7
|
+
class Inlines
|
|
8
|
+
STYLE_STEPS = {
|
|
9
|
+
weight: ->(value) { [:b] if value == :bold },
|
|
10
|
+
style: ->(value) { [:i] if value == :italic },
|
|
11
|
+
underline: ->(value) { [:u] if value },
|
|
12
|
+
strikethrough: ->(value) { [:strikethrough] if value },
|
|
13
|
+
color: ->(value) { [:color, value] if value },
|
|
14
|
+
size: ->(value) { [:size, value] if value },
|
|
15
|
+
font: ->(value) { [:font, value] if value }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
MARK_STEPS = {
|
|
19
|
+
bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(runs, styles)
|
|
23
|
+
@runs = runs
|
|
24
|
+
@styles = styles
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(inlines) = inlines.grep(Inline).each { |inline| inline.break? ? @runs.br : write_one(inline) }
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def write_one(inline) = nest(inline.marks.flat_map { |mark, value| steps(mark, value) }, inline.text)
|
|
32
|
+
|
|
33
|
+
def nest(steps, text)
|
|
34
|
+
return @runs.plain(text) if steps.empty?
|
|
35
|
+
|
|
36
|
+
name, *args = steps.first
|
|
37
|
+
@runs.public_send(name, *args) { nest(steps.drop(1), text) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def steps(mark, value)
|
|
41
|
+
case mark
|
|
42
|
+
when :link then [[:link, value], *style_steps(@styles[:a])]
|
|
43
|
+
when :code then style_steps(@styles[:code])
|
|
44
|
+
when :script then [[value]]
|
|
45
|
+
else MARK_STEPS.fetch(mark, [])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def style_steps(style) = style.filter_map { |key, value| STYLE_STEPS[key]&.call(value) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nodes"
|
|
4
|
+
require_relative "styles"
|
|
5
|
+
require_relative "renderer/inlines"
|
|
6
|
+
|
|
7
|
+
module Stationery
|
|
8
|
+
module Rich
|
|
9
|
+
# Draws rich-text blocks with a component's element DSL. Images come from
|
|
10
|
+
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
11
|
+
# from files under `base_path:`; remote URLs are never fetched.
|
|
12
|
+
class Renderer
|
|
13
|
+
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
14
|
+
|
|
15
|
+
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false)
|
|
16
|
+
@component = component
|
|
17
|
+
@builder = builder
|
|
18
|
+
@gap = gap
|
|
19
|
+
@styles = Styles.resolve(styles)
|
|
20
|
+
@images = images
|
|
21
|
+
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
22
|
+
@bookmarks = bookmarks
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def block(block)
|
|
30
|
+
case block
|
|
31
|
+
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
32
|
+
when Heading then heading(block)
|
|
33
|
+
when List then list(block)
|
|
34
|
+
when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
|
|
35
|
+
when CodeBlock then code(block)
|
|
36
|
+
when Rule then @component.rule(**@styles[:hr])
|
|
37
|
+
when Table then table(block)
|
|
38
|
+
when Image then image(block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def paragraph(inlines, **)
|
|
43
|
+
@component.text(**) { |runs| Inlines.new(runs, @styles).write(inlines) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def heading(heading)
|
|
47
|
+
style = @styles[:"h#{heading.level}"]
|
|
48
|
+
size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
|
|
49
|
+
bookmark = heading_bookmark(heading)
|
|
50
|
+
paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def heading_bookmark(heading)
|
|
54
|
+
return unless @bookmarks && heading.level <= 3
|
|
55
|
+
|
|
56
|
+
{ title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def list(list)
|
|
60
|
+
options = list.ordered ? { start: list.start || 1 } : {}
|
|
61
|
+
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
62
|
+
list.items.each do |blocks|
|
|
63
|
+
@component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def code(block)
|
|
69
|
+
font = @styles[:code][:font]
|
|
70
|
+
@component.box(**@styles[:pre]) { @component.text(block.text, **({ font: } if font)) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def table(table)
|
|
74
|
+
header = table.rows.first&.all?(&:header) ? 1 : 0
|
|
75
|
+
rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
|
|
76
|
+
@component.table(rows, header:, cell: @styles[:table][:cell])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cell_content(cell)
|
|
80
|
+
style = cell.header ? @styles[:table][:header] : {}
|
|
81
|
+
-> { @component.text_style(**style, align: cell.align) { render(cell.blocks) } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def image(node)
|
|
85
|
+
source = resolve(node.src)
|
|
86
|
+
return unless source
|
|
87
|
+
|
|
88
|
+
loaded = Images.load(source)
|
|
89
|
+
@component.image(loaded, **dimensions(node, loaded))
|
|
90
|
+
rescue UnsupportedImage => e
|
|
91
|
+
skip(node.src, e.message)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve(src)
|
|
95
|
+
return @images.call(src) || skip(src, "not found") if @images
|
|
96
|
+
return skip(src, "remote") if REMOTE.match?(src)
|
|
97
|
+
return skip(src, "not found") unless @base_path
|
|
98
|
+
|
|
99
|
+
path = File.expand_path(src, @base_path)
|
|
100
|
+
return skip(src, "outside base_path") unless path.start_with?(@base_path + File::SEPARATOR)
|
|
101
|
+
|
|
102
|
+
File.file?(path) ? path : skip(src, "not found")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dimensions(node, loaded)
|
|
106
|
+
width = node.width
|
|
107
|
+
height = node.height
|
|
108
|
+
limit = @styles[:img][:max_width]
|
|
109
|
+
natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
|
|
110
|
+
return { width:, height: } unless limit && natural > limit
|
|
111
|
+
|
|
112
|
+
{ width: limit, height: height && (height * limit.fdiv(natural)) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def skip(source, reason)
|
|
116
|
+
@builder.warnings << Warnings::SkippedImage.new(source:, reason:)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
# How the html and markdown elements style each kind of block. Heading
|
|
6
|
+
# `scale:` multiplies the current text size; a `size:` in points wins.
|
|
7
|
+
module Styles
|
|
8
|
+
HEADING_SCALES = [2.0, 1.6, 1.3, 1.1, 1.0, 0.9].freeze
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
**HEADING_SCALES.each_with_index.to_h do |scale, index|
|
|
12
|
+
[:"h#{index + 1}", { scale:, weight: :bold, keep_with_next: true }]
|
|
13
|
+
end,
|
|
14
|
+
p: {},
|
|
15
|
+
a: { color: "#2563EB", underline: true },
|
|
16
|
+
code: { font: nil },
|
|
17
|
+
pre: { background: "#F3F4F6", padding: 8, radius: 4 },
|
|
18
|
+
blockquote: { border: { sides: [:left], width: 3, color: "#E5E7EB" }, padding: [0, 0, 0, 10] },
|
|
19
|
+
hr: { height: 1, color: "#E5E7EB" },
|
|
20
|
+
table: { cell: { padding: 4 }, header: { weight: :bold } },
|
|
21
|
+
li: {},
|
|
22
|
+
img: { max_width: nil }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def resolve(overrides) = merge(DEFAULTS, overrides)
|
|
28
|
+
|
|
29
|
+
def merge(base, overrides)
|
|
30
|
+
base.merge(overrides) { |_, old, new| old.is_a?(Hash) && new.is_a?(Hash) ? merge(old, new) : new }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Resolves internal links (`#name`) against the anchors painted on the
|
|
5
|
+
# finished pages and fills page-number slots with the pages their anchors
|
|
6
|
+
# landed on. Anchors drawn by page templates repeat on every page, so they
|
|
7
|
+
# resolve to their first page and never count as duplicates.
|
|
8
|
+
class Structure
|
|
9
|
+
# Where a named anchor sits: a zero-based page index and a PDF-space top.
|
|
10
|
+
Destination = Data.define(:page, :top)
|
|
11
|
+
|
|
12
|
+
# Returns the destinations by name; unresolved links are dropped.
|
|
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
|
|
15
|
+
|
|
16
|
+
def initialize(pages, warnings, resources, book)
|
|
17
|
+
@pages = pages
|
|
18
|
+
@warnings = warnings
|
|
19
|
+
@resources = resources
|
|
20
|
+
@book = book
|
|
21
|
+
@destinations = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolve
|
|
25
|
+
collect
|
|
26
|
+
collect_templates
|
|
27
|
+
@pages.each_with_index do |page, index|
|
|
28
|
+
page.slots.each { |slot| fill(page, slot) }
|
|
29
|
+
page.annotations.replace(page.annotations.filter_map { |link| link_to(link, index) })
|
|
30
|
+
end
|
|
31
|
+
@destinations
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def collect
|
|
37
|
+
@pages.each_with_index do |page, index|
|
|
38
|
+
page.anchors.each do |name, top|
|
|
39
|
+
if @destinations.key?(name)
|
|
40
|
+
@warnings << Warnings::DuplicateAnchor.new(name:, page: index + 1)
|
|
41
|
+
else
|
|
42
|
+
@destinations[name] = Destination.new(index, top)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def collect_templates
|
|
49
|
+
@pages.each_with_index do |page, index|
|
|
50
|
+
page.template_anchors.each { |name, top| @destinations[name] ||= Destination.new(index, top) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fill(page, slot)
|
|
55
|
+
destination = @destinations[slot.anchor] or return
|
|
56
|
+
label = (destination.page + 1).to_s
|
|
57
|
+
font, face = @book.resolve(slot.style)
|
|
58
|
+
style = slot.style
|
|
59
|
+
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)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def link_to(link, index)
|
|
69
|
+
return link unless link.key?(:dest)
|
|
70
|
+
|
|
71
|
+
destination = @destinations[link[:dest]]
|
|
72
|
+
return { rect: link[:rect], dest: destination } if destination
|
|
73
|
+
|
|
74
|
+
@warnings << Warnings::UnresolvedLink.new(name: link[:dest], page: index + 1)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# An SVG elliptical arc as cubic Bezier segments (SVG 1.1 appendix F.6.5:
|
|
6
|
+
# endpoint to centre parameterisation, then at most 90 degrees a segment).
|
|
7
|
+
class Arc
|
|
8
|
+
def initialize(x1, y1, rx, ry, rotation, large, sweep, x2, y2)
|
|
9
|
+
@x1 = x1.to_f
|
|
10
|
+
@y1 = y1.to_f
|
|
11
|
+
@x2 = x2
|
|
12
|
+
@y2 = y2
|
|
13
|
+
@rx = rx.to_f
|
|
14
|
+
@ry = ry.to_f
|
|
15
|
+
@phi = rotation * Math::PI / 180
|
|
16
|
+
@large = large == 1
|
|
17
|
+
@sweep = sweep == 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def curves
|
|
21
|
+
centre
|
|
22
|
+
count = [(@delta.abs / (Math::PI / 2)).ceil, 1].max
|
|
23
|
+
step = @delta / count
|
|
24
|
+
curves = Array.new(count) { |i| segment(@theta + (i * step), step) }
|
|
25
|
+
curves.last[4] = @x2
|
|
26
|
+
curves.last[5] = @y2
|
|
27
|
+
curves
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def centre
|
|
33
|
+
cos = Math.cos(@phi)
|
|
34
|
+
sin = Math.sin(@phi)
|
|
35
|
+
dx = (@x1 - @x2) / 2.0
|
|
36
|
+
dy = (@y1 - @y2) / 2.0
|
|
37
|
+
x1p = (cos * dx) + (sin * dy)
|
|
38
|
+
y1p = (-sin * dx) + (cos * dy)
|
|
39
|
+
scale_radii(x1p, y1p)
|
|
40
|
+
cxp, cyp = centre_prime(x1p, y1p)
|
|
41
|
+
@cx = (cos * cxp) - (sin * cyp) + ((@x1 + @x2) / 2.0)
|
|
42
|
+
@cy = (sin * cxp) + (cos * cyp) + ((@y1 + @y2) / 2.0)
|
|
43
|
+
angles(x1p, y1p, cxp, cyp)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scale_radii(x1p, y1p)
|
|
47
|
+
lambda = ((x1p**2) / (@rx**2)) + ((y1p**2) / (@ry**2))
|
|
48
|
+
return unless lambda > 1
|
|
49
|
+
|
|
50
|
+
@rx *= Math.sqrt(lambda)
|
|
51
|
+
@ry *= Math.sqrt(lambda)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def centre_prime(x1p, y1p)
|
|
55
|
+
rx2 = @rx**2
|
|
56
|
+
ry2 = @ry**2
|
|
57
|
+
numerator = (rx2 * ry2) - (rx2 * (y1p**2)) - (ry2 * (x1p**2))
|
|
58
|
+
denominator = (rx2 * (y1p**2)) + (ry2 * (x1p**2))
|
|
59
|
+
coefficient = Math.sqrt([numerator, 0].max / denominator) * (@large == @sweep ? -1 : 1)
|
|
60
|
+
[coefficient * @rx * y1p / @ry, -coefficient * @ry * x1p / @rx]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def angles(x1p, y1p, cxp, cyp)
|
|
64
|
+
ux = (x1p - cxp) / @rx
|
|
65
|
+
uy = (y1p - cyp) / @ry
|
|
66
|
+
vx = (-x1p - cxp) / @rx
|
|
67
|
+
vy = (-y1p - cyp) / @ry
|
|
68
|
+
@theta = Math.atan2(uy, ux)
|
|
69
|
+
@delta = Math.atan2((ux * vy) - (uy * vx), (ux * vx) + (uy * vy))
|
|
70
|
+
@delta -= 2 * Math::PI if !@sweep && @delta.positive?
|
|
71
|
+
@delta += 2 * Math::PI if @sweep && @delta.negative?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def segment(start, step)
|
|
75
|
+
t = 4.0 / 3 * Math.tan(step / 4)
|
|
76
|
+
a1 = start
|
|
77
|
+
a2 = start + step
|
|
78
|
+
p1 = point(Math.cos(a1) - (t * Math.sin(a1)), Math.sin(a1) + (t * Math.cos(a1)))
|
|
79
|
+
p2 = point(Math.cos(a2) + (t * Math.sin(a2)), Math.sin(a2) - (t * Math.cos(a2)))
|
|
80
|
+
p3 = point(Math.cos(a2), Math.sin(a2))
|
|
81
|
+
[*p1, *p2, *p3]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def point(ux, uy)
|
|
85
|
+
x = @rx * ux
|
|
86
|
+
y = @ry * uy
|
|
87
|
+
[(Math.cos(@phi) * x) - (Math.sin(@phi) * y) + @cx, (Math.sin(@phi) * x) + (Math.cos(@phi) * y) + @cy]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# A path sink that measures a shape's bounding box in its own user space,
|
|
6
|
+
# curves by their extrema. Takes the calls Shapes.trace makes on a path.
|
|
7
|
+
class Bounds
|
|
8
|
+
def initialize
|
|
9
|
+
@xs = []
|
|
10
|
+
@ys = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def move_to(x, y) = add(x, y)
|
|
14
|
+
def line_to(x, y) = add(x, y)
|
|
15
|
+
def close = self
|
|
16
|
+
|
|
17
|
+
def curve_to(x1, y1, x2, y2, x, y)
|
|
18
|
+
x0 = @xs.last || 0
|
|
19
|
+
y0 = @ys.last || 0
|
|
20
|
+
(extrema(x0, x1, x2, x) + extrema(y0, y1, y2, y)).each do |at|
|
|
21
|
+
add(cubic(at, x0, x1, x2, x), cubic(at, y0, y1, y2, y))
|
|
22
|
+
end
|
|
23
|
+
add(x, y)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rounded_rect(x, y, w, h, _radius)
|
|
27
|
+
add(x, y)
|
|
28
|
+
add(x + w, y + h)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ellipse(cx, cy, rx, ry)
|
|
32
|
+
add(cx - rx, cy - ry)
|
|
33
|
+
add(cx + rx, cy + ry)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# [x, y, width, height], or nil when the shape has no area.
|
|
37
|
+
def box
|
|
38
|
+
return nil if @xs.empty?
|
|
39
|
+
|
|
40
|
+
width = @xs.max - @xs.min
|
|
41
|
+
height = @ys.max - @ys.min
|
|
42
|
+
[@xs.min, @ys.min, width, height] if width.positive? && height.positive?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def add(x, y)
|
|
48
|
+
@xs << x
|
|
49
|
+
@ys << y
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parameters in (0, 1) where the cubic's derivative is zero.
|
|
54
|
+
def extrema(from, first, second, to)
|
|
55
|
+
a = -from + (3 * first) - (3 * second) + to
|
|
56
|
+
b = 2 * (from - (2 * first) + second)
|
|
57
|
+
c = first - from
|
|
58
|
+
roots(a, b, c).select { |t| t.positive? && t < 1 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def roots(a, b, c)
|
|
62
|
+
return b.zero? ? [] : [-c.fdiv(b)] if a.zero?
|
|
63
|
+
|
|
64
|
+
discriminant = (b**2) - (4 * a * c)
|
|
65
|
+
return [] if discriminant.negative?
|
|
66
|
+
|
|
67
|
+
[1, -1].map { |sign| (-b + (sign * Math.sqrt(discriminant))) / (2.0 * a) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cubic(at, from, first, second, to)
|
|
71
|
+
rest = 1 - at
|
|
72
|
+
((rest**3) * from) + (3 * (rest**2) * at * first) + (3 * rest * (at**2) * second) + ((at**3) * to)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|