stationery 0.1.0 → 0.2.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. metadata +85 -4
@@ -4,31 +4,38 @@ module Stationery
4
4
  module PDF
5
5
  # Writes finished pages, their shared resources and document info as PDF.
6
6
  class Assembler
7
- def initialize(pages:, resources:, info: {})
7
+ def initialize(pages:, resources:, info: {}, outline: [])
8
8
  @pages = pages
9
9
  @resources = resources
10
10
  @info = info
11
+ @outline = outline
11
12
  end
12
13
 
13
14
  def render
14
15
  writer = Writer.new
15
16
  tree = writer.reserve
16
17
  refs = @resources.build(writer)
17
- kids = @pages.map { |page| write_page(writer, page, tree, refs) }
18
+ kids = @kids = @pages.map { writer.reserve }
19
+ @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
18
20
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
19
- root = writer.add({ Type: :Catalog, Pages: tree })
21
+ root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
20
22
  writer.render(root:, info: writer.add(info_dictionary))
21
23
  end
22
24
 
23
25
  private
24
26
 
25
- def write_page(writer, page, tree, refs)
27
+ def catalog(tree, outlines)
28
+ catalog = { Type: :Catalog, Pages: tree }
29
+ outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
30
+ end
31
+
32
+ def write_page(writer, page, ref, tree, refs)
26
33
  dictionary = {
27
34
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
28
35
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
29
36
  }
30
37
  dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
31
- writer.add(dictionary)
38
+ writer.set(ref, dictionary)
32
39
  end
33
40
 
34
41
  def page_resources(page, refs)
@@ -38,8 +45,12 @@ module Stationery
38
45
  end
39
46
 
40
47
  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 } }
