stationery 0.9.0 → 0.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +143 -34
  4. data/lib/stationery/{svg → css}/selector.rb +1 -1
  5. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  6. data/lib/stationery/css.rb +20 -0
  7. data/lib/stationery/document.rb +21 -9
  8. data/lib/stationery/elements/rich.rb +10 -2
  9. data/lib/stationery/elements.rb +9 -4
  10. data/lib/stationery/errors.rb +4 -0
  11. data/lib/stationery/fonts/fallback.rb +1 -1
  12. data/lib/stationery/html/css.rb +276 -0
  13. data/lib/stationery/html/document.rb +5 -3
  14. data/lib/stationery/html/tokenizer.rb +15 -4
  15. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  16. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  17. data/lib/stationery/html/tree_builder.rb +11 -1
  18. data/lib/stationery/layout/flow.rb +29 -0
  19. data/lib/stationery/layout/image.rb +12 -6
  20. data/lib/stationery/layout/mark.rb +2 -0
  21. data/lib/stationery/layout/node.rb +4 -0
  22. data/lib/stationery/layout/text.rb +47 -10
  23. data/lib/stationery/pdf/assembler.rb +14 -4
  24. data/lib/stationery/pdf/signature/cms.rb +21 -13
  25. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  26. data/lib/stationery/pdf/signature.rb +16 -9
  27. data/lib/stationery/pdf/writer.rb +73 -19
  28. data/lib/stationery/rich/nodes.rb +37 -11
  29. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  30. data/lib/stationery/rich/renderer/indents.rb +1 -0
  31. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  32. data/lib/stationery/rich/renderer.rb +32 -15
  33. data/lib/stationery/svg/style.rb +4 -6
  34. data/lib/stationery/testing/inspector.rb +49 -6
  35. data/lib/stationery/text/breaks.rb +123 -0
  36. data/lib/stationery/text/paragraph.rb +8 -0
  37. data/lib/stationery/text/runs_builder.rb +4 -0
  38. data/lib/stationery/text/wrapper.rb +57 -8
  39. data/lib/stationery/version.rb +1 -1
  40. data/lib/stationery/warnings.rb +9 -0
  41. data/lib/stationery.rb +3 -2
  42. metadata +8 -3
@@ -4,7 +4,9 @@ module Stationery
4
4
  # The block model rich-text parsers (HTML, Markdown) produce and elements render.
5
5
  module Rich
6
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 "".
7
+ # script: (:sub/:sup), color: (a colour), size: (points), scale: (a factor of the text size)
8
+ # and break: true, a hard line break whose text is "". A false bold:, italic:, underline: or
9
+ # strike: switches the mark off inside an element that has it.
8
10
  Inline = Data.define(:text, :marks) do
9
11
  def self.break = new(text: "", marks: { break: true })
10
12
 
@@ -12,15 +14,39 @@ module Stationery
12
14
  def break? = marks[:break] == true
13
15
  end
14
16
 
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)
17
+ # Every block carries a `style` Hash from the source's CSS (see HTML::Css):
18
+ # align:, background:, padding:, margin:/margin_top:/margin_bottom:,
19
+ # border:, width:, break_before:, break_after:, keep_together:. Empty
20
+ # for Markdown and for HTML without styles.
21
+ Paragraph = Data.define(:inlines, :style) do
22
+ def initialize(inlines:, style: {}) = super
23
+ end
24
+ Heading = Data.define(:level, :inlines, :style) do
25
+ def initialize(level:, inlines:, style: {}) = super
26
+ end
27
+ List = Data.define(:ordered, :start, :items, :style) do
28
+ def initialize(ordered:, start:, items:, style: {}) = super
29
+ end
30
+ Blockquote = Data.define(:blocks, :style) do
31
+ def initialize(blocks:, style: {}) = super
32
+ end
33
+ CodeBlock = Data.define(:text, :language, :style) do
34
+ def initialize(text:, language:, style: {}) = super
35
+ end
20
36
  Rule = Data.define
