pdfrb 0.7.1 → 0.7.11

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/RELEASE.md +17 -11
  4. data/data/pdfrb/layout/hyphenation_en.txt +408 -0
  5. data/lib/pdfrb/color/default_profile.rb +213 -0
  6. data/lib/pdfrb/color/icc_validator.rb +69 -0
  7. data/lib/pdfrb/color.rb +2 -0
  8. data/lib/pdfrb/conformance/ltv.rb +112 -0
  9. data/lib/pdfrb/conformance/pades.rb +198 -0
  10. data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
  11. data/lib/pdfrb/conformance/pdf_a.rb +123 -0
  12. data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
  13. data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
  14. data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
  15. data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
  16. data/lib/pdfrb/conformance/pdf_x.rb +66 -1
  17. data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
  18. data/lib/pdfrb/conformance.rb +8 -0
  19. data/lib/pdfrb/content/parser.rb +82 -0
  20. data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
  21. data/lib/pdfrb/digital_signature.rb +1 -0
  22. data/lib/pdfrb/document.rb +29 -0
  23. data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
  24. data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
  25. data/lib/pdfrb/encryption/v5_writer.rb +108 -0
  26. data/lib/pdfrb/encryption.rb +3 -0
  27. data/lib/pdfrb/font_loader/type3.rb +65 -0
  28. data/lib/pdfrb/font_loader.rb +1 -0
  29. data/lib/pdfrb/image_loader/gif.rb +246 -0
  30. data/lib/pdfrb/image_loader/tiff.rb +257 -0
  31. data/lib/pdfrb/image_loader.rb +2 -0
  32. data/lib/pdfrb/layout/font_fallback.rb +203 -0
  33. data/lib/pdfrb/layout/hyphenation.rb +120 -0
  34. data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
  35. data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
  36. data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
  37. data/lib/pdfrb/layout/polygon_frame.rb +106 -0
  38. data/lib/pdfrb/layout/table_box.rb +154 -23
  39. data/lib/pdfrb/layout/text_shaper.rb +129 -0
  40. data/lib/pdfrb/layout.rb +7 -0
  41. data/lib/pdfrb/source/linearization_reader.rb +76 -0
  42. data/lib/pdfrb/source/recovery.rb +65 -1
  43. data/lib/pdfrb/source/tokenizer.rb +21 -0
  44. data/lib/pdfrb/source.rb +1 -0
  45. data/lib/pdfrb/task/thumbnail.rb +133 -0
  46. data/lib/pdfrb/task.rb +1 -0
  47. data/lib/pdfrb/version.rb +1 -1
  48. metadata +28 -2
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Glyph-level fallback: given a primary font and a chain of
6
+ # fallback fonts (default CJK, Symbol, etc.), picks the first
7
+ # font that can render the missing glyph. Returns the chosen
8
+ # font name or nil if none can render.
9
+ #
10
+ # Coverage lookup uses the Standard 14 AFM glyph-name sets for
11
+ # built-in fonts (Helvetica, Times-Roman, Courier, Symbol,
12
+ # ZapfDingbats), and falls back to WinAnsi range coverage for
13
+ # any other font.
14
+ class FontFallback
15
+ DEFAULT_CHAIN = %w[Helvetica Times-Roman Courier Symbol].freeze
16
+
17
+ # Common Standard 14 AFM character-name sets, indexed by font
18
+ # name. Each is a Set of glyph names the font defines. A
19
+ # codepoint is "covered" if its Unicode-to-glyph-name lookup
20
+ # yields a name in the set. Subset of full AFM data — covers
21
+ # the Latin, Greek, and punctuation ranges needed for typical
22
+ # prose.
23
+ STANDARD14_GLYPH_NAMES = {
24
+ "Helvetica" => %w[
25
+ space exclam quotedbl numbersign dollar percent ampersand
26
+ quoteright parenleft parenright asterisk plus comma minus
27
+ period slash zero one two three four five six seven eight
28
+ nine colon semicolon less equal greater question at A B C D
29
+ E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
30
+ backslash bracketright asciicircum underscore quoteleft a b
31
+ c d e f g h i j k l m n o p q r s t u v w x y z braceleft
32
+ bar braceright asciitilde
33
+ Agrave Aacute Acircumflex Atilde Adieresis Aring AE Ccedilla
34
+ Egrave Eacute Ecircumflex Edieresis Igrave Iacute Icircumflex
35
+ Idieresis Eth Ntilde Ograve Oacute Ocircumflex Otilde Odieresis
36
+ multiply Oslash Ugrave Uacute Ucircumflex Udieresis Yacute
37
+ Thorn germandbls agrave aacute acircumflex atilde adieresis
38
+ aring ae ccedilla egrave eacute ecircumflex edieresis igrave
39
+ iacute icircumflex idieresis eth ntilde ograve oacute
40
+ ocircumflex otilde odieresis divide oslash ugrave uacute
41
+ ucircumflex udieresis yacute thorn ydieresis
42
+ ].to_set.freeze,
43
+ "Times-Roman" => %w[
44
+ space exclam quotedbl numbersign dollar percent ampersand
45
+ quoteright parenleft parenright asterisk plus comma minus
46
+ period slash zero one two three four five six seven eight
47
+ nine colon semicolon less equal greater question at A B C D
48
+ E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
49
+ backslash bracketright asciicircum underscore quoteleft a b
50
+ c d e f g h i j k l m n o p q r s t u v w x y z braceleft
51
+ bar braceright asciitilde
52
+ ].to_set.freeze,
53
+ "Courier" => %w[
54
+ space exclam quotedbl numbersign dollar percent ampersand
55
+ quoteright parenleft parenright asterisk plus comma minus
56
+ period slash zero one two three four five six seven eight
57
+ nine colon semicolon less equal greater question at A B C D
58
+ E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
59
+ backslash bracketright asciicircum underscore quoteleft a b
60
+ c d e f g h i j k l m n o p q r s t u v w x y z braceleft
61
+ bar braceright asciitilde
62
+ ].to_set.freeze,
63
+ "Symbol" => %w[
64
+ Alpha Beta Chi Delta Epsilon Phi Gamma Eta Iota Theta Kappa
65
+ Lambda Mu Nu Omicron Pi Rho Sigma Tau Upsilon Omega Xi Psi Zeta
66
+ alpha beta chi delta epsilon phi gamma eta iota theta kappa
67
+ lambda mu nu omicron pi rho sigma tau upsilon omega xi psi zeta
68
+ ].to_set.freeze,
69
+ "ZapfDingbats" => %w[
70
+ a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 a16
71
+ ].to_set.freeze,
72
+ }.freeze
73
+
74
+ # Unicode → Adobe glyph name lookup (subset). The full Adobe
75
+ # Glyph List is ~2400 entries; this covers Latin + Greek
76
+ # codepoints that the Standard 14 fonts handle.
77
+ UNICODE_TO_GLYPH = {
78
+ 0x20 => "space", 0x21 => "exclam", 0x22 => "quotedbl",
79
+ 0x23 => "numbersign", 0x24 => "dollar", 0x25 => "percent",
80
+ 0x26 => "ampersand", 0x27 => "quoteright", 0x28 => "parenleft",
81
+ 0x29 => "parenright", 0x2A => "asterisk", 0x2B => "plus",
82
+ 0x2C => "comma", 0x2D => "minus", 0x2E => "period", 0x2F => "slash",
83
+ 0xC0 => "Agrave", 0xC1 => "Aacute", 0xC2 => "Acircumflex",
84
+ 0xC3 => "Atilde", 0xC4 => "Adieresis", 0xC5 => "Aring",
85
+ 0xC6 => "AE", 0xC7 => "Ccedilla", 0xC8 => "Egrave",
86
+ 0xC9 => "Eacute", 0xCA => "Ecircumflex", 0xCB => "Edieresis",
87
+ 0xCC => "Igrave", 0xCD => "Iacute", 0xCE => "Icircumflex",
88
+ 0xCF => "Idieresis", 0xD0 => "Eth", 0xD1 => "Ntilde",
89
+ 0xD2 => "Ograve", 0xD3 => "Oacute", 0xD4 => "Ocircumflex",
90
+ 0xD5 => "Otilde", 0xD6 => "Odieresis", 0xD8 => "Oslash",
91
+ 0xD9 => "Ugrave", 0xDA => "Uacute", 0xDB => "Ucircumflex",
92
+ 0xDC => "Udieresis", 0xDD => "Yacute", 0xDE => "Thorn",
93
+ 0xDF => "germandbls",
94
+ 0xE0 => "agrave", 0xE1 => "aacute", 0xE2 => "acircumflex",
95
+ 0xE3 => "atilde", 0xE4 => "adieresis", 0xE5 => "aring",
96
+ 0xE6 => "ae", 0xE7 => "ccedilla", 0xE8 => "egrave",
97
+ 0xE9 => "eacute", 0xEA => "ecircumflex", 0xEB => "edieresis",
98
+ 0xEC => "igrave", 0xED => "iacute", 0xEE => "icircumflex",
99
+ 0xEF => "idieresis", 0xF0 => "eth", 0xF1 => "ntilde",
100
+ 0xF2 => "ograve", 0xF3 => "oacute", 0xF4 => "ocircumflex",
101
+ 0xF5 => "otilde", 0xF6 => "odieresis", 0xF8 => "oslash",
102
+ 0xF9 => "ugrave", 0xFA => "uacute", 0xFB => "ucircumflex",
103
+ 0xFC => "udieresis", 0xFD => "yacute", 0xFE => "thorn",
104
+ 0xFF => "ydieresis",
105
+ 0x391 => "Alpha", 0x392 => "Beta", 0x393 => "Gamma",
106
+ 0x394 => "Delta", 0x395 => "Epsilon", 0x396 => "Zeta",
107
+ 0x397 => "Eta", 0x398 => "Theta", 0x399 => "Iota",
108
+ 0x39A => "Kappa", 0x39B => "Lambda", 0x39C => "Mu",
109
+ 0x39D => "Nu", 0x39E => "Xi", 0x39F => "Omicron",
110
+ 0x3A0 => "Pi", 0x3A1 => "Rho", 0x3A3 => "Sigma",
111
+ 0x3A4 => "Tau", 0x3A5 => "Upsilon", 0x3A6 => "Phi",
112
+ 0x3A7 => "Chi", 0x3A8 => "Psi", 0x3A9 => "Omega",
113
+ 0x3B1 => "alpha", 0x3B2 => "beta", 0x3B3 => "gamma",
114
+ 0x3B4 => "delta", 0x3B5 => "epsilon", 0x3B6 => "zeta",
115
+ 0x3B7 => "eta", 0x3B8 => "theta", 0x3B9 => "iota",
116
+ 0x3BA => "kappa", 0x3BB => "lambda", 0x3BC => "mu",
117
+ 0x3BD => "nu", 0x3BE => "xi", 0x3BF => "omicron",
118
+ 0x3C0 => "pi", 0x3C1 => "rho", 0x3C3 => "sigma",
119
+ 0x3C4 => "tau", 0x3C5 => "upsilon", 0x3C6 => "phi",
120
+ 0x3C7 => "chi", 0x3C8 => "psi", 0x3C9 => "omega"
121
+ }.freeze
122
+
123
+ attr_reader :chain
124
+
125
+ # @param chain [Array<String>] ordered list of font names to
126
+ # try when the primary can't render a glyph.
127
+ def initialize(chain: DEFAULT_CHAIN)
128
+ @chain = chain
129
+ end
130
+
131
+ # Find a font in the chain that covers +codepoint+.
132
+ # @return [String, nil] font name, or nil if none match.
133
+ def pick(codepoint, primary: nil)
134
+ return primary if primary && covers?(primary, codepoint)
135
+
136
+ @chain.find { |name| covers?(name, codepoint) }
137
+ end
138
+
139
+ # Reshape +text+ into segments, each tagged with the font that
140
+ # renders it. Returns an Array of [font_name, substring] pairs.
141
+ # Adjacent segments that share the same font are merged.
142
+ def segment(text, primary: nil)
143
+ return [[primary, text]] if text.to_s.empty?
144
+
145
+ segments = []
146
+ current_font = nil
147
+ current_chars = +""
148
+
149
+ text.to_s.each_codepoint do |cp|
150
+ ch = cp.chr(Encoding::UTF_8)
151
+ font = pick(cp, primary: primary)
152
+ if font == current_font
153
+ current_chars << ch
154
+ else
155
+ segments << [current_font, current_chars] if current_chars.length.positive?
156
+ current_font = font
157
+ current_chars = ch.to_s
158
+ end
159
+ end
160
+ segments << [current_font, current_chars] if current_chars.length.positive?
161
+ segments
162
+ end
163
+
164
+ # Whether +font_name+ can render +codepoint+. Uses the
165
+ # Standard 14 glyph-name sets for built-in fonts; for
166
+ # non-standard fonts, assumes WinAnsi coverage up to 255.
167
+ def covers?(font_name, codepoint)
168
+ return true if codepoint < 128 # ASCII always covered
169
+
170
+ standard14 = STANDARD14_GLYPH_NAMES[font_name.to_s]
171
+ if standard14
172
+ glyph = glyph_name_for(codepoint)
173
+ return !!glyph && standard14.include?(glyph)
174
+ end
175
+
176
+ winansi?(codepoint)
177
+ end
178
+
179
+ private
180
+
181
+ def glyph_name_for(codepoint)
182
+ # Single-codepoint entries in UNICODE_TO_GLYPH.
183
+ return UNICODE_TO_GLYPH[codepoint] if UNICODE_TO_GLYPH.key?(codepoint)
184
+
185
+ # Ranges that map to ASCII letters/digits — all share the
186
+ # ASCII character as glyph name. Grouped to avoid rubocop
187
+ # duplicate-branch warnings.
188
+ case codepoint
189
+ when 0x30..0x39, 0x41..0x5A, 0x61..0x7A then codepoint.chr
190
+ end
191
+ end
192
+
193
+ def winansi?(codepoint)
194
+ return true if codepoint < 0x80
195
+ return true if (0xA0..0xFF).cover?(codepoint)
196
+ return true if [0x20AC, 0x201A, 0x0192, 0x201E, 0x2026,
197
+ 0x2020, 0x2021, 0x02C6, 0x2030].include?(codepoint)
198
+
199
+ false
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Knuth-Liang hyphenation algorithm, pure Ruby. Uses a small
6
+ # built-in pattern set covering common English patterns; callers
7
+ # can supply their own pattern list for other languages.
8
+ #
9
+ # Patterns come from TeX's .pat files. Each pattern has letters
10
+ # and digits where the digits encode the hyphenation weights
11
+ # (0=never, 1-9=allowed). Example: ".abc1de" means "before 'd'
12
+ # in 'abcde' you may hyphenate".
13
+ #
14
+ # The full English pattern set is ~62 KB. We ship a curated
15
+ # subset; users who need full coverage can pass their own
16
+ # +patterns:+ at construction.
17
+ module Hyphenation
18
+ # Curated subset covering common English word endings and
19
+ # prefixes. Not exhaustive but useful for plain prose. Numbers
20
+ # are weights; a weight of 1 means "may hyphenate here".
21
+ #
22
+ # Loaded from data/pdfrb/layout/hyphenation_en.txt so the
23
+ # patterns stay reviewable; falls back to an empty list if the
24
+ # data file is missing.
25
+ PATTERNS_EN = begin
26
+ path = File.expand_path("../../../data/pdfrb/layout/hyphenation_en.txt", __dir__)
27
+ File.exist?(path) ? File.readlines(path, chomp: true).reject(&:empty?) : []
28
+ rescue StandardError
29
+ []
30
+ end.freeze
31
+
32
+ module_function
33
+
34
+ # @param word [String] the word to hyphenate.
35
+ # @param patterns [Array<String>] Knuth-Liang patterns.
36
+ # @return [Array<Integer>] positions in +word+ where a hyphen
37
+ # may be inserted (between characters).
38
+ def hyphenate_positions(word, patterns: PATTERNS_EN)
39
+ return [] if word.length < 4
40
+
41
+ lowered = word.downcase.gsub(/[^a-z]/, "")
42
+ return [] if lowered.length < 4
43
+
44
+ weights = word_weights(lowered, patterns)
45
+ positions = []
46
+ # Odd weights between two characters mean a hyphen is allowed.
47
+ # Skip first and last character per Knuth-Liang rule.
48
+ (1...lowered.length).each do |i|
49
+ next if i < 2 || i > lowered.length - 2
50
+
51
+ positions << i if weights[i].odd?
52
+ end
53
+ positions
54
+ end
55
+
56
+ # Split +word+ into segments at valid hyphenation points.
57
+ # Includes the hyphen char (U+2010) on non-final segments when
58
+ # +with_hyphen:+ is true.
59
+ def split(word, patterns: PATTERNS_EN, with_hyphen: true)
60
+ positions = hyphenate_positions(word, patterns: patterns)
61
+ return [word] if positions.empty?
62
+
63
+ segments = []
64
+ prev = 0
65
+ positions.each do |pos|
66
+ segment = word[prev...pos] || ""
67
+ segment += "‐" if with_hyphen
68
+ segments << segment
69
+ prev = pos
70
+ end
71
+ segments << (word[prev..] || "")
72
+ segments
73
+ end
74
+
75
+ # Compute the per-character weights by overlaying every
76
+ # matching pattern. The weight at position i in the word is the
77
+ # max across all matching patterns at that inter-character slot.
78
+ def word_weights(word, patterns)
79
+ padded = ".#{word}."
80
+ weights = ::Array.new(word.length + 1, 0)
81
+ patterns.each do |pattern|
82
+ apply_pattern(weights, padded, pattern)
83
+ end
84
+ weights
85
+ end
86
+
87
+ # Decode a Knuth-Liang pattern into [letters, weights]. The
88
+ # letters are the alphabetic chars; the weights array has
89
+ # weights.length == letters.length + 1 (the slots between
90
+ # letters, including before and after).
91
+ def decode_pattern(pattern)
92
+ letters = +""
93
+ weights = [0]
94
+ pattern.each_char do |ch|
95
+ if /[0-9]/.match?(ch)
96
+ weights[-1] = [weights[-1], ch.to_i].max
97
+ else
98
+ letters << ch
99
+ weights << 0
100
+ end
101
+ end
102
+ [letters, weights]
103
+ end
104
+
105
+ def apply_pattern(weights, padded, pattern)
106
+ letters, pat_weights = decode_pattern(pattern)
107
+ start = 0
108
+ while (idx = padded.index(letters, start))
109
+ pat_weights.each_with_index do |w, i|
110
+ next if w.zero?
111
+
112
+ target = idx + i
113
+ weights[target] = [weights[target], w].max if weights[target]
114
+ end
115
+ start = idx + 1
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Arabic kashida justification: stretches Arabic letters using
6
+ # the tatweel (U+0640) character to fill a line, instead of (or
7
+ # in addition to) inter-word spacing. The technique inserts
8
+ # tatweel after selected letters (those with right-joining
9
+ # behaviour) to grow the line width toward the target.
10
+ module JustificationKashidas
11
+ # Arabic letters with right-joining behaviour (kashida can be
12
+ # inserted after these). Subset — covers the most common
13
+ # letters. Full list per Unicode Arabic Joining_Type.
14
+ RIGHT_JOINING = %w[
15
+ ب ت ث ج ح خ د ذ ر ز س ش ص ض ط ظ ع غ ف ق ك ل م ن ه و ي
16
+ ـب ـت ـث ـج ـح ـخ ـس ـش ـص ـض ـط ـظ ـع ـغ ـف ـق ـك ـل ـم ـن ـه
17
+ ].freeze
18
+
19
+ TATWEEL = "ـ"
20
+
21
+ module_function
22
+
23
+ # Insert kashidas into +text+ to grow it to +target_width+
24
+ # using +measure+ (a callable that returns the width of a
25
+ # string). Returns the modified string. If the natural width
26
+ # already meets target_width, returns text unchanged.
27
+ def justify(text, target_width:, measure:)
28
+ return text if text.to_s.empty?
29
+
30
+ natural = measure.call(text)
31
+ return text if natural >= target_width
32
+
33
+ candidates = kashida_insertion_points(text)
34
+ return text if candidates.empty?
35
+
36
+ # Greedy: add one tatweel per candidate until target reached.
37
+ result = text.to_s.dup
38
+ added = 0
39
+ loop do
40
+ break if natural >= target_width
41
+ break if added >= candidates.length * 3 # cap to avoid runaway
42
+
43
+ pos = candidates[added % candidates.length]
44
+ result = insert_at(result, pos + added, TATWEEL)
45
+ added += 1
46
+ natural = measure.call(result)
47
+ end
48
+ result
49
+ end
50
+
51
+ # Indices in +text+ where a tatweel may be inserted (after a
52
+ # right-joining letter that's not already followed by a space).
53
+ def kashida_insertion_points(text)
54
+ points = []
55
+ prev = nil
56
+ text.to_s.each_char.with_index do |ch, i|
57
+ next_char = text.to_s[i + 1]
58
+ if RIGHT_JOINING.include?(ch) && next_char &&
59
+ RIGHT_JOINING.include?(next_char)
60
+ points << (i + 1)
61
+ end
62
+ prev = ch
63
+ end
64
+ points
65
+ end
66
+
67
+ def insert_at(string, pos, char)
68
+ string[0...pos] + char + string[pos..]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Multi-cell text layout: flows text across an arbitrary shape
6
+ # defined by a series of horizontal bands. Used for magazine-
7
+ # style layouts where text wraps around images, sidebars, or
8
+ # irregular page regions.
9
+ #
10
+ # A band is a rectangular region with [x, y, width, height] in
11
+ # PDF coordinates (origin bottom-left). The layouter fills bands
12
+ # top-to-bottom, left-to-right.
13
+ class MultiCellTextLayout
14
+ attr_reader :bands, :style
15
+
16
+ def initialize(bands:, style: Style.new(:base))
17
+ @bands = bands
18
+ @style = style
19
+ end
20
+
21
+ # Lay out +text+ into the bands. Returns an Array of
22
+ # [band_index, lines] pairs where each line is a Layout::Line.
23
+ def layout(text)
24
+ layouter = TextLayouter.new(@style)
25
+ result = []
26
+ words = text.to_s.split(/(\s+)/)
27
+ current_band = 0
28
+ cursor_height = band_height(current_band)
29
+ current_chunk = +""
30
+
31
+ words.each do |word|
32
+ trial = current_chunk + word
33
+ lines = layouter.layout(trial, band_width(current_band))
34
+ total_h = lines.sum { |l| line_height(l) }
35
+
36
+ if total_h > cursor_height && current_band < @bands.length - 1
37
+ # Flush current chunk to current band
38
+ result << [current_band, layouter.layout(current_chunk, band_width(current_band))] if current_chunk.length.positive?
39
+ current_band += 1
40
+ cursor_height = band_height(current_band)
41
+ current_chunk = +word
42
+ else
43
+ current_chunk = trial
44
+ end
45
+ end
46
+ result << [current_band, layouter.layout(current_chunk, band_width(current_band))] if current_chunk.length.positive?
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ def band_width(index)
53
+ @bands[index] ? @bands[index][2].to_f : 100.0
54
+ end
55
+
56
+ def band_height(index)
57
+ @bands[index] ? @bands[index][3].to_f : 100.0
58
+ end
59
+
60
+ def line_height(_line)
61
+ @style.font_size ? @style.font_size * 1.2 : 14.0
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Multi-page table: wraps a single TableBox's worth of rows and
6
+ # splits it into per-page fragments when it doesn't fit a single
7
+ # frame. Each fragment is a TableBox whose fit? returns true for
8
+ # the available height; the splitter yields them in document
9
+ # order, optionally repeating a header row on each fragment.
10
+ #
11
+ # This is intentionally a thin coordinator: it doesn't re-layout
12
+ # cells; it just partitions rows. The actual row drawing is
13
+ # delegated to TableBox, so col/row spans continue to work — but
14
+ # spans that cross a split boundary are clamped at the split
15
+ # (their rowspan is truncated to the rows that fit).
16
+ class MultiPageTableBox < Box
17
+ DEFAULT_MIN_ROWS_PER_PAGE = 3
18
+ DEFAULT_REPEAT_HEADER = true
19
+
20
+ attr_reader :rows, :column_widths, :header_row_count, :min_rows_per_page
21
+
22
+ # @param rows [Array<Array<Pdfrb::Layout::TableBox::Cell, Box>>]
23
+ # the full table content.
24
+ # @param column_widths [Array<Numeric>, nil] explicit widths.
25
+ # @param header_row_count [Integer] number of leading rows to
26
+ # repeat at the top of each page fragment. 0 disables.
27
+ # @param min_rows_per_page [Integer] minimum number of body
28
+ # rows on each page; if the split would produce fewer, the
29
+ # fragment is deferred to the next page.
30
+ def initialize(rows:, column_widths: nil, header_row_count: 0,
31
+ min_rows_per_page: DEFAULT_MIN_ROWS_PER_PAGE, **)
32
+ super(**)
33
+ @rows = rows
34
+ @column_widths = column_widths
35
+ @header_row_count = header_row_count
36
+ @min_rows_per_page = min_rows_per_page
37
+ @fragments = nil
38
+ end
39
+
40
+ # Returns the per-page fragments as an Array of TableBox. The
41
+ # first fragment fits within +available_height+; each
42
+ # subsequent fragment fits within +page_height+ (the next page
43
+ # may have more room because there's no preceding content).
44
+ #
45
+ # Mutates internal state (the @fragments cache). Idempotent for
46
+ # the same dimensions.
47
+ def fragments(available_width:, first_page_height:, later_page_height:)
48
+ compute_column_widths(available_width)
49
+ header = @header_row_count.positive? ? @rows.first(@header_row_count) : []
50
+ body = @rows[@header_row_count..] || []
51
+
52
+ fragments = []
53
+ cursor = 0
54
+ first_pass = true
55
+ while cursor < body.length
56
+ available = first_pass ? first_page_height : later_page_height
57
+ max_count = rows_that_fit(body, cursor, available)
58
+ count = if max_count.zero? && cursor.zero?
59
+ # First fragment must contain at least one row
60
+ # even if it overflows; otherwise the table is
61
+ # undrawable.
62
+ [@min_rows_per_page, body.length].min
63
+ elsif max_count < @min_rows_per_page
64
+ # Force at least min_rows_per_page (may overflow).
65
+ [@min_rows_per_page, body.length - cursor].min
66
+ else
67
+ max_count
68
+ end
69
+ break if count.zero?
70
+
71
+ chunk = body[cursor, count]
72
+ rows_for_fragment = header + chunk
73
+ fragments << build_fragment(rows_for_fragment)
74
+ cursor += count
75
+ first_pass = false
76
+ end
77
+ fragments
78
+ end
79
+
80
+ def fit?(available_width, available_height)
81
+ @fragments = fragments(available_width: available_width,
82
+ first_page_height: available_height,
83
+ later_page_height: available_height)
84
+ @fragments.any?
85
+ end
86
+
87
+ def draw_content(canvas, x, y)
88
+ return if @fragments.nil? || @fragments.empty?
89
+
90
+ # Draw the first fragment in place; the Composer is
91
+ # responsible for placing subsequent fragments on later
92
+ # pages by calling draw on each fragment directly.
93
+ offset_y = y
94
+ @fragments.each do |frag|
95
+ frag.draw(canvas, x, offset_y)
96
+ offset_y -= frag.height
97
+ end
98
+ end
99
+
100
+ # Iterate fragments. Yields each TableBox fragment; if a block
101
+ # is absent, returns an Enumerator. Useful for the Composer
102
+ # flow: the first fragment is drawn at the current cursor,
103
+ # subsequent fragments trigger a page break.
104
+ def each_fragment(&block)
105
+ return enum_for(:each_fragment) unless block
106
+
107
+ @fragments.each(&block)
108
+ end
109
+
110
+ private
111
+
112
+ def compute_column_widths(available_width)
113
+ return @column_widths = equal_widths(available_width) if @column_widths.nil?
114
+
115
+ @column_widths.map!(&:to_f)
116
+ end
117
+
118
+ def equal_widths(available_width)
119
+ col_count = @rows.first&.size || 1
120
+ Array.new(col_count, available_width / col_count)
121
+ end
122
+
123
+ # Measure how many rows from +body+ starting at +cursor+ fit
124
+ # in +available_height+ using the computed column widths.
125
+ def rows_that_fit(body, cursor, available_height)
126
+ remaining = available_height.to_f
127
+ count = 0
128
+ body[cursor..].each_with_index do |row, _i|
129
+ row_h = measure_row_height(row)
130
+ break if (remaining - row_h).negative?
131
+
132
+ remaining -= row_h
133
+ count += 1
134
+ end
135
+ count
136
+ end
137
+
138
+ # Estimate row height as the max natural height of any cell
139
+ # in the row, given the column widths.
140
+ def measure_row_height(row)
141
+ heights = row.map.with_index do |cell, _col|
142
+ cell_box = cell.is_a?(TableBox::Cell) ? cell.box : cell
143
+ next 0 unless cell_box.is_a?(Pdfrb::Layout::Box)
144
+
145
+ cell_box.height.to_f
146
+ end
147
+ heights.max || 14.0
148
+ end
149
+
150
+ def build_fragment(rows)
151
+ TableBox.new(rows: rows, column_widths: @column_widths&.dup)
152
+ end
153
+ end
154
+ end
155
+ end