48
+ target = if (dest = link[:dest])
49
+ { Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
50
+ else
51
+ { A: { Type: :Action, S: :URI, URI: link[:url].b } }
52
+ end
53
+ { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
43
54
  end
44
55
 
45
56
  def info_dictionary
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # Writes outline items as the /Outlines tree. Each item nests under the
6
+ # nearest earlier item of a shallower level, so a level jump (1 → 3) or a
7
+ # leading level-2 item still lands somewhere sensible.
8
+ class OutlineWriter
9
+ Node = Data.define(:item, :ref, :children) do
10
+ def open? = item.nil? || item.open
11
+ end
12
+
13
+ def initialize(writer, items, page_refs)
14
+ @writer = writer
15
+ @items = items
16
+ @page_refs = page_refs
17
+ end
18
+
19
+ # The /Outlines root reference, or nil when there is nothing to write.
20
+ def write
21
+ return if @items.empty?
22
+
23
+ root = tree
24
+ write_children(root)
25
+ @writer.set(root.ref, { Type: :Outlines, First: root.children.first.ref, Last: root.children.last.ref,
26
+ Count: visible(root) })
27
+ end
28
+
29
+ private
30
+
31
+ def tree
32
+ root = Node.new(nil, @writer.reserve, [])
33
+ stack = []
34
+ @items.each do |item|
35
+ stack.pop while stack.any? && stack.last.item.level >= item.level
36
+ node = Node.new(item, @writer.reserve, [])
37
+ (stack.last || root).children << node
38
+ stack << node
39
+ end
40
+ root
41
+ end
42
+
43
+ def write_children(parent)
44
+ parent.children.each_with_index do |node, index|
45
+ write_children(node)
46
+ siblings = { Prev: index.positive? ? parent.children[index - 1].ref : nil,
47
+ Next: parent.children[index + 1]&.ref }
48
+ @writer.set(node.ref, dictionary(node, parent).merge(siblings).compact)
49
+ end
50
+ end
51
+
52
+ def dictionary(node, parent)
53
+ dest = node.item.dest
54
+ {
55
+ Title: TextString.new(node.item.title), Parent: parent.ref,
56
+ First: node.children.first&.ref, Last: node.children.last&.ref, Count: count(node),
57
+ Dest: [@page_refs.fetch(dest.page), :XYZ, nil, dest.top, nil]
58
+ }
59
+ end
60
+
61
+ # Items shown when `node` is expanded: its children plus the visible
62
+ # descendants of each open child.
63
+ def visible(node) = node.children.sum { |child| 1 + (child.open? ? visible(child) : 0) }
64
+
65
+ def count(node)
66
+ return if node.children.empty?
67
+
68
+ node.open? ? visible(node) : -visible(node)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -41,7 +41,11 @@ module Stationery
41
41
  def number(value)
42
42
  return value.to_s if value.is_a?(Integer)
43
43
 
44
- str = format("%.4f", value).sub(/\.?0+\z/, "")
44
+ str = format("%.4f", value)
45
+ last = str.bytesize
46
+ last -= 1 while str.getbyte(last - 1) == 48 # "0"
47
+ last -= 1 if str.getbyte(last - 1) == 46 # "."
48
+ str = str.byteslice(0, last)
45
49
  str == "-0" ? "0" : str
46
50
  end
47
51
 
@@ -30,6 +30,10 @@ module Stationery
30
30
  end
31
31
 
32
32
  def render(root:, info:)
33
+ if (missing = @objects.index(nil))
34
+ raise Error, "object #{missing + 1} reserved but never set"
35
+ end
36
+
33
37
  out = HEADER.dup
34
38
  offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
35
39
  xref = out.bytesize
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stationery"
4
+
5
+ module Stationery
6
+ # Browsable sample documents, like ActionMailer previews. Each public method
7
+ # returns one document:
8
+ #
9
+ # # spec/pdfs/previews/invoice_pdf_preview.rb
10
+ # class InvoicePdfPreview < Stationery::Preview
11
+ # def paid = InvoicePdf.new(Invoice.first)
12
+ # def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
13
+ # end
14
+ class Preview
15
+ REGISTRY_LOCK = Mutex.new
16
+
17
+ class << self
18
+ def inherited(subclass)
19
+ super
20
+ REGISTRY_LOCK.synchronize { Preview.registry << subclass }
21
+ end
22
+
23
+ # Named subclasses with at least one preview; a name defined again (a
24
+ # reloaded file) keeps its latest class.
25
+ def all
26
+ classes = REGISTRY_LOCK.synchronize { Preview.registry.dup }
27
+ classes.select { it.name && it.pdfs.any? }.reverse.uniq(&:name).sort_by(&:preview_name)
28
+ end
29
+
30
+ # `load`, not `require`: edited previews show up without a restart.
31
+ def load(paths)
32
+ paths.each { |path| Dir["#{path}/**/*_preview.rb"].each { |file| Kernel.load(file) } }
33
+ end
34
+
35
+ # "invoice_pdf/paid" => [InvoicePdfPreview, "paid"]
36
+ def find(path)
37
+ name, _, pdf = path.to_s.rpartition("/")
38
+ klass = all.find { it.preview_name == name }
39
+ [klass, pdf] if klass&.pdfs&.include?(pdf)
40
+ end
41
+
42
+ def preview_name = underscore(name.delete_suffix("Preview"))
43
+ def pdfs = public_instance_methods(false).sort.map(&:to_s)
44
+
45
+ protected
46
+
47
+ def registry = (@registry ||= []) # rubocop:disable ThreadSafety/ClassInstanceVariable
48
+
49
+ private
50
+
51
+ def underscore(name)
52
+ name.gsub("::", "/").gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
53
+ end
54
+ end
55
+
56
+ def render(name, params = {})
57
+ method = public_method(name)
58
+ document = method.arity.zero? ? method.call : method.call(params)
59
+ return document if document.respond_to?(:to_pdf)
60
+
61
+ raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "stationery/preview"
5
+
6
+ module Stationery
7
+ # Serves Stationery::Preview classes at /rails/stationery/previews. Loaded
8
+ # by the Railtie when config.stationery.show_previews is on.
9
+ class PreviewsController < ActionController::Base
10
+ before_action :require_previews
11
+ before_action :load_previews
12
+
13
+ def index
14
+ paths = Preview.all.flat_map { |klass| klass.pdfs.map { "#{klass.preview_name}/#{it}" } }
15
+ render html: page(paths).html_safe
16
+ end
17
+
18
+ def show
19
+ klass, pdf = Preview.find(params[:path])
20
+ return head(:not_found) unless klass
21
+
22
+ document = klass.new.render(pdf, request.query_parameters.except("debug"))
23
+ send_data document.to_pdf(**debug_option(document)), type: "application/pdf", disposition: "inline"
24
+ end
25
+
26
+ private
27
+
28
+ def config = ::Rails.application.config.stationery
29
+
30
+ def require_previews
31
+ head :not_found unless config.show_previews
32
+ end
33
+
34
+ def load_previews
35
+ Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
36
+ end
37
+
38
+ def debug_option(document)
39
+ debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
40
+ debug ? { debug: true } : {}
41
+ end
42
+
43
+ def page(paths)
44
+ items = paths.map do |path|
45
+ href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
46
+ %(<li><a href="#{href}">#{ERB::Util.html_escape(path)}</a> <a href="#{href}?debug=1">debug</a></li>)
47
+ end
48
+ <<~HTML
49
+ <!doctype html>
50
+ <html>
51
+ <head><meta charset="utf-8"><title>PDF previews</title></head>
52
+ <body style="font-family: system-ui, sans-serif; margin: 2rem">
53
+ <h1>PDF previews</h1>
54
+ <ul>#{items.join("\n")}</ul>
55
+ #{"<p>No previews found in #{ERB::Util.html_escape(config.preview_paths.join(", "))}.</p>" if items.empty?}
56
+ </body>
57
+ </html>
58
+ HTML
59
+ end
60
+ end
61
+ end
@@ -18,3 +18,5 @@ end
18
18
  if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
19
19
  ActiveSupport.on_load(:action_controller) { include Stationery::Rails }
20
20
  end
21
+
22
+ require_relative "railtie" if defined?(Rails::Railtie)
@@ -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
@@ -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