21
- Table = Data.define(:rows)
22
- Cell = Data.define(:header, :align, :blocks)
23
- Image = Data.define(:src, :alt, :width, :height)
37
+ Table = Data.define(:rows, :style) do
38
+ def initialize(rows:, style: {}) = super
39
+ end
40
+ Cell = Data.define(:header, :align, :blocks, :style) do
41
+ def initialize(header:, align:, blocks:, style: {}) = super
42
+ end
43
+ Image = Data.define(:src, :alt, :width, :height, :style) do
44
+ def initialize(src:, alt:, width:, height:, style: {}) = super
45
+ end
46
+ # A styled container (a `div` with a background, padding, margins or a
47
+ # page-break rule) holding blocks; an unstyled one flattens into its parent.
48
+ Container = Data.define(:blocks, :style)
49
+ PageBreak = Data.define
24
50
 
25
51
  # Operations on inline sequences shared by the parsers.
26
52
  module Inlines
@@ -42,12 +68,12 @@ module Stationery
42
68
  end
43
69
 
44
70
  # Groups inlines into paragraphs, with each image standing as a block between them.
45
- def paragraphs(items)
71
+ def paragraphs(items, style: {})
46
72
  items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
47
73
  next run.first if run.first.is_a?(Image)
48
74
 
49
75
  inlines = merge(block_given? ? yield(run) : run)
50
- Paragraph.new(inlines:) unless inlines.empty?
76
+ Paragraph.new(inlines:, style:) unless inlines.empty?
51
77
  end
52
78
  end
53
79
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Rich
5
+ class Renderer
6
+ # What a block's CSS style becomes around the block: margins as spacers,
7
+ # a background and padding as a box, page-break rules as page breaks and
8
+ # kept-together groups, borders and widths as table options.
9
+ module Boxes
10
+ PADDING = { padding_top: 0, padding_right: 1, padding_bottom: 2, padding_left: 3 }.freeze
11
+
12
+ private
13
+
14
+ # Draws the block inside its margins and its box. A block styled with
15
+ # nothing but an alignment draws as it is.
16
+ def styled(style, &)
17
+ return yield if style.empty?
18
+
19
+ spaced(style) { boxed(style, &) }
20
+ end
21
+
22
+ # Page breaks and margins around the block, and the rule that keeps it
23
+ # on one page.
24
+ def spaced(style)
25
+ return yield unless spacing?(style)
26
+
27
+ top, bottom = margins(style)
28
+ @component.page_break if style[:break_before]
29
+ @component.group(keep_together: style[:keep_together] == true) do
30
+ @component.spacer(top) if top.positive?
31
+ yield
32
+ @component.spacer(bottom) if bottom.positive?
33
+ end
34
+ @component.page_break if style[:break_after]
35
+ end
36
+
37
+ def spacing?(style)
38
+ style[:break_before] || style[:break_after] || style[:keep_together] || margins(style).any?(&:positive?)
39
+ end
40
+
41
+ def margins(style)
42
+ all = style[:margin]
43
+ [style[:margin_top] || all&.[](0) || 0, style[:margin_bottom] || all&.[](2) || 0]
44
+ end
45
+
46
+ def boxed(style, &)
47
+ options = box_options(style)
48
+ options.empty? ? yield : @component.box(**options, &)
49
+ end
50
+
51
+ # The background and padding a style gives a box, to merge over a
52
+ # block's own (`styles[:blockquote]`, `styles[:pre]`).
53
+ def box_options(style)
54
+ options = {}
55
+ options[:background] = style[:background] if style[:background]
56
+ padding = padding_of(style)
57
+ options[:padding] = padding if padding
58
+ options
59
+ end
60
+
61
+ def padding_of(style)
62
+ sides = PADDING.select { |key, _| style[key] }
63
+ return style[:padding] if sides.empty?
64
+
65
+ padding = (style[:padding] || [0, 0, 0, 0]).dup
66
+ sides.each { |key, index| padding[index] = style[key] }
67
+ padding
68
+ end
69
+
70
+ def aligned(style) = style[:align] ? { align: style[:align] } : {}
71
+
72
+ def border_options(border)
73
+ return {} unless border
74
+ return { borders: [] } if border[:width].zero?
75
+
76
+ { border_width: border[:width], border_color: border[:color] }
77
+ end
78
+
79
+ def cell_options(style)
80
+ options = border_options(style[:border]).merge(box_options(style))
81
+ options[:borders] = %i[top right bottom left] if options[:border_width]
82
+ options
83
+ end
84
+
85
+ # Column widths from the first row, when every cell of it has one. A
86
+ # percentage is a share of the table, which then takes the full width
87
+ # unless it has a width of its own.
88
+ def column_widths(table)
89
+ widths = table.rows.first.map { |cell| cell.style[:width] }
90
+ return {} unless widths.all?
91
+
92
+ widths.any? { |width| fraction?(width) } && !table.style[:width] ? { widths:, width: :full } : { widths: }
93
+ end
94
+
95
+ # A table's own width: points, the full width for 100%, or a share of
96
+ # the space through a box of that width.
97
+ def sized(width)
98
+ return yield({}) unless width
99
+ return yield({ width: }) unless fraction?(width)
100
+ return yield({ width: :full }) if width >= 1
101
+
102
+ @component.box(width:) { yield({ width: :full }) }
103
+ end
104
+
105
+ def fraction?(value) = value.is_a?(Float) && value <= 1
106
+ end
107
+ end
108
+ end
109
+ end
@@ -30,6 +30,7 @@ module Stationery
30
30
  blocks.map do |block|
