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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -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 +10 -6
  10. data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. 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
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- # Document-wide names for fonts, images and graphics states, so each is
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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stationery"
4
+ require_relative "testing/matchers"
5
+
6
+ RSpec.configure { |config| config.include Stationery::Testing::Matchers }
@@ -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