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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/RELEASE.md +17 -11
- data/data/pdfrb/layout/hyphenation_en.txt +408 -0
- data/lib/pdfrb/color/default_profile.rb +213 -0
- data/lib/pdfrb/color/icc_validator.rb +69 -0
- data/lib/pdfrb/color.rb +2 -0
- data/lib/pdfrb/conformance/ltv.rb +112 -0
- data/lib/pdfrb/conformance/pades.rb +198 -0
- data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
- data/lib/pdfrb/conformance/pdf_a.rb +123 -0
- data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
- data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
- data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
- data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
- data/lib/pdfrb/conformance/pdf_x.rb +66 -1
- data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
- data/lib/pdfrb/conformance.rb +8 -0
- data/lib/pdfrb/content/parser.rb +82 -0
- data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
- data/lib/pdfrb/digital_signature.rb +1 -0
- data/lib/pdfrb/document.rb +29 -0
- data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
- data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
- data/lib/pdfrb/encryption/v5_writer.rb +108 -0
- data/lib/pdfrb/encryption.rb +3 -0
- data/lib/pdfrb/font_loader/type3.rb +65 -0
- data/lib/pdfrb/font_loader.rb +1 -0
- data/lib/pdfrb/image_loader/gif.rb +246 -0
- data/lib/pdfrb/image_loader/tiff.rb +257 -0
- data/lib/pdfrb/image_loader.rb +2 -0
- data/lib/pdfrb/layout/font_fallback.rb +203 -0
- data/lib/pdfrb/layout/hyphenation.rb +120 -0
- data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
- data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
- data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
- data/lib/pdfrb/layout/polygon_frame.rb +106 -0
- data/lib/pdfrb/layout/table_box.rb +154 -23
- data/lib/pdfrb/layout/text_shaper.rb +129 -0
- data/lib/pdfrb/layout.rb +7 -0
- data/lib/pdfrb/source/linearization_reader.rb +76 -0
- data/lib/pdfrb/source/recovery.rb +65 -1
- data/lib/pdfrb/source/tokenizer.rb +21 -0
- data/lib/pdfrb/source.rb +1 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +1 -0
- data/lib/pdfrb/version.rb +1 -1
- metadata +28 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Polygon-aware Frame: extends Frame to compute available area
|
|
6
|
+
# against an arbitrary polygon shape, not just a rectangle.
|
|
7
|
+
# Useful for L-shaped text regions, text-around-image, and
|
|
8
|
+
# irregular page layouts.
|
|
9
|
+
#
|
|
10
|
+
# The default Frame.find_available_area always returns the
|
|
11
|
+
# top-left rectangle; this subclass finds the largest inscribed
|
|
12
|
+
# rectangle of (width × height) inside the polygon, scanning
|
|
13
|
+
# row-by-row to find the leftmost x where the requested box fits
|
|
14
|
+
# at the requested height.
|
|
15
|
+
class PolygonFrame < Frame
|
|
16
|
+
DEFAULT_STEP = 1.0
|
|
17
|
+
|
|
18
|
+
attr_reader :polygon
|
|
19
|
+
|
|
20
|
+
# @param polygon [Array<Array<Numeric>>] array of [x, y] vertex
|
|
21
|
+
# pairs. The polygon is implicitly closed.
|
|
22
|
+
# @param step [Numeric] scan resolution in PDF units; smaller
|
|
23
|
+
# steps find tighter fits but take longer.
|
|
24
|
+
def initialize(left:, bottom:, width:, height:, polygon: nil, step: DEFAULT_STEP)
|
|
25
|
+
super(left: left, bottom: bottom, width: width, height: height)
|
|
26
|
+
@polygon = polygon || [[left, bottom], [left + width, bottom],
|
|
27
|
+
[left + width, bottom + height],
|
|
28
|
+
[left, bottom + height]]
|
|
29
|
+
@step = step.to_f
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find the next available area inside the polygon that fits
|
|
33
|
+
# (width, height). Walks row by row from the top of the
|
|
34
|
+
# bounding box downward; at each row y, scans x from left
|
|
35
|
+
# toward the right edge, returning the first (x, y, w, h)
|
|
36
|
+
# position where all four corners of (x, y - h, w, h) are
|
|
37
|
+
# inside the polygon.
|
|
38
|
+
def find_available_area(width, height)
|
|
39
|
+
w = width.to_f
|
|
40
|
+
h = height.to_f
|
|
41
|
+
return nil unless w.positive? && h.positive?
|
|
42
|
+
|
|
43
|
+
bbox_top = bounding_box_top
|
|
44
|
+
bbox_bottom = bounding_box_bottom
|
|
45
|
+
bbox_left = bounding_box_left
|
|
46
|
+
bbox_right = bounding_box_right
|
|
47
|
+
|
|
48
|
+
y = bbox_top
|
|
49
|
+
while y - h >= bbox_bottom
|
|
50
|
+
x = bbox_left
|
|
51
|
+
while x + w <= bbox_right
|
|
52
|
+
return [x, y, w, h] if contains_rectangle?(x, y - h, w, h)
|
|
53
|
+
|
|
54
|
+
x += @step
|
|
55
|
+
end
|
|
56
|
+
y -= @step
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bounding_box_top
|
|
62
|
+
@polygon.map { |_x, y| y }.max.to_f
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bounding_box_bottom
|
|
66
|
+
@polygon.map { |_x, y| y }.min.to_f
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def bounding_box_left
|
|
70
|
+
@polygon.map { |x, _y| x }.min.to_f
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bounding_box_right
|
|
74
|
+
@polygon.map { |x, _y| x }.max.to_f
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Point-in-polygon test (ray casting).
|
|
78
|
+
def contains_point?(x, y)
|
|
79
|
+
inside = false
|
|
80
|
+
n = @polygon.length
|
|
81
|
+
i = 0
|
|
82
|
+
j = n - 1
|
|
83
|
+
while i < n
|
|
84
|
+
xi, yi = @polygon[i]
|
|
85
|
+
xj, yj = @polygon[j]
|
|
86
|
+
if ((yi > y) != (yj > y)) &&
|
|
87
|
+
(x < ((xj - xi) * (y - yi) / (yj - yi)) + xi)
|
|
88
|
+
inside = !inside
|
|
89
|
+
end
|
|
90
|
+
j = i
|
|
91
|
+
i += 1
|
|
92
|
+
end
|
|
93
|
+
inside
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def contains_rectangle?(x, y, w, h)
|
|
97
|
+
[
|
|
98
|
+
[x, y],
|
|
99
|
+
[x + w, y],
|
|
100
|
+
[x, y + h],
|
|
101
|
+
[x + w, y + h],
|
|
102
|
+
].all? { |px, py| contains_point?(px, py) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -1,47 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string literal: true
|
|
2
2
|
|
|
3
3
|
module Pdfrb
|
|
4
4
|
module Layout
|
|
5
5
|
# A table of cells. Each cell holds a Box. Column widths are auto
|
|
6
6
|
# unless specified; row heights computed from content.
|
|
7
|
+
#
|
|
8
|
+
# Cells may declare +colspan+ / +rowspan+ to occupy multiple
|
|
9
|
+
# columns or rows. Cell entries can be plain Boxes (treated as
|
|
10
|
+
# 1x1 cells) or +TableBox::Cell+ instances created via
|
|
11
|
+
# +TableBox.cell(box, colspan:, rowspan:+).
|
|
7
12
|
class TableBox < Box
|
|
13
|
+
Cell = Struct.new(:box, :colspan, :rowspan, keyword_init: true) do
|
|
14
|
+
def initialize(box:, colspan: 1, rowspan: 1)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fit?(*args); box.fit?(*args); end
|
|
19
|
+
|
|
20
|
+
def draw(*args); box.draw(*args); end
|
|
21
|
+
|
|
22
|
+
def height; box.height; end
|
|
23
|
+
|
|
24
|
+
def width; box.width; end
|
|
25
|
+
end
|
|
26
|
+
|
|
8
27
|
attr_reader :rows, :column_widths
|
|
9
28
|
|
|
29
|
+
# Build a Cell wrapping +box+ with the given spans.
|
|
30
|
+
def self.cell(box, colspan: 1, rowspan: 1)
|
|
31
|
+
Cell.new(box: box, colspan: colspan, rowspan: rowspan)
|
|
32
|
+
end
|
|
33
|
+
|
|
10
34
|
def initialize(rows:, column_widths: nil, **)
|
|
11
35
|
super(**)
|
|
12
|
-
@rows = rows
|
|
36
|
+
@rows = rows.map { |row| row.map { |c| wrap_cell(c) } }
|
|
13
37
|
@column_widths = column_widths
|
|
14
38
|
end
|
|
15
39
|
|
|
16
40
|
def fit?(available_width, available_height)
|
|
17
41
|
compute_column_widths(available_width)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
row.each_with_index do |cell, col|
|
|
22
|
-
cell_w = @column_widths[col]
|
|
23
|
-
cell.fit?(cell_w, remaining) or return false
|
|
24
|
-
row_heights << cell.height
|
|
25
|
-
end
|
|
26
|
-
row_h = row_heights.max || 0
|
|
27
|
-
remaining -= row_h
|
|
28
|
-
end
|
|
29
|
-
@width = available_width
|
|
30
|
-
@height = available_height - remaining
|
|
31
|
-
true
|
|
42
|
+
layout_grid!
|
|
43
|
+
fit_rows!(available_height)
|
|
44
|
+
@height <= available_height
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
def draw_content(canvas, x, y)
|
|
35
48
|
offset_y = y
|
|
36
|
-
|
|
37
|
-
max_h = 0
|
|
49
|
+
row_heights.each_with_index do |row_h, row_index|
|
|
38
50
|
offset_x = x
|
|
39
|
-
|
|
40
|
-
cell
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
@column_widths.each_with_index do |_w, col_index|
|
|
52
|
+
cell = cell_at(row_index, col_index)
|
|
53
|
+
if cell && origin?(row_index, col_index, cell)
|
|
54
|
+
span_w = spanned_width(col_index, cell.colspan)
|
|
55
|
+
span_h = spanned_height(row_index, cell.rowspan)
|
|
56
|
+
cell.box.fit?(span_w, span_h)
|
|
57
|
+
cell.draw(canvas, offset_x, offset_y)
|
|
58
|
+
end
|
|
59
|
+
offset_x += @column_widths[col_index]
|
|
43
60
|
end
|
|
44
|
-
offset_y -=
|
|
61
|
+
offset_y -= row_h
|
|
45
62
|
end
|
|
46
63
|
end
|
|
47
64
|
|
|
@@ -49,8 +66,23 @@ module Pdfrb
|
|
|
49
66
|
@rows.empty?
|
|
50
67
|
end
|
|
51
68
|
|
|
69
|
+
# Height of each row, indexed by row position. Populated by
|
|
70
|
+
# +fit?+.
|
|
71
|
+
def row_heights
|
|
72
|
+
@row_heights || []
|
|
73
|
+
end
|
|
74
|
+
|
|
52
75
|
private
|
|
53
76
|
|
|
77
|
+
def wrap_cell(value)
|
|
78
|
+
return value if value.is_a?(Cell)
|
|
79
|
+
|
|
80
|
+
box = value.is_a?(::Hash) ? value[:box] : value
|
|
81
|
+
colspan = value.is_a?(::Hash) ? (value[:colspan] || 1) : 1
|
|
82
|
+
rowspan = value.is_a?(::Hash) ? (value[:rowspan] || 1) : 1
|
|
83
|
+
Cell.new(box: box, colspan: colspan, rowspan: rowspan)
|
|
84
|
+
end
|
|
85
|
+
|
|
54
86
|
def compute_column_widths(available_width)
|
|
55
87
|
return @column_widths = equal_widths(available_width) if @column_widths.nil?
|
|
56
88
|
|
|
@@ -61,6 +93,105 @@ module Pdfrb
|
|
|
61
93
|
col_count = @rows.first&.size || 1
|
|
62
94
|
Array.new(col_count, available_width / col_count)
|
|
63
95
|
end
|
|
96
|
+
|
|
97
|
+
# Build a (row, col) -> Cell map accounting for spans, so any
|
|
98
|
+
# cell covered by a span from above or to the left resolves to
|
|
99
|
+
# the same Cell instance. @grid[r][c] = the originating Cell.
|
|
100
|
+
def layout_grid!
|
|
101
|
+
@grid = ::Array.new(@rows.size) { ::Array.new(column_count) }
|
|
102
|
+
@origins = {} # [r, c] -> true if this is the top-left of a Cell
|
|
103
|
+
@rows.each_with_index do |row, r|
|
|
104
|
+
c = 0
|
|
105
|
+
row.each do |cell|
|
|
106
|
+
c = next_free_column(r, c)
|
|
107
|
+
place_cell(cell, r, c)
|
|
108
|
+
@origins[[r, c]] = true
|
|
109
|
+
c += cell.colspan
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def next_free_column(row, col)
|
|
115
|
+
col += 1 while @grid[row] && @grid[row][col]
|
|
116
|
+
col
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def place_cell(cell, row, col)
|
|
120
|
+
cell.rowspan.times do |dr|
|
|
121
|
+
cell.colspan.times do |dc|
|
|
122
|
+
r = row + dr
|
|
123
|
+
c = col + dc
|
|
124
|
+
next unless @grid[r] && c < @grid[r].size
|
|
125
|
+
|
|
126
|
+
@grid[r][c] = cell
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def column_count
|
|
132
|
+
@column_widths.length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cell_at(row, col)
|
|
136
|
+
@grid[row]&.[](col)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def origin?(row, col, cell)
|
|
140
|
+
# The cell is the origin if the grid position matches the
|
|
141
|
+
# cell's top-left (i.e. the position above and to the left
|
|
142
|
+
# belongs to a different cell or none).
|
|
143
|
+
return true if row.zero? && col.zero?
|
|
144
|
+
|
|
145
|
+
above = row.positive? ? @grid[row - 1][col] : nil
|
|
146
|
+
left = col.positive? ? @grid[row][col - 1] : nil
|
|
147
|
+
above != cell && left != cell
|
|
148
|
+
end
|
|
149
|
+
alias is_origin? origin?
|
|
150
|
+
|
|
151
|
+
def spanned_width(start_col, colspan)
|
|
152
|
+
span = colspan
|
|
153
|
+
@column_widths[start_col, span].sum
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def spanned_height(start_row, rowspan)
|
|
157
|
+
span = rowspan
|
|
158
|
+
@row_heights[start_row, span]&.sum || 0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fit_rows!(available_height)
|
|
162
|
+
@row_heights = compute_row_heights(available_height)
|
|
163
|
+
@height = @row_heights.sum
|
|
164
|
+
@width = @column_widths.sum
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# First pass: compute the natural height of each row by taking
|
|
169
|
+
# the max content height across cells in that row, ignoring
|
|
170
|
+
# rowspan contributions to non-origin rows.
|
|
171
|
+
def compute_row_heights(available_height)
|
|
172
|
+
heights = ::Array.new(@rows.size, 0)
|
|
173
|
+
@rows.each_with_index do |row, r|
|
|
174
|
+
row.each_with_index do |_cell, _c|
|
|
175
|
+
# Handled via grid below for span correctness.
|
|
176
|
+
end
|
|
177
|
+
# Use the grid to skip span-occupied cells.
|
|
178
|
+
column_count.times do |c|
|
|
179
|
+
cell = @grid[r][c]
|
|
180
|
+
next unless cell && origin?(r, c, cell)
|
|
181
|
+
|
|
182
|
+
span_w = spanned_width(c, cell.colspan)
|
|
183
|
+
cell.fit?(span_w, available_height)
|
|
184
|
+
cell_h = cell.height.to_f
|
|
185
|
+
# Distribute the height across the rows the cell spans.
|
|
186
|
+
distribute = [(cell_h / cell.rowspan).ceil, 1].max
|
|
187
|
+
cell.rowspan.times do |dr|
|
|
188
|
+
rr = r + dr
|
|
189
|
+
heights[rr] = [heights[rr], distribute].max if heights[rr]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
heights
|
|
194
|
+
end
|
|
64
195
|
end
|
|
65
196
|
end
|
|
66
197
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Text shaping interface. Pure-Ruby shapers don't exist for the
|
|
6
|
+
# complexity of OpenType GSUB/GPOS — this module defines the
|
|
7
|
+
# contract that a real shaper (HarfBuzz, fribidi, etc.) must
|
|
8
|
+
# satisfy. Callers can plug in a shaper implementation via
|
|
9
|
+
# Pdfrb::Layout::TextShaper.implementation = MyShaper.
|
|
10
|
+
#
|
|
11
|
+
# The default implementation is grapheme-cluster-aware: it keeps
|
|
12
|
+
# each user-perceived character (a base + combining marks, a
|
|
13
|
+
# Hangul syllable, an emoji-ZWJ sequence, etc.) together as one
|
|
14
|
+
# cluster so the TextLayouter measures and breaks at cluster
|
|
15
|
+
# boundaries, never inside one.
|
|
16
|
+
module TextShaper
|
|
17
|
+
@implementation = nil
|
|
18
|
+
|
|
19
|
+
ShapedRun = Struct.new(:codepoints, :clusters, :advances,
|
|
20
|
+
:cluster_starts, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
attr_accessor :implementation
|
|
24
|
+
|
|
25
|
+
# Shape +text+ for +font+ at +size+. Returns a ShapedRun.
|
|
26
|
+
#
|
|
27
|
+
# When an +implementation+ is registered, delegates to it.
|
|
28
|
+
# Otherwise runs the default grapheme-cluster shaper.
|
|
29
|
+
def shape(text, font: nil, size: 12, direction: :ltr)
|
|
30
|
+
if implementation
|
|
31
|
+
return implementation.shape(text, font: font, size: size,
|
|
32
|
+
direction: direction)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
default_shape(text, size, direction)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Default shaper: one cluster per grapheme. Each cluster's
|
|
41
|
+
# advance is its base-codepoint width (combining marks add
|
|
42
|
+
# zero). Falls back to size/2 when font metrics aren't
|
|
43
|
+
# available.
|
|
44
|
+
def default_shape(text, size, _direction)
|
|
45
|
+
clusters = text.to_s.grapheme_clusters.to_a
|
|
46
|
+
codepoints = []
|
|
47
|
+
cluster_indices = []
|
|
48
|
+
advances = []
|
|
49
|
+
cluster_starts = []
|
|
50
|
+
|
|
51
|
+
clusters.each_with_index do |cluster, cluster_idx|
|
|
52
|
+
cluster_starts << codepoints.length
|
|
53
|
+
cps = cluster.codepoints.to_a
|
|
54
|
+
cps.each do |cp|
|
|
55
|
+
codepoints << cp
|
|
56
|
+
cluster_indices << cluster_idx
|
|
57
|
+
advances << advance_for(cp, size, cps)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ShapedRun.new(
|
|
62
|
+
codepoints: codepoints,
|
|
63
|
+
clusters: cluster_indices,
|
|
64
|
+
advances: advances,
|
|
65
|
+
cluster_starts: cluster_starts
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Per-codepoint advance. Combining marks (Unicode general
|
|
70
|
+
# category M*) contribute 0; everything else gets size/2
|
|
71
|
+
# as a heuristic unless real metrics are available.
|
|
72
|
+
def advance_for(codepoint, size, _cluster_cps)
|
|
73
|
+
return 0.0 if combining_mark?(codepoint)
|
|
74
|
+
return size * 0.6 if wide?(codepoint)
|
|
75
|
+
|
|
76
|
+
# Heuristic average advance for ASCII range.
|
|
77
|
+
size / 2.0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Combining marks: Unicode general categories Mn, Mc, Me.
|
|
81
|
+
# Subset of common ranges; the full list is in
|
|
82
|
+
# UnicodeData.txt.
|
|
83
|
+
def combining_mark?(codepoint)
|
|
84
|
+
ranges = [
|
|
85
|
+
0x0300..0x036F, # Combining Diacritical Marks
|
|
86
|
+
0x0483..0x0489,
|
|
87
|
+
0x0591..0x05BD,
|
|
88
|
+
0x05BF..0x05BF,
|
|
89
|
+
0x05C1..0x05C2, 0x05C4..0x05C5, 0x05C7..0x05C7,
|
|
90
|
+
0x0610..0x061A,
|
|
91
|
+
0x064B..0x065F,
|
|
92
|
+
0x0670..0x0670,
|
|
93
|
+
0x06D6..0x06DC,
|
|
94
|
+
0x06DF..0x06E4,
|
|
95
|
+
0x06E7..0x06E8,
|
|
96
|
+
0x06EA..0x06ED,
|
|
97
|
+
0x0711..0x0711,
|
|
98
|
+
0x0730..0x074A,
|
|
99
|
+
0x0900..0x0903,
|
|
100
|
+
0x093A..0x094F,
|
|
101
|
+
0x0951..0x0957,
|
|
102
|
+
0x20D0..0x20FF, # Combining marks for symbols
|
|
103
|
+
0xFE00..0xFE0F # Variation selectors
|
|
104
|
+
]
|
|
105
|
+
ranges.any? { |r| r.cover?(codepoint) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Wide characters: CJK + emoji. These typically take 1em
|
|
109
|
+
# advance (double-width of a Latin letter).
|
|
110
|
+
def wide?(codepoint)
|
|
111
|
+
ranges = [
|
|
112
|
+
0x1100..0x115F, # Hangul Jamo
|
|
113
|
+
0x2E80..0x303E, # CJK Radicals
|
|
114
|
+
0x3040..0x33BF, # Hiragana, Katakana, CJK
|
|
115
|
+
0x3400..0x4DBF, # CJK Extension A
|
|
116
|
+
0x4E00..0x9FFF, # CJK Unified Ideographs
|
|
117
|
+
0xA000..0xA4CF, # Yi
|
|
118
|
+
0xAC00..0xD7A3, # Hangul Syllables
|
|
119
|
+
0xF900..0xFAFF, # CJK Compatibility Ideographs
|
|
120
|
+
0xFE30..0xFE4F, # CJK Compatibility Forms
|
|
121
|
+
0x1F000..0x1FAFF, # Emoji + extensions
|
|
122
|
+
0x20000..0x2FFFF, # CJK Extension B+
|
|
123
|
+
]
|
|
124
|
+
ranges.any? { |r| r.cover?(codepoint) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/pdfrb/layout.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Pdfrb
|
|
|
15
15
|
autoload :ImageBox, "pdfrb/layout/image_box"
|
|
16
16
|
autoload :ListBox, "pdfrb/layout/list_box"
|
|
17
17
|
autoload :TableBox, "pdfrb/layout/table_box"
|
|
18
|
+
autoload :MultiPageTableBox, "pdfrb/layout/multi_page_table_box"
|
|
18
19
|
autoload :ColumnBox, "pdfrb/layout/column_box"
|
|
19
20
|
autoload :InlineBox, "pdfrb/layout/inline_box"
|
|
20
21
|
autoload :BoxFitter, "pdfrb/layout/box_fitter"
|
|
@@ -23,6 +24,12 @@ module Pdfrb
|
|
|
23
24
|
autoload :TextFragment, "pdfrb/layout/text_fragment"
|
|
24
25
|
autoload :Line, "pdfrb/layout/line"
|
|
25
26
|
autoload :Bidi, "pdfrb/layout/bidi"
|
|
27
|
+
autoload :Hyphenation, "pdfrb/layout/hyphenation"
|
|
28
|
+
autoload :TextShaper, "pdfrb/layout/text_shaper"
|
|
29
|
+
autoload :PolygonFrame, "pdfrb/layout/polygon_frame"
|
|
30
|
+
autoload :FontFallback, "pdfrb/layout/font_fallback"
|
|
31
|
+
autoload :JustificationKashidas, "pdfrb/layout/justification_kashidas"
|
|
32
|
+
autoload :MultiCellTextLayout, "pdfrb/layout/multi_cell_text_layout"
|
|
26
33
|
autoload :RomanNumeral, "pdfrb/layout/list_box"
|
|
27
34
|
end
|
|
28
35
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Source
|
|
5
|
+
# Reads the Linearization dictionary (Annex F) that lives on the
|
|
6
|
+
# first indirect object of a linearized PDF. Exposes the
|
|
7
|
+
# page-one fast-path offset (/O), end of first page (/E), number
|
|
8
|
+
# of pages (/N), and the hint stream offset (/T) so callers can
|
|
9
|
+
# resolve page 1 without parsing the rest of the file.
|
|
10
|
+
#
|
|
11
|
+
# A non-linearized PDF returns nil from .detect.
|
|
12
|
+
class LinearizationReader
|
|
13
|
+
LinearizationInfo = Struct.new(
|
|
14
|
+
:linearized, # 1.0 marker
|
|
15
|
+
:file_length, # /L
|
|
16
|
+
:first_page_obj_offset, # /O
|
|
17
|
+
:first_page_end_offset, # /E
|
|
18
|
+
:page_count, # /N
|
|
19
|
+
:hint_stream_offset, # /T
|
|
20
|
+
:primary_hint_offset, # /H [offset, length]
|
|
21
|
+
keyword_init: true
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Parse the first indirect object at offset 0 in +io+ and
|
|
26
|
+
# return a LinearizationInfo if it's a Linearization dict,
|
|
27
|
+
# nil otherwise.
|
|
28
|
+
def detect(io)
|
|
29
|
+
io.seek(0, IO::SEEK_SET)
|
|
30
|
+
header = io.read(1024).to_s
|
|
31
|
+
return nil unless header.start_with?("%PDF-")
|
|
32
|
+
|
|
33
|
+
# Skip the header line, then look for the first "N G obj"
|
|
34
|
+
# marker. The Linearization dict is on the first indirect
|
|
35
|
+
# object in a linearized file.
|
|
36
|
+
io.seek(0, IO::SEEK_SET)
|
|
37
|
+
tok = Pdfrb::Source::Tokenizer.new(io)
|
|
38
|
+
parser = Pdfrb::Source::Parser.new(tok, document: nil)
|
|
39
|
+
first_obj = begin
|
|
40
|
+
parser.parse_indirect_object
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
return nil unless first_obj
|
|
45
|
+
|
|
46
|
+
dict = dictionary_value(first_obj)
|
|
47
|
+
return nil unless dict && dict[:Linearized]
|
|
48
|
+
|
|
49
|
+
build_info(dict)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def dictionary_value(obj)
|
|
55
|
+
return obj.value if obj.is_a?(Pdfrb::Model::Cos::Dictionary)
|
|
56
|
+
return obj.value if obj.is_a?(Pdfrb::Model::Cos::Stream)
|
|
57
|
+
|
|
58
|
+
obj if obj.is_a?(::Hash)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_info(dict)
|
|
62
|
+
hint = dict[:H]
|
|
63
|
+
LinearizationInfo.new(
|
|
64
|
+
linearized: dict[:Linearized].to_s,
|
|
65
|
+
file_length: dict[:L]&.to_i,
|
|
66
|
+
first_page_obj_offset: dict[:O]&.to_i,
|
|
67
|
+
first_page_end_offset: dict[:E]&.to_i,
|
|
68
|
+
page_count: dict[:N]&.to_i,
|
|
69
|
+
hint_stream_offset: dict[:T]&.to_i,
|
|
70
|
+
primary_hint_offset: hint.is_a?(::Array) ? hint : nil
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -6,9 +6,23 @@ module Pdfrb
|
|
|
6
6
|
# patterns and synthesise an XrefSection from the offsets found.
|
|
7
7
|
# Triggered when the xref table is missing or points to a wrong
|
|
8
8
|
# offset.
|
|
9
|
+
#
|
|
10
|
+
# Extended recovery capabilities:
|
|
11
|
+
# * Cross-reference table reconstruction (the original mode).
|
|
12
|
+
# * Trailer recovery: find /Root and /Encrypt by scanning for
|
|
13
|
+
# the keys directly when the trailer is corrupt.
|
|
14
|
+
# * Hybrid xref detection: read both xref table and xref stream
|
|
15
|
+
# when both are present.
|
|
16
|
+
# * Object-stream reconstruction: rebuild an XrefSection from a
|
|
17
|
+
# /Type /ObjStm stream when its header is intact but the
|
|
18
|
+
# xref stream is missing.
|
|
9
19
|
module Recovery
|
|
10
20
|
OBJ_PATTERN = /(\d+)\s+(\d+)\s+obj\b/.freeze
|
|
11
|
-
|
|
21
|
+
TRAILER_ROOT_PATTERN = %r{/Root\s+(\d+)\s+(\d+)\s+R}.freeze
|
|
22
|
+
TRAILER_ENCRYPT_PATTERN = %r{/Encrypt\s+(\d+)\s+(\d+)\s+R}.freeze
|
|
23
|
+
TRAILER_INFO_PATTERN = %r{/Info\s+(\d+)\s+(\d+)\s+R}.freeze
|
|
24
|
+
private_constant :OBJ_PATTERN, :TRAILER_ROOT_PATTERN,
|
|
25
|
+
:TRAILER_ENCRYPT_PATTERN, :TRAILER_INFO_PATTERN
|
|
12
26
|
|
|
13
27
|
module_function
|
|
14
28
|
|
|
@@ -30,6 +44,56 @@ module Pdfrb
|
|
|
30
44
|
end
|
|
31
45
|
section
|
|
32
46
|
end
|
|
47
|
+
|
|
48
|
+
# Scan the file for /Root, /Info, /Encrypt references when the
|
|
49
|
+
# trailer dict is unreadable. Returns a Hash with :Root, :Info,
|
|
50
|
+
# :Encrypt keys as References (or nil per key).
|
|
51
|
+
def recover_trailer_references(io)
|
|
52
|
+
io.seek(0, IO::SEEK_SET)
|
|
53
|
+
data = io.read.to_s
|
|
54
|
+
data.force_encoding(Encoding::BINARY)
|
|
55
|
+
|
|
56
|
+
root_match = data.match(TRAILER_ROOT_PATTERN)
|
|
57
|
+
info_match = data.match(TRAILER_INFO_PATTERN)
|
|
58
|
+
encrypt_match = data.match(TRAILER_ENCRYPT_PATTERN)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
Root: root_match ? Pdfrb::Model::Reference.new(root_match[1].to_i, root_match[2].to_i) : nil,
|
|
62
|
+
Info: info_match ? Pdfrb::Model::Reference.new(info_match[1].to_i, info_match[2].to_i) : nil,
|
|
63
|
+
Encrypt: encrypt_match ? Pdfrb::Model::Reference.new(encrypt_match[1].to_i, encrypt_match[2].to_i) : nil,
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Detect hybrid xref: PDF 1.7+ allows both a classical xref
|
|
68
|
+
# table AND an xref stream in the same revision, referenced
|
|
69
|
+
# from the table via /XRefStm. Returns true if the file has
|
|
70
|
+
# both. Pure-Ruby: scans for `startxref` followed by both
|
|
71
|
+
# `xref` and a Type=XRef stream object.
|
|
72
|
+
def hybrid_xref?(io)
|
|
73
|
+
io.seek(0, IO::SEEK_SET)
|
|
74
|
+
data = io.read.to_s
|
|
75
|
+
data.force_encoding(Encoding::BINARY)
|
|
76
|
+
|
|
77
|
+
has_table = data.include?("\nxref\n")
|
|
78
|
+
has_stream = data.match?(/\/Type\s*\/XRef\b/)
|
|
79
|
+
has_table && has_stream
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reconstruct an XrefSection from a /Type /ObjStm stream by
|
|
83
|
+
# reading its /N + /First + decompressed body. Used when the
|
|
84
|
+
# outer xref stream is corrupt but a known ObjStm is intact.
|
|
85
|
+
def rebuild_from_object_stream(objstm, document)
|
|
86
|
+
return nil unless objstm
|
|
87
|
+
|
|
88
|
+
pairs = ObjectStreamReader.read(objstm, document)
|
|
89
|
+
section = XrefSection.new
|
|
90
|
+
pairs.each_key do |oid|
|
|
91
|
+
# Mark as compressed in objstm 0 (placeholder; caller
|
|
92
|
+
# fills in the real objstm_oid).
|
|
93
|
+
section.add_compressed(oid, 0, 0, objstm.oid)
|
|
94
|
+
end
|
|
95
|
+
section
|
|
96
|
+
end
|
|
33
97
|
end
|
|
34
98
|
end
|
|
35
99
|
end
|
|
@@ -52,6 +52,27 @@ module Pdfrb
|
|
|
52
52
|
self
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Whether the underlying IO is at end of stream.
|
|
56
|
+
def eof?
|
|
57
|
+
@io.eof?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Read one byte from the underlying IO. Returns nil at EOF.
|
|
61
|
+
def read_byte
|
|
62
|
+
b = @io.getbyte
|
|
63
|
+
@pos += 1 if b
|
|
64
|
+
b
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Skip whitespace bytes (NUL, HT, LF, FF, CR, SP).
|
|
68
|
+
def skip_whitespace
|
|
69
|
+
while (b = peek_byte)
|
|
70
|
+
break unless WHITESPACE_BYTES.include?(b)
|
|
71
|
+
|
|
72
|
+
advance_byte
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
55
76
|
private
|
|
56
77
|
|
|
57
78
|
def fill_lookahead(n)
|
data/lib/pdfrb/source.rb
CHANGED
|
@@ -20,5 +20,6 @@ module Pdfrb
|
|
|
20
20
|
autoload :ObjectStreamReader, "pdfrb/source/object_stream_reader"
|
|
21
21
|
autoload :Recovery, "pdfrb/source/recovery"
|
|
22
22
|
autoload :LinearizationDetection, "pdfrb/source/linearization_detection"
|
|
23
|
+
autoload :LinearizationReader, "pdfrb/source/linearization_reader"
|
|
23
24
|
end
|
|
24
25
|
end
|