31
31
  case block
32
32
  when Blockquote then 1 + indents_of(block.blocks)
33
+ when Container then indents_of(block.blocks)
33
34
  when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
34
35
  when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
35
36
  else 0
@@ -15,8 +15,12 @@ module Stationery
15
15
  font: ->(value) { [:font, value] if value }
16
16
  }.freeze
17
17
 
18
+ # A true mark switches the style on, a false one (from CSS) switches it off again.
18
19
  MARK_STEPS = {
19
- bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
20
+ bold: { true => [[:b]], false => [[:with, { weight: :regular }]] },
21
+ italic: { true => [[:i]], false => [[:with, { style: :normal }]] },
22
+ underline: { true => [[:u]], false => [[:with, { underline: false }]] },
23
+ strike: { true => [[:strikethrough]], false => [[:with, { strikethrough: false }]] }
20
24
  }.freeze
21
25
 
22
26
  def initialize(runs, styles, links = Links.new(:all, nil))
@@ -50,7 +54,10 @@ module Stationery
50
54
  when :link then [[:link, value], *style_steps(@styles[:a])]
51
55
  when :code then style_steps(@styles[:code])
52
56
  when :script then [[value]]
53
- else MARK_STEPS.fetch(mark, [])
57
+ when :color then [[:color, value]]
58
+ when :size then [[:size, value]]
59
+ when :scale then [[:scale, value]]
60
+ else MARK_STEPS.dig(mark, value) || []
54
61
  end
55
62
  end
56
63
 
@@ -5,6 +5,7 @@ require_relative "styles"
5
5
  require_relative "renderer/indents"
6
6
  require_relative "renderer/inlines"
7
7
  require_relative "renderer/links"
8
+ require_relative "renderer/boxes"
8
9
 
9
10
  module Stationery
10
11
  module Rich
@@ -12,9 +13,12 @@ module Stationery
12
13
  # `images:` (a callable given the src, returning a path, an IO or nil) or
13
14
  # from files under `base_path:`; remote URLs are never fetched. Block
14
15
  # quotes and lists nested deeper than Indents::LIMIT stop indenting, which
15
- # is reported as a NestingLimit warning.
16
+ # is reported as a NestingLimit warning. A block's `style` (from the
17
+ # source's CSS) adds margins, a box, an alignment or a page break around
18
+ # what `styles:` draws; a block without one draws exactly as before.
16
19
  class Renderer
17
20
  include Indents
21
+ include Boxes
18
22
 
19
23
  REMOTE = /\A[a-z][a-z0-9+.-]*:/i
20
24
  LINK_SCHEMES = %w[http https mailto tel].freeze
@@ -43,14 +47,16 @@ module Stationery
43
47
 
44
48
  def block(block)
45
49
  case block
46
- when Paragraph then paragraph(block.inlines, **@styles[:p])
47
- when Heading then heading(block)
48
- when List then indented(block) { list(block) }
49
- when Blockquote then indented(block) { blockquote(block) }
50
- when CodeBlock then code(block)
50
+ when Paragraph then styled(block.style) { paragraph(block.inlines, **@styles[:p], **aligned(block.style)) }
51
+ when Heading then styled(block.style) { heading(block) }
52
+ when List then styled(block.style) { indented(block) { list(block) } }
53
+ when Blockquote then spaced(block.style) { indented(block) { blockquote(block) } }
54
+ when CodeBlock then spaced(block.style) { code(block) }
51
55
  when Rule then @component.rule(**@styles[:hr])
52
- when Table then table(block)
56
+ when Table then styled(block.style.except(:border, :width)) { table(block) }
53
57
  when Image then image(block)
58
+ when Container then styled(block.style) { group(block.blocks) }
59
+ when PageBreak then @component.page_break
54
60
  end
55
61
  end
56
62
 
@@ -62,7 +68,8 @@ module Stationery
62
68
  style = @styles[:"h#{heading.level}"]
63
69
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
64
70
  bookmark = heading_bookmark(heading)
65
- paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
71
+ paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level,
72
+ **aligned(heading.style), **({ bookmark: } if bookmark))
66
73
  end
67
74
 
68
75
  def heading_bookmark(heading)
@@ -72,7 +79,7 @@ module Stationery
72
79
  end
73
80
 
74
81
  def blockquote(quote)
75
- @component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
82
+ @component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
76
83
  end
77
84
 
78
85
  def list(list)
@@ -86,18 +93,23 @@ module Stationery
86
93
 
87
94
  def code(block)
88
95
  font = @styles[:code][:font]
89
- @component.box(**@styles[:pre]) { @component.text(block.text, **({ font: } if font)) }
96
+ @component.box(**@styles[:pre], **box_options(block.style)) do
97
+ @component.text(block.text, **({ font: } if font))
98
+ end
90
99
  end
91
100
 
92
101
  def table(table)
93
102
  header = table.rows.first&.all?(&:header) ? 1 : 0
94
103
  rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
95
- @component.table(rows, header:, cell: @styles[:table][:cell])
104
+ cell = @styles[:table][:cell].merge(border_options(table.style[:border]))
105
+ sized(table.style[:width]) { |width| @component.table(rows, header:, cell:, **width, **column_widths(table)) }
96
106
  end
97
107
 
98
108
  def cell_content(cell)
99
109
  style = cell.header ? @styles[:table][:header] : {}
100
- -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
110
+ content = -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
111
+ options = cell_options(cell.style)
112
+ options.empty? ? content : { content:, **options }
101
113
  end
102
114
 
103
115
  def image(node)
@@ -105,7 +117,7 @@ module Stationery
105
117
  return unless source
106
118
 
107
119
  loaded = Images.load(source)
108
- @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
120
+ @component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
109
121
  rescue UnsupportedImage => e
110
122
  skip(node.src, e.message)
111
123
  end
@@ -121,9 +133,14 @@ module Stationery
121
133
  File.file?(path) ? path : skip(src, "not found")
122
134
  end
123
135
 
136
+ # A CSS width wins over the width and height attributes (the height
137
+ # then follows the aspect ratio); a percentage is a share of the space.
124
138
  def dimensions(node, loaded)
125
- width = node.width
126
- height = node.height
139
+ styled = node.style[:width]
140
+ return { width: styled } if fraction?(styled)
141
+
142
+ width = styled || node.width
143
+ height = styled ? nil : node.height
127
144
  limit = @styles[:img][:max_width]
128
145
  natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
129
146
  return { width:, height: } unless limit && natural > limit
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Stationery
4
4
  module SVG
5
+ Selector = CSS::Selector
6
+ Stylesheet = CSS::Stylesheet
7
+
5
8
  # Presentation attributes an element inherits from its ancestors, and the
6
9
  # transform it draws with. An element's own values cascade: presentation
7
10
  # attributes, then stylesheet rules, then its inline `style`. `color` sets
@@ -27,12 +30,7 @@ module Stationery
27
30
  NAMED.fetch(value.downcase, value)
28
31
  end
29
32
 
30
- def self.declarations(style)
31
- style.to_s.split(";").filter_map do |declaration|
32
- key, value = declaration.split(":", 2).map(&:strip)
33
- [key, value] if key && value
34
- end.to_h
35
- end
33
+ def self.declarations(style) = CSS.declarations(style)
36
34
 
37
35
  attr_reader :values, :matrix, :color
38
36
 
@@ -12,6 +12,8 @@ module Stationery
12
12
  # Needs the pdf-reader gem, loaded on first use.
13
13
  class Inspector
14
14
  UTF16_BOM = "\xFE\xFF".b
15
+ # id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
16
+ TIMESTAMP_TOKEN = "1.2.840.113549.1.9.16.2.14"
15
17
  XMP_PROPERTY = %r{^\s*<((?!rdf:)[\w.-]+:[\w.-]+)>(.*?)</\1>$}
16
18
  XMP_ITEM = %r{<rdf:li[^>]*>(.*?)</rdf:li>}
17
19
  XMP_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"' }.freeze
@@ -134,10 +136,13 @@ module Stationery
134
136
  end
135
137
 
136
138
  # Every signed signature field: `{ field:, name:, reason:, location:,
137
- # signed_at:, subfilter:, byte_range:, signer:, valid: }`. `signer` is
138
- # the signing certificate's subject ("CN=…,O=…"). `valid` is whether
139
- # the signature covers the whole file and verifies against the
139
+ # signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }`.
140
+ # `signer` is the signing certificate's subject ("CN=…,O=…"). `valid` is
141
+ # whether the signature covers the whole file and verifies against the
140
142
  # certificate it carries; the certificate's own trust is not judged.
143
+ # `timestamp` is nil, or `{ time:, tsa:, valid: }` for the RFC 3161 token
144
+ # a signature carries: `valid` when the token is over this signature
145
+ # and verifies against the TSA certificate it carries.
141
146
  def signatures
142
147
  form = catalog[:AcroForm]
143
148
  return [] unless form
@@ -207,13 +212,51 @@ module Stationery
207
212
  # parsed dictionary: it is the one string encryption leaves alone.
208
213
  def verdict(range)
209
214
  contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
210
- cms = OpenSSL::PKCS7.new(PDF::Signature.der([contents[1..-2]].pack("H*")))
215
+ der = PDF::Signature.der([contents[1..-2]].pack("H*"))
216
+ cms = OpenSSL::PKCS7.new(der)
211
217
  signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
212
218
  flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
213
219
  whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
214
- { signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags) }
220
+ { signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags),
221
+ timestamp: timestamp(der) }
215
222
  rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
216
- { signer: nil, valid: false }
223
+ { signer: nil, valid: false, timestamp: nil }
224
+ end
225
+
226
+ # The timestamp token among the first SignerInfo's unsigned attributes,
227
+ # judged against that SignerInfo's signature value; nil without one.
228
+ def timestamp(der)
229
+ info = OpenSSL::ASN1.decode(der).value[1].value[0].value[4].value[0].value
230
+ unsigned = info[6..].to_a.find { |item| item.tag == 1 && item.tag_class == :CONTEXT_SPECIFIC }
231
+ attribute = unsigned&.value&.find { |one| one.value[0].oid == TIMESTAMP_TOKEN }
232
+ attribute && timestamp_verdict(attribute.value[1].value.first, info[5].value)
233
+ end
234
+
235
+ # The token verified as a TimeStampResp would be, against the signature
236
+ # value it should cover. The certificates it carries are the trust
237
+ # anchors (a TSA sends its chain, often without the root), so this
238
+ # says the token is sound, not that the TSA is one to trust.
239
+ def timestamp_verdict(token, signature)
240
+ granted = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(0)])
241
+ response = OpenSSL::Timestamp::Response.new(OpenSSL::ASN1::Sequence.new([granted, token]).to_der)
242
+ info = response.token_info
243
+ covers = info.message_imprint == OpenSSL::Digest.digest(info.algorithm, signature)
244
+ { time: info.gen_time, tsa: signer(response.token), valid: covers && timestamp_verifies?(response) }
245
+ end
246
+
247
+ def timestamp_verifies?(response)
248
+ info = response.token_info
249
+ request = OpenSSL::Timestamp::Request.new
250
+ request.algorithm = info.algorithm
251
+ request.message_imprint = info.message_imprint
252
+ request.nonce = info.nonce if info.nonce
253
+ store = OpenSSL::X509::Store.new
254
+ store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
255
+ response.token.certificates.to_a.each { |certificate| store.add_cert(certificate) }
256
+ response.verify(request, store)
257
+ true
258
+ rescue OpenSSL::OpenSSLError
259
+ false
217
260
  end
218
261
 
219
262
  def signer(cms)
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # Where a line may break inside a word: between two ideographic
6
+ # characters (UAX #14 class ID: CJK ideographs, kana, Hangul, fullwidth
7
+ # forms), never before a closing mark or a character that must not start
8
+ # a line (、。」ー and the small kana: classes CL, CP, EX and NS), never
9
+ # after an opening bracket (「(: class OP). A word is cut into units at
10
+ # those points; Latin text stays one unit, so its breaking is unchanged.
11
+ module Breaks
12
+ ZERO_WIDTH_SPACE = "​"
13
+
14
+ IDEOGRAPHIC = [
15
+ 0x1100..0x11FF, # Hangul Jamo
16
+ 0x2E80..0x2FDF, # CJK radicals
17
+ 0x3005..0x3007, # 々 〆 〇
18
+ 0x3021..0x3029, # Hangzhou numerals
19
+ 0x3031..0x3035, # kana repeat marks
20
+ 0x3038..0x303C,
21
+ 0x3041..0x309A, # Hiragana (the small kana are taken out below)
22
+ 0x30A1..0x30FA, # Katakana
23
+ 0x3100..0x312F, # Bopomofo
24
+ 0x3130..0x318F, # Hangul compatibility Jamo
25
+ 0x3190..0x31FF, # Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
26
+ 0x3200..0x33FF, # enclosed CJK, CJK compatibility
27
+ 0x3400..0x4DBF, # CJK extension A
28
+ 0x4E00..0x9FFF, # CJK unified ideographs
29
+ 0xA000..0xA4CF, # Yi
30
+ 0xA960..0xA97F, # Hangul Jamo extended A
31
+ 0xAC00..0xD7AF, # Hangul syllables
32
+ 0xF900..0xFAFF, # CJK compatibility ideographs
33
+ 0xFE30..0xFE4F, # CJK compatibility forms
34
+ 0xFF10..0xFF19, # fullwidth digits
35
+ 0xFF21..0xFF3A, 0xFF41..0xFF5A, # fullwidth Latin
36
+ 0xFF66..0xFF6F, 0xFF71..0xFF9D, # halfwidth Katakana (ー and the small ones are NS)
37
+ 0x1B000..0x1B16F, # Kana supplement and extended
38
+ 0x20000..0x3134F # CJK extensions B–G
39
+ ].freeze
40
+
41
+ # Never at the start of a line: closing brackets and quotes, sentence
42
+ # and clause punctuation, prolonged sound and iteration marks, small kana.
43
+ CLOSING = [
44
+ 0x3001..0x3003, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, 0x3015, 0x3017, 0x3019, 0x301B, 0x301C,
45
+ 0x301F, 0x303B, 0x309B..0x309E, 0x30A0, 0x30FB..0x30FE,
46
+ 0x3041, 0x3043, 0x3045, 0x3047, 0x3049, 0x3063, 0x3083, 0x3085, 0x3087, 0x308E, 0x3095, 0x3096,
47
+ 0x30A1, 0x30A3, 0x30A5, 0x30A7, 0x30A9, 0x30C3, 0x30E3, 0x30E5, 0x30E7, 0x30EE, 0x30F5, 0x30F6,
48
+ 0x31F0..0x31FF,
49
+ 0xFE50..0xFE52, 0xFE54..0xFE57, 0xFE5A, 0xFE5C, 0xFE5E,
50
+ 0xFF01, 0xFF09, 0xFF0C, 0xFF0E, 0xFF1A, 0xFF1B, 0xFF1F, 0xFF3D, 0xFF5D, 0xFF60, 0xFF61, 0xFF63,
51
+ 0xFF64, 0xFF65, 0xFF67..0xFF6F, 0xFF70, 0xFF9E, 0xFF9F,
52
+ 0x2025, 0x2026
53
+ ].freeze
54
+
55
+ # Never at the end of a line: opening brackets and quotes.
56
+ OPENING = [
57
+ 0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x301D,
58
+ 0xFE59, 0xFE5B, 0xFE5D,
59
+ 0xFF08, 0xFF3B, 0xFF5B, 0xFF5F, 0xFF62
60
+ ].freeze
61
+
62
+ CLOSING_CODES = CLOSING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
63
+ OPENING_CODES = OPENING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
64
+
65
+ module_function
66
+
67
+ def ideographic?(char)
68
+ code = char.ord
69
+ !CLOSING_CODES.key?(code) && IDEOGRAPHIC.any? { |range| range.cover?(code) }
70
+ end
71
+
72
+ def closing?(char) = CLOSING_CODES.key?(char.ord)
73
+ def opening?(char) = OPENING_CODES.key?(char.ord)
74
+
75
+ # Whether `text` holds any character these rules know about, the cheap
76
+ # test that keeps Latin text on the fast path.
77
+ def cjk?(text) = text.match?(CJK)
78
+
79
+ CJK = /[ᄀ-ᇿ⺀-⿟、-ヿ㄀-鿿ꀀ-꓏ꥠ-꥿가-힯豈-﫿︰-﹞!-゚\u{1B000}-\u{1B16F}\u{20000}-\u{3134F}‥…]/
80
+
81
+ # The word's break units in order, each `[text, glued]`: `glued` is true
82
+ # for a unit that must stay with the unit before it (it starts with a
83
+ # closing mark), so the caller joins it to whatever came before, even
84
+ # across styled runs. A word without CJK characters is one unit.
85
+ def units(word)
86
+ return [[word, false]] unless cjk?(word)
87
+
88
+ units = []
89
+ current = +""
90
+ glued = false
91
+ open = false # current holds opening brackets only, waiting for what they open
92
+ word.each_char do |char|
93
+ if closing?(char)
94
+ glued = true if current.empty?
95
+ elsif starts_unit?(char, current, open)
96
+ units << [current.freeze, glued] unless current.empty?
97
+ current = +""
98
+ glued = false
99
+ end
100
+ current << char
101
+ open = opening?(char) && (open || current.length == 1)
102
+ end
103
+ units << [current.freeze, glued] unless current.empty?
104
+ units
105
+ end
106
+
107
+ # Whether `char` opens a new unit after `current`: an opening bracket or
108
+ # an ideographic character does unless `current` is only opening
109
+ # brackets; Latin text does after a unit that ends in a CJK character
110
+ # (an opening bracket or Latin followed by a closing mark stay glued).
111
+ def starts_unit?(char, current, open)
112
+ return false if current.empty? || open
113
+ return true if opening?(char) || ideographic?(char)
114
+
115
+ cjk?(current[-1]) && !(closing?(current[-1]) && !ideographic_unit?(current))
116
+ end
117
+
118
+ # A unit that carries an ideographic character (as opposed to Latin text
119
+ # followed by a closing mark, such as "abc。").
120
+ def ideographic_unit?(text) = text.each_char.any? { |char| ideographic?(char) }
121
+ end
122
+ end
123
+ end
@@ -34,6 +34,14 @@ module Stationery
34
34
  [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
35
35
  end
36
36
 
37
+ # [first `count` lines, the rest]; nil for an empty side.
38
+ def split_at(count)
39
+ return [nil, self] if count <= 0
40
+ return [self, nil] if count >= @lines.size
41
+
42
+ [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
43
+ end
44
+
37
45
  # A paragraph that fits `max_height`: truncated to whole lines, shrunk
38
46
  # (never below 4pt) or left as is for :visible.
39
47
  def fit(max_height, overflow:)
@@ -46,6 +46,10 @@ module Stationery
46
46
  def size(value, text = nil, &) = styled({ size: value }, text, &)
47
47
  def font(family, text = nil, &) = styled({ family: family.to_s }, text, &)
48
48
  def link(url, text = nil, &) = styled({ link: url.to_s }, text, &)
49
+ # The current size multiplied: `scale(1.5) { … }` is one and a half times the text around it.
50
+ def scale(factor, text = nil, &) = styled({ size: @styles.last.size * factor }, text, &)
51
+ # Any style fields at once, also to switch a mark off: `with({ weight: :regular }) { … }`.
52
+ def with(overrides, text = nil, &) = styled(overrides, text, &)
49
53
 
50
54
  private
51
55