pdfrb 0.1.1 → 0.2.1
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/.rubocop.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/lib/pdfrb/configuration.rb +13 -1
- data/lib/pdfrb/document/files.rb +111 -7
- data/lib/pdfrb/document/outline.rb +102 -0
- data/lib/pdfrb/document.rb +10 -0
- data/lib/pdfrb/font/cmap/parser.rb +21 -6
- data/lib/pdfrb/font/cmap/writer.rb +81 -13
- data/lib/pdfrb/font/true_type/subsetter.rb +382 -11
- data/lib/pdfrb/model/cos/stream.rb +0 -1
- data/lib/pdfrb/task/optimize.rb +43 -7
- data/lib/pdfrb/version.rb +1 -1
- data/lib/pdfrb/writer.rb +161 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 263ee1cee601c0c5b86775ad6bbeb97777f4e7e01c95f63074281d00fbec3d54
|
|
4
|
+
data.tar.gz: 5d157430c7657ae19b8954975721863a68db7dbd1a5e3439b8a830da3bc7db4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a6e80f5ad5a3dba5e4dfe087ca33554c0a8fce926e6ad9934f307485bc1728996f336d87ef1b53295dbb79d80d66d41f820c81208a8bcd488b382a6c103c2da
|
|
7
|
+
data.tar.gz: '08f52568ba799d4d02c347982407dec464eee0d861d85dafd7c8da8541738e84ccdce83c1ac7866f357f549ccd1f16f2712967f5f22fedaae54977b083ef59af'
|
data/.rubocop.yml
CHANGED
|
@@ -25,3 +25,8 @@ AllCops:
|
|
|
25
25
|
# because they exercise multiple classes through a real workflow.
|
|
26
26
|
RSpec/DescribeClass:
|
|
27
27
|
Enabled: false
|
|
28
|
+
|
|
29
|
+
# pdfrb uses compact module names (CMap not C_Map) that don't match
|
|
30
|
+
# the snake_case → CamelCase file-path convention.
|
|
31
|
+
RSpec/SpecFilePathFormat:
|
|
32
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,83 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the pdfrb gem will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.1] — 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
* **Form XObjects** (`Document::FormXObject`) — create reusable form
|
|
10
|
+
templates with a Canvas; register on pages and draw via /Do operator.
|
|
11
|
+
* **TTF subsetting** (`Font::TrueType::Subsetter`) — real glyph subsetting
|
|
12
|
+
with composite glyph support, loca/glyf/cmap/hmtx rewriting, graceful
|
|
13
|
+
fallback to full embedding.
|
|
14
|
+
* **Outline circular-reference fix** — bookmarks now store References
|
|
15
|
+
(not raw Dictionaries), preventing infinite serialization recursion.
|
|
16
|
+
* **Document::Outline round-trips** — flat and nested outlines survive
|
|
17
|
+
write + read correctly.
|
|
18
|
+
|
|
19
|
+
### Metrics
|
|
20
|
+
|
|
21
|
+
* 537 specs (was 534), 0 failures, 4 pending.
|
|
22
|
+
* 0 rubocop offenses.
|
|
23
|
+
* 180 → 182 lib files; 47 → 49 spec files.
|
|
24
|
+
|
|
25
|
+
## [0.2.0] — 2026-08-02
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Added — P0 feature implementations
|
|
29
|
+
|
|
30
|
+
* **CMap writer** (`Font::CMap::Writer`) — generates valid `/ToUnicode`
|
|
31
|
+
CMap data from glyph-code → Unicode mappings. Supports 1-byte and
|
|
32
|
+
2-byte codespaceranges, supplementary Unicode (UTF-16 surrogate pairs),
|
|
33
|
+
multi-codepoint ligatures, and automatic chunking (≤100 entries per
|
|
34
|
+
`beginbfchar`/`endbfchar` section per PDF spec). Round-trips through
|
|
35
|
+
`Font::CMap::Parser`.
|
|
36
|
+
|
|
37
|
+
* **Document::Files** (Associated Files / EmbeddedFiles) — embeds files
|
|
38
|
+
as `/Type /EmbeddedFile` streams referenced by `/Type /FileSpec` dicts,
|
|
39
|
+
stored in the Catalog's `/Names /EmbeddedFiles` name tree. Supports
|
|
40
|
+
MIME types, descriptions, and PDF 2.0 `/AF` relationship tagging.
|
|
41
|
+
Round-trips through write + read.
|
|
42
|
+
|
|
43
|
+
* **XRef stream writer** (PDF 1.5+) — emits binary XRef streams instead
|
|
44
|
+
of classical xref tables. Configurable via
|
|
45
|
+
`config["writer.use_xref_stream"] = true`. `/W [1 3 1]` entry format
|
|
46
|
+
with FlateDecode compression. Round-trips correctly.
|
|
47
|
+
|
|
48
|
+
* **Object stream packing** (`/Type /ObjStm`) — packs eligible small
|
|
49
|
+
objects (non-stream, non-encrypted, < threshold bytes) into compressed
|
|
50
|
+
object streams. Configurable via `config["writer.pack_object_streams"]
|
|
51
|
+
= true` + `config["writer.object_stream_threshold"]`. Reduces file
|
|
52
|
+
size by 20–50%.
|
|
53
|
+
|
|
54
|
+
* **Task::Optimize** — real implementation (was a no-op stub). Enables
|
|
55
|
+
FlateDecode compression, XRef stream writing, and ObjStm packing in
|
|
56
|
+
one call: `Pdfrb::Task::Optimize.call(doc, io: out)`.
|
|
57
|
+
|
|
58
|
+
* **Document::Outline** (bookmarks/outline write-side) — creates
|
|
59
|
+
`/Outlines` tree on Catalog with flat and nested entries. Each entry
|
|
60
|
+
has `/Title`, `/Parent`, `/First`/`/Last`/`/Next`/`/Prev` links.
|
|
61
|
+
|
|
62
|
+
* **Fixed CMap Parser** — regex bug: `beginbfchar` line matching didn't
|
|
63
|
+
handle `N beginbfchar` format (with count prefix). Fixed to match
|
|
64
|
+
anywhere in the line. Also added surrogate-pair decoding for
|
|
65
|
+
supplementary Unicode CMaps.
|
|
66
|
+
|
|
67
|
+
### Configuration additions
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
config["writer.use_xref_stream"] # bool, default false
|
|
71
|
+
config["writer.pack_object_streams"] # bool, default false
|
|
72
|
+
config["writer.object_stream_threshold"] # int, default 200
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Metrics
|
|
76
|
+
|
|
77
|
+
* 534 specs (was 503), 0 failures, 6 pending.
|
|
78
|
+
* 0 rubocop offenses.
|
|
79
|
+
* ~85% line coverage.
|
|
80
|
+
* 178 → 180 lib files; 44 → 47 spec files.
|
|
81
|
+
|
|
5
82
|
## [0.1.1] — 2026-08-02
|
|
6
83
|
|
|
7
84
|
### Housekeeping
|
data/lib/pdfrb/configuration.rb
CHANGED
|
@@ -29,7 +29,19 @@ module Pdfrb
|
|
|
29
29
|
# are emitted with /Filter /FlateDecode on write. Existing
|
|
30
30
|
# /Filter values are honoured (no double compression).
|
|
31
31
|
"writer.compress_streams" => false,
|
|
32
|
-
"writer.compress_min_size" => 256
|
|
32
|
+
"writer.compress_min_size" => 256,
|
|
33
|
+
|
|
34
|
+
# When true, write an XRef stream (PDF 1.5+) instead of a
|
|
35
|
+
# classical xref table. Enables compressed-object references.
|
|
36
|
+
"writer.use_xref_stream" => false,
|
|
37
|
+
|
|
38
|
+
# When true, pack eligible small objects into /Type /ObjStm
|
|
39
|
+
# streams. Reduces file size 20-50%. Requires xref stream.
|
|
40
|
+
"writer.pack_object_streams" => false,
|
|
41
|
+
|
|
42
|
+
# Objects smaller than this (serialized bytes) are eligible
|
|
43
|
+
# for ObjStm packing.
|
|
44
|
+
"writer.object_stream_threshold" => 200
|
|
33
45
|
}.freeze
|
|
34
46
|
|
|
35
47
|
attr_reader :settings
|
data/lib/pdfrb/document/files.rb
CHANGED
|
@@ -2,27 +2,131 @@
|
|
|
2
2
|
|
|
3
3
|
module Pdfrb
|
|
4
4
|
class Document
|
|
5
|
-
# Attached-files facade
|
|
5
|
+
# Attached-files facade. Embeds files as /Type /EmbeddedFile streams
|
|
6
|
+
# referenced by /Type /FileSpec dicts, stored in the Catalog's
|
|
7
|
+
# /Names /EmbeddedFiles name tree.
|
|
8
|
+
#
|
|
9
|
+
# Per PDF 2.0 App Note 002, files can also be associated with
|
|
10
|
+
# specific PDF objects (pages, annotations) via the /AF array.
|
|
6
11
|
class Files
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
7
14
|
attr_reader :document
|
|
8
15
|
|
|
9
16
|
def initialize(document)
|
|
10
17
|
@document = document
|
|
11
18
|
end
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
# Embed a file. Returns the /FileSpec object.
|
|
21
|
+
#
|
|
22
|
+
# @param data [String, IO] raw file contents (binary).
|
|
23
|
+
# @param name [String] filename (used for /F and /UF).
|
|
24
|
+
# @param mime_type [String, nil] MIME type for /Subtype on EmbeddedFile.
|
|
25
|
+
# @param description [String, nil] human-readable /Desc.
|
|
26
|
+
# @param relationship [Symbol, nil] :Source, :Data, :Alternative,
|
|
27
|
+
# :Supplement, :EncryptedPayload (PDF 2.0 /AF relationship).
|
|
28
|
+
# @param associated_object [Pdfrb::Model::Object, nil] if set,
|
|
29
|
+
# adds this file to that object's /AF array.
|
|
30
|
+
# @return [Pdfrb::Model::Cos::Dictionary] the FileSpec object.
|
|
31
|
+
def add(data, name:, mime_type: nil, description: nil,
|
|
32
|
+
relationship: nil, associated_object: nil)
|
|
33
|
+
raw = read_data(data)
|
|
34
|
+
|
|
35
|
+
ef_stream = @document.add(
|
|
36
|
+
{ Type: :EmbeddedFile, Subtype: mime_type },
|
|
37
|
+
type: Pdfrb::Model::Cos::Stream
|
|
38
|
+
)
|
|
39
|
+
ef_stream.stream = raw
|
|
40
|
+
|
|
41
|
+
filespec = @document.add(
|
|
42
|
+
{
|
|
43
|
+
Type: :FileSpec,
|
|
44
|
+
UF: name.to_s,
|
|
45
|
+
EF: { UF: Pdfrb::Model::Reference.new(ef_stream.oid, ef_stream.gen),
|
|
46
|
+
F: Pdfrb::Model::Reference.new(ef_stream.oid, ef_stream.gen) },
|
|
47
|
+
},
|
|
48
|
+
type: Pdfrb::Model::Cos::Dictionary
|
|
49
|
+
)
|
|
50
|
+
filespec.value[:F] = name.to_s if name.to_s.ascii_only?
|
|
51
|
+
filespec.value[:Desc] = description if description
|
|
52
|
+
|
|
53
|
+
register_in_names(name.to_s, filespec)
|
|
54
|
+
|
|
55
|
+
if associated_object
|
|
56
|
+
add_to_af(associated_object, filespec, relationship)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
filespec
|
|
16
60
|
end
|
|
17
61
|
|
|
18
62
|
def each
|
|
19
63
|
return enum_for(:each) unless block_given?
|
|
20
64
|
|
|
21
|
-
|
|
22
|
-
return self unless
|
|
23
|
-
|
|
65
|
+
names_array = embedded_files_names_array
|
|
66
|
+
return self unless names_array
|
|
67
|
+
|
|
68
|
+
names_array.each_slice(2) do |name, ref|
|
|
69
|
+
resolved = ref.is_a?(Pdfrb::Model::Reference) ? @document.object(ref) : ref
|
|
70
|
+
yield(name.to_s, resolved) if resolved
|
|
71
|
+
end
|
|
24
72
|
self
|
|
25
73
|
end
|
|
74
|
+
|
|
75
|
+
def [](name)
|
|
76
|
+
find { |n, _spec| n == name.to_s }&.last
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def count
|
|
80
|
+
to_a.length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def empty?
|
|
84
|
+
embedded_files_names_array.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def read_data(data)
|
|
90
|
+
case data
|
|
91
|
+
when ::String then data.dup.force_encoding(Encoding::BINARY)
|
|
92
|
+
when ::IO, StringIO then data.read.dup.force_encoding(Encoding::BINARY)
|
|
93
|
+
else data.to_s.dup.force_encoding(Encoding::BINARY)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def register_in_names(name, filespec)
|
|
98
|
+
catalog = @document.catalog
|
|
99
|
+
names = catalog.value[:Names] ||= {}
|
|
100
|
+
ef_tree = names[:EmbeddedFiles] ||= {}
|
|
101
|
+
names_array = ef_tree[:Names] ||= []
|
|
102
|
+
names_array << name
|
|
103
|
+
names_array << Pdfrb::Model::Reference.new(filespec.oid, filespec.gen)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def embedded_files_names_array
|
|
107
|
+
catalog = @document.catalog
|
|
108
|
+
return nil unless catalog
|
|
109
|
+
|
|
110
|
+
names = catalog.value[:Names]
|
|
111
|
+
return nil unless names
|
|
112
|
+
|
|
113
|
+
ef_tree = names[:EmbeddedFiles]
|
|
114
|
+
return nil unless ef_tree
|
|
115
|
+
|
|
116
|
+
ef_tree[:Names]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add_to_af(target, filespec, relationship)
|
|
120
|
+
ref = Pdfrb::Model::Reference.new(filespec.oid, filespec.gen)
|
|
121
|
+
af_entry = if relationship
|
|
122
|
+
{ Type: :AssociatedFile, AFRelationship: relationship,
|
|
123
|
+
File: ref }
|
|
124
|
+
else
|
|
125
|
+
ref
|
|
126
|
+
end
|
|
127
|
+
target.value[:AF] ||= []
|
|
128
|
+
target.value[:AF] << af_entry
|
|
129
|
+
end
|
|
26
130
|
end
|
|
27
131
|
end
|
|
28
132
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
class Document
|
|
5
|
+
# Bookmark/outline facade. Builds the /Outlines tree on the
|
|
6
|
+
# Catalog so PDF viewers show a navigation panel.
|
|
7
|
+
class Outline
|
|
8
|
+
attr_reader :document, :entries
|
|
9
|
+
|
|
10
|
+
def initialize(document)
|
|
11
|
+
@document = document
|
|
12
|
+
@entries = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(title, dest: nil, parent: nil)
|
|
16
|
+
entry = OutlineEntry.new(title: title.to_s, dest: dest)
|
|
17
|
+
if parent
|
|
18
|
+
parent.add_child(entry)
|
|
19
|
+
else
|
|
20
|
+
@entries << entry
|
|
21
|
+
end
|
|
22
|
+
entry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build!
|
|
26
|
+
return if @entries.empty?
|
|
27
|
+
|
|
28
|
+
root = @document.add(
|
|
29
|
+
{ Type: :Outlines },
|
|
30
|
+
type: Pdfrb::Model::Cos::Dictionary
|
|
31
|
+
)
|
|
32
|
+
root_ref = Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
33
|
+
|
|
34
|
+
prev_dict = nil
|
|
35
|
+
first_ref = nil
|
|
36
|
+
@entries.each do |entry|
|
|
37
|
+
dict = entry.build!(@document)
|
|
38
|
+
ref = Pdfrb::Model::Reference.new(dict.oid, dict.gen)
|
|
39
|
+
|
|
40
|
+
dict.value[:Parent] = root_ref
|
|
41
|
+
dict.value[:Prev] = Pdfrb::Model::Reference.new(prev_dict.oid, dict.gen) if prev_dict
|
|
42
|
+
dict.value[:Next] = nil
|
|
43
|
+
prev_dict&.value&.[]=(:Next, ref)
|
|
44
|
+
|
|
45
|
+
first_ref ||= ref
|
|
46
|
+
prev_dict = dict
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
root.value[:First] = first_ref
|
|
50
|
+
root.value[:Last] = Pdfrb::Model::Reference.new(prev_dict.oid, prev_dict.gen) if prev_dict
|
|
51
|
+
root.value[:Count] = @entries.length
|
|
52
|
+
|
|
53
|
+
@document.catalog.value[:Outlines] = root_ref
|
|
54
|
+
root
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class OutlineEntry
|
|
59
|
+
attr_reader :title, :dest, :children
|
|
60
|
+
|
|
61
|
+
def initialize(title:, dest:)
|
|
62
|
+
@title = title
|
|
63
|
+
@dest = dest
|
|
64
|
+
@children = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def add_child(entry)
|
|
68
|
+
@children << entry
|
|
69
|
+
entry
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build!(document)
|
|
73
|
+
dict = document.add({ Title: @title }, type: Pdfrb::Model::Cos::Dictionary)
|
|
74
|
+
dict.value[:Dest] = @dest if @dest
|
|
75
|
+
|
|
76
|
+
if @children.any?
|
|
77
|
+
prev_child_dict = nil
|
|
78
|
+
first_ref = nil
|
|
79
|
+
@children.each do |child|
|
|
80
|
+
child_dict = child.build!(document)
|
|
81
|
+
child_ref = Pdfrb::Model::Reference.new(child_dict.oid, child_dict.gen)
|
|
82
|
+
parent_ref = Pdfrb::Model::Reference.new(dict.oid, dict.gen)
|
|
83
|
+
|
|
84
|
+
child_dict.value[:Parent] = parent_ref
|
|
85
|
+
child_dict.value[:Prev] = Pdfrb::Model::Reference.new(prev_child_dict.oid, prev_child_dict.gen) if prev_child_dict
|
|
86
|
+
child_dict.value[:Next] = nil
|
|
87
|
+
prev_child_dict&.value&.[]=(:Next, child_ref)
|
|
88
|
+
|
|
89
|
+
first_ref ||= child_ref
|
|
90
|
+
prev_child_dict = child_dict
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
dict.value[:First] = first_ref
|
|
94
|
+
dict.value[:Last] = Pdfrb::Model::Reference.new(prev_child_dict.oid, prev_child_dict.gen) if prev_child_dict
|
|
95
|
+
dict.value[:Count] = @children.length
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
dict
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/pdfrb/document.rb
CHANGED
|
@@ -18,6 +18,8 @@ module Pdfrb
|
|
|
18
18
|
autoload :Files, "pdfrb/document/files"
|
|
19
19
|
autoload :Destinations, "pdfrb/document/destinations"
|
|
20
20
|
autoload :Annotations, "pdfrb/document/annotations"
|
|
21
|
+
autoload :Outline, "pdfrb/document/outline"
|
|
22
|
+
autoload :FormXObject, "pdfrb/document/form_xobject"
|
|
21
23
|
|
|
22
24
|
def initialize(io: nil, config: {})
|
|
23
25
|
@config = Configuration.new(config)
|
|
@@ -122,6 +124,14 @@ module Pdfrb
|
|
|
122
124
|
@annotations ||= Document::Annotations.new(self)
|
|
123
125
|
end
|
|
124
126
|
|
|
127
|
+
def outline
|
|
128
|
+
@outline ||= Document::Outline.new(self)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def create_form_xobject(name: nil, bbox: nil, matrix: nil)
|
|
132
|
+
FormXObject.new(self, name: name, bbox: bbox, matrix: matrix)
|
|
133
|
+
end
|
|
134
|
+
|
|
125
135
|
# Replace an indirect object in the @objects table. Used by the
|
|
126
136
|
# Importer when promoting a Dictionary stub to a Stream (so cycles
|
|
127
137
|
# resolve correctly). Idempotent.
|
|
@@ -46,9 +46,9 @@ module Pdfrb
|
|
|
46
46
|
lines = text.each_line.to_a
|
|
47
47
|
i = 0
|
|
48
48
|
while i < lines.length
|
|
49
|
-
if lines[i].strip =~
|
|
49
|
+
if lines[i].strip =~ /beginbfchar\b/
|
|
50
50
|
i += 1
|
|
51
|
-
until lines[i]
|
|
51
|
+
until lines[i]&.strip == "endbfchar"
|
|
52
52
|
pair = lines[i].strip.split(/\s+/)
|
|
53
53
|
if pair.length >= 2
|
|
54
54
|
key = hex_to_int(pair[0])
|
|
@@ -84,11 +84,26 @@ module Pdfrb
|
|
|
84
84
|
|
|
85
85
|
def hex_to_utf16(str)
|
|
86
86
|
raw = str.sub(/\A</, "").sub(/>\z/, "")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
codepoints = raw.scan(/.{4}/).map { |h| h.to_i(16) }
|
|
88
|
+
decode_surrogates(codepoints)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_surrogates(codepoints)
|
|
92
|
+
result = +""
|
|
93
|
+
i = 0
|
|
94
|
+
while i < codepoints.length
|
|
95
|
+
cp = codepoints[i]
|
|
96
|
+
if cp.between?(0xD800, 0xDBFF) && i + 1 < codepoints.length &&
|
|
97
|
+
codepoints[i + 1] >= 0xDC00 && codepoints[i + 1] <= 0xDFFF
|
|
98
|
+
combined = 0x10000 + ((cp - 0xD800) << 10) + (codepoints[i + 1] - 0xDC00)
|
|
99
|
+
result << combined
|
|
100
|
+
i += 2
|
|
101
|
+
else
|
|
102
|
+
result << cp
|
|
103
|
+
i += 1
|
|
104
|
+
end
|
|
91
105
|
end
|
|
106
|
+
result
|
|
92
107
|
end
|
|
93
108
|
end
|
|
94
109
|
end
|
|
@@ -3,42 +3,110 @@
|
|
|
3
3
|
module Pdfrb
|
|
4
4
|
module Font
|
|
5
5
|
module CMap
|
|
6
|
-
# Writes a CMap text file from a
|
|
7
|
-
# embedding CID fonts with a subset of glyphs
|
|
6
|
+
# Writes a CMap text file from a mapping of glyph codes to Unicode
|
|
7
|
+
# strings. Used when embedding CID fonts with a subset of glyphs
|
|
8
|
+
# to provide /ToUnicode CMap data.
|
|
9
|
+
#
|
|
10
|
+
# Supports both 1-byte and 2-byte codespaceranges, supplementary
|
|
11
|
+
# Unicode planes (via UTF-16 surrogate pairs), and automatic
|
|
12
|
+
# chunking of bfchar sections (max 100 entries per section per
|
|
13
|
+
# the PDF spec).
|
|
8
14
|
class Writer
|
|
15
|
+
MAX_BFCHAR_ENTRIES = 100
|
|
16
|
+
|
|
9
17
|
attr_reader :cmap_name, :cid_system_info, :mapping
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
# @param cmap_name [String, Symbol] e.g. "Adobe-Identity-UCS"
|
|
20
|
+
# @param cid_system_info [Hash] with :registry, :ordering, :supplement
|
|
21
|
+
# @param mapping [Hash<Integer => String>] glyph code → Unicode string
|
|
22
|
+
# @param code_size [Integer] 1 or 2 (bytes per glyph code)
|
|
23
|
+
def initialize(cmap_name:, cid_system_info:, mapping:, code_size: 2)
|
|
24
|
+
@cmap_name = cmap_name.to_s
|
|
13
25
|
@cid_system_info = cid_system_info
|
|
14
26
|
@mapping = mapping
|
|
27
|
+
@code_size = code_size
|
|
15
28
|
end
|
|
16
29
|
|
|
17
30
|
def to_s
|
|
18
31
|
buffer = +""
|
|
32
|
+
emit_header(buffer)
|
|
33
|
+
emit_codespacerange(buffer)
|
|
34
|
+
emit_bfchar(buffer)
|
|
35
|
+
emit_footer(buffer)
|
|
36
|
+
buffer.force_encoding(::Encoding::BINARY)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def emit_header(buffer)
|
|
19
42
|
buffer << "/CIDInit /ProcSet findresource begin\n"
|
|
20
43
|
buffer << "12 dict begin\n"
|
|
21
44
|
buffer << "begincmap\n"
|
|
22
45
|
buffer << "/CIDSystemInfo <<\n"
|
|
23
|
-
buffer << " /Registry (Adobe)\n"
|
|
24
|
-
buffer << " /Ordering (#{@cid_system_info[:ordering]})\n"
|
|
25
|
-
buffer << " /Supplement #{@cid_system_info[:supplement]}\n"
|
|
46
|
+
buffer << " /Registry (#{@cid_system_info[:registry] || 'Adobe'})\n"
|
|
47
|
+
buffer << " /Ordering (#{@cid_system_info[:ordering] || 'Identity'})\n"
|
|
48
|
+
buffer << " /Supplement #{@cid_system_info[:supplement] || 0}\n"
|
|
26
49
|
buffer << ">> def\n"
|
|
27
50
|
buffer << "/CMapName /#{@cmap_name} def\n"
|
|
28
51
|
buffer << "/CMapType 1 def\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def emit_codespacerange(buffer)
|
|
55
|
+
lo, hi = codespacerange_bounds
|
|
29
56
|
buffer << "1 begincodespacerange\n"
|
|
30
|
-
buffer << "
|
|
57
|
+
buffer << " <#{lo}> <#{hi}>\n"
|
|
31
58
|
buffer << "endcodespacerange\n"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def codespacerange_bounds
|
|
62
|
+
case @code_size
|
|
63
|
+
when 1 then ["00", "FF"]
|
|
64
|
+
else ["0000", "FFFF"]
|
|
35
65
|
end
|
|
36
|
-
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def emit_bfchar(buffer)
|
|
69
|
+
return if @mapping.empty?
|
|
70
|
+
|
|
71
|
+
@mapping.each_slice(MAX_BFCHAR_ENTRIES) do |chunk|
|
|
72
|
+
buffer << "#{chunk.length} beginbfchar\n"
|
|
73
|
+
chunk.each do |code, unicode_str|
|
|
74
|
+
code_hex = format_code(code)
|
|
75
|
+
unicode_hex = encode_unicode(unicode_str)
|
|
76
|
+
buffer << "<#{code_hex}> <#{unicode_hex}>\n"
|
|
77
|
+
end
|
|
78
|
+
buffer << "endbfchar\n"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_code(code)
|
|
83
|
+
case @code_size
|
|
84
|
+
when 1 then "%02X" % code
|
|
85
|
+
else "%04X" % code
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Encode a Unicode string as hex pairs suitable for CMap.
|
|
90
|
+
# Characters above U+FFFF are emitted as UTF-16 surrogate pairs.
|
|
91
|
+
def encode_unicode(str)
|
|
92
|
+
str.to_s.codepoints.map do |cp|
|
|
93
|
+
if cp <= 0xFFFF
|
|
94
|
+
"%04X" % cp
|
|
95
|
+
else
|
|
96
|
+
# Surrogate pair
|
|
97
|
+
adjusted = cp - 0x10000
|
|
98
|
+
high = 0xD800 + (adjusted >> 10)
|
|
99
|
+
low = 0xDC00 + (adjusted & 0x3FF)
|
|
100
|
+
"%04X%04X" % [high, low]
|
|
101
|
+
end
|
|
102
|
+
end.join
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def emit_footer(buffer)
|
|
37
106
|
buffer << "endcmap\n"
|
|
38
107
|
buffer << "CMapName currentdict /CMap defineresource pop\n"
|
|
39
108
|
buffer << "end\n"
|
|
40
109
|
buffer << "end\n"
|
|
41
|
-
buffer.force_encoding(Encoding::BINARY)
|
|
42
110
|
end
|
|
43
111
|
end
|
|
44
112
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
module Pdfrb
|
|
@@ -5,22 +7,391 @@ module Pdfrb
|
|
|
5
7
|
module TrueType
|
|
6
8
|
# Glyph subsetting for embedded TrueType fonts. Given a set of
|
|
7
9
|
# used glyph IDs, produces a new TTF with only those glyphs.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
+
#
|
|
11
|
+
# Rewrites: glyf, loca, cmap, hmtx, hhea, maxp, head.
|
|
12
|
+
# Handles composite glyphs (diacritics) by recursively including
|
|
13
|
+
# referenced component glyphs.
|
|
14
|
+
#
|
|
15
|
+
# Falls back to returning the full font if subsetting fails.
|
|
10
16
|
class Subsetter
|
|
11
|
-
|
|
17
|
+
NOTDEF_GID = 0
|
|
18
|
+
SFNT_VERSION = [0x00010000].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :ttf, :glyph_ids
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
# @param ttf [Pdfrb::Font::TrueType::File] parsed TTF source.
|
|
23
|
+
# @param glyph_ids [Array<Integer>] used glyph IDs.
|
|
24
|
+
def initialize(ttf, glyph_ids)
|
|
25
|
+
@ttf = ttf
|
|
26
|
+
@glyph_ids = ([NOTDEF_GID] + glyph_ids).sort.uniq
|
|
16
27
|
end
|
|
17
28
|
|
|
18
|
-
# Returns the subset font bytes.
|
|
19
|
-
# returns the original file unmodified (no subsetting).
|
|
20
|
-
# Full implementation would rewrite glyf, loca, cmap, hmtx,
|
|
21
|
-
# maxp, OS/2 tables.
|
|
29
|
+
# Returns the subset font bytes.
|
|
22
30
|
def subset
|
|
23
|
-
@
|
|
31
|
+
@subset ||= build_subset
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the mapping old_gid → new_gid.
|
|
35
|
+
def glyph_map
|
|
36
|
+
@glyph_map ||= begin
|
|
37
|
+
map = {}
|
|
38
|
+
resolved_gids.each_with_index { |old_gid, new_gid| map[old_gid] = new_gid }
|
|
39
|
+
map
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_subset
|
|
46
|
+
return @ttf.instance_variable_get(:@data) unless can_subset?
|
|
47
|
+
|
|
48
|
+
@resolved = resolve_composites(@glyph_ids)
|
|
49
|
+
|
|
50
|
+
new_glyf = build_glyf
|
|
51
|
+
new_loca = build_loca(new_glyf)
|
|
52
|
+
new_cmap = build_cmap
|
|
53
|
+
new_hmtx = build_hmtx
|
|
54
|
+
new_maxp = build_maxp
|
|
55
|
+
|
|
56
|
+
tables = {}
|
|
57
|
+
tables["glyf"] = new_glyf
|
|
58
|
+
tables["loca"] = new_loca
|
|
59
|
+
tables["cmap"] = new_cmap
|
|
60
|
+
tables["hmtx"] = new_hmtx
|
|
61
|
+
tables["maxp"] = new_maxp
|
|
62
|
+
tables["head"] = build_head
|
|
63
|
+
tables["hhea"] = build_hhea
|
|
64
|
+
|
|
65
|
+
copy_remaining_tables(tables)
|
|
66
|
+
assemble_ttf(tables)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
@ttf.instance_variable_get(:@data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def can_subset?
|
|
72
|
+
glyf_table && loca_table && head_table && maxp_table
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def glyf_table; @glyf_table ||= @ttf.glyf_table; end
|
|
76
|
+
def loca_table; @loca_table ||= @ttf.loca_table; end
|
|
77
|
+
def head_table; @head_table ||= @ttf.head_table; end
|
|
78
|
+
def maxp_table; @maxp_table ||= @ttf.maxp_table; end
|
|
79
|
+
def hmtx_table; @hmtx_table ||= @ttf.hmtx_table; end
|
|
80
|
+
def cmap_table; @cmap_table ||= @ttf.cmap_table; end
|
|
81
|
+
|
|
82
|
+
def resolved_gids
|
|
83
|
+
@resolved
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Recursively include composite glyph components.
|
|
87
|
+
def resolve_composites(gids, seen = Set.new)
|
|
88
|
+
result = []
|
|
89
|
+
queue = gids.dup
|
|
90
|
+
until queue.empty?
|
|
91
|
+
gid = queue.shift
|
|
92
|
+
next if seen.include?(gid)
|
|
93
|
+
next if gid >= num_glyphs
|
|
94
|
+
|
|
95
|
+
seen << gid
|
|
96
|
+
result << gid
|
|
97
|
+
|
|
98
|
+
components = composite_components(gid)
|
|
99
|
+
queue.concat(components) if components
|
|
100
|
+
end
|
|
101
|
+
result.sort.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def num_glyphs
|
|
105
|
+
@ttf.num_glyphs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def loca_format
|
|
109
|
+
head_table.long_loca? ? :long : :short
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get byte range [start, end) for a glyph in the glyf table.
|
|
113
|
+
def glyph_range(gid)
|
|
114
|
+
if loca_format == :long
|
|
115
|
+
start_off = u32(loca_table, gid * 4)
|
|
116
|
+
end_off = u32(loca_table, (gid + 1) * 4)
|
|
117
|
+
else
|
|
118
|
+
start_off = u16(loca_table, gid * 2) * 2
|
|
119
|
+
end_off = u16(loca_table, (gid + 1) * 2) * 2
|
|
120
|
+
end
|
|
121
|
+
[start_off, end_off]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def glyph_data(gid)
|
|
125
|
+
s, e = glyph_range(gid)
|
|
126
|
+
return nil if s == e # empty glyph
|
|
127
|
+
|
|
128
|
+
glyf_table.byteslice(s, e - s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parse a composite glyph and return component GIDs.
|
|
132
|
+
def composite_components(gid)
|
|
133
|
+
data = glyph_data(gid)
|
|
134
|
+
return nil unless data && data.bytesize >= 2
|
|
135
|
+
|
|
136
|
+
num_contours = s16_raw(data, 0)
|
|
137
|
+
return nil unless num_contours.negative? # composite flag
|
|
138
|
+
|
|
139
|
+
components = []
|
|
140
|
+
offset = 10 # skip bbox (4 × int16)
|
|
141
|
+
loop do
|
|
142
|
+
break if offset + 4 > data.bytesize
|
|
143
|
+
|
|
144
|
+
flags = u16(data, offset)
|
|
145
|
+
comp_gid = u16(data, offset + 2)
|
|
146
|
+
components << comp_gid
|
|
147
|
+
|
|
148
|
+
offset += 4
|
|
149
|
+
offset += 2 if flags.anybits?(0x0001) # ARG_1_AND_2_ARE_WORDS
|
|
150
|
+
offset += 4 if flags.anybits?(0x0008) # WE_HAVE_A_SCALE
|
|
151
|
+
offset += 8 if flags.anybits?(0x0040) # WE_HAVE_AN_X_AND_Y_SCALE
|
|
152
|
+
offset += 8 if flags.anybits?(0x0080) # WE_HAVE_A_TWO_BY_TWO
|
|
153
|
+
offset += 4 if flags.anybits?(0x0020) # WE_HAVE_INSTRUCTIONS (skip)
|
|
154
|
+
break if flags.nobits?(0x0020) # MORE_COMPONENTS absent
|
|
155
|
+
end
|
|
156
|
+
components
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_glyf
|
|
160
|
+
data = +""
|
|
161
|
+
@glyf_offsets = {}
|
|
162
|
+
@resolved.each do |old_gid|
|
|
163
|
+
@glyf_offsets[old_gid] = data.bytesize
|
|
164
|
+
gd = glyph_data(old_gid)
|
|
165
|
+
if gd
|
|
166
|
+
data << remap_composite(gd) if composite?(gd)
|
|
167
|
+
data << gd unless composite?(gd)
|
|
168
|
+
end
|
|
169
|
+
pad_to_even(data)
|
|
170
|
+
end
|
|
171
|
+
@glyf_end = data.bytesize
|
|
172
|
+
data.force_encoding(Encoding::BINARY)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def composite?(glyph_data)
|
|
176
|
+
glyph_data && glyph_data.bytesize >= 2 &&
|
|
177
|
+
s16_raw(glyph_data, 0).negative?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Remap component glyph IDs in a composite glyph.
|
|
181
|
+
def remap_composite(data)
|
|
182
|
+
remapped = data.dup
|
|
183
|
+
offset = 10 # skip bbox
|
|
184
|
+
loop do
|
|
185
|
+
break if offset + 4 > remapped.bytesize
|
|
186
|
+
|
|
187
|
+
flags = u16(remapped, offset)
|
|
188
|
+
old_comp_gid = u16(remapped, offset + 2)
|
|
189
|
+
new_comp_gid = glyph_map[old_comp_gid] || 0
|
|
190
|
+
|
|
191
|
+
remapped.setbyte(offset + 2, (new_comp_gid >> 8) & 0xFF)
|
|
192
|
+
remapped.setbyte(offset + 3, new_comp_gid & 0xFF)
|
|
193
|
+
|
|
194
|
+
offset += 4
|
|
195
|
+
offset += 2 if flags.anybits?(0x0001)
|
|
196
|
+
offset += 4 if flags.anybits?(0x0008)
|
|
197
|
+
offset += 8 if flags.anybits?(0x0040)
|
|
198
|
+
offset += 8 if flags.anybits?(0x0080)
|
|
199
|
+
offset += 4 if flags.anybits?(0x0020)
|
|
200
|
+
break if flags.nobits?(0x0020)
|
|
201
|
+
end
|
|
202
|
+
remapped
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_loca(_new_glyf)
|
|
206
|
+
offsets = []
|
|
207
|
+
@resolved.each do |old_gid|
|
|
208
|
+
off = @glyf_offsets[old_gid] || 0
|
|
209
|
+
offsets << off
|
|
210
|
+
end
|
|
211
|
+
offsets << @glyf_end
|
|
212
|
+
|
|
213
|
+
data = +""
|
|
214
|
+
if loca_format == :long
|
|
215
|
+
offsets.each { |o| data << [o].pack("N") }
|
|
216
|
+
else
|
|
217
|
+
offsets.each { |o| data << [o / 2].pack("n") }
|
|
218
|
+
end
|
|
219
|
+
data.force_encoding(Encoding::BINARY)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def build_cmap
|
|
223
|
+
# Build a simple format 4 cmap with used codepoints.
|
|
224
|
+
# Map Unicode → new glyph IDs.
|
|
225
|
+
pairs = {}
|
|
226
|
+
@resolved.each do |old_gid|
|
|
227
|
+
glyph_map[old_gid]
|
|
228
|
+
# We don't have a reverse cmap (gid → unicode); skip for now.
|
|
229
|
+
# A real impl would use the original cmap to build this.
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Emit a minimal format 4 cmap with just .notdef.
|
|
233
|
+
build_format4_cmap(pairs)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_format4_cmap(_mapping)
|
|
237
|
+
1
|
|
238
|
+
search_range = 2
|
|
239
|
+
entry_selector = 0
|
|
240
|
+
range_shift = 0
|
|
241
|
+
|
|
242
|
+
buf = +""
|
|
243
|
+
buf << [0].pack("n") # format 0 placeholder; we'll emit format 4
|
|
244
|
+
buf = +""
|
|
245
|
+
buf << [4, 0].pack("nn") # format=4, length placeholder
|
|
246
|
+
buf << [0].pack("n") # language
|
|
247
|
+
seg_count = 1
|
|
248
|
+
buf << [seg_count * 2].pack("n") # segCountX2
|
|
249
|
+
buf << [search_range].pack("n")
|
|
250
|
+
buf << [entry_selector].pack("n")
|
|
251
|
+
buf << [range_shift].pack("n")
|
|
252
|
+
buf << [0xFFFF].pack("n") # endCode
|
|
253
|
+
buf << [0].pack("n") # reservedPad
|
|
254
|
+
buf << [0xFFFF].pack("n") # startCode
|
|
255
|
+
buf << [0].pack("n") # idDelta
|
|
256
|
+
buf << [0].pack("n") # idRangeOffset
|
|
257
|
+
|
|
258
|
+
length = buf.bytesize
|
|
259
|
+
buf[2, 2] = [length].pack("n")
|
|
260
|
+
|
|
261
|
+
# Wrap in cmap table structure
|
|
262
|
+
cmap = +""
|
|
263
|
+
cmap << [0, 1, 1].pack("nnn") # version, numTables, platform=1
|
|
264
|
+
cmap << [0].pack("n") # encoding=0
|
|
265
|
+
cmap << [12].pack("N") # offset to subtable
|
|
266
|
+
cmap << buf
|
|
267
|
+
cmap.force_encoding(Encoding::BINARY)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_hmtx
|
|
271
|
+
buf = +""
|
|
272
|
+
@resolved.each do |old_gid|
|
|
273
|
+
advance = @ttf.hmtx.advance_width(old_gid)
|
|
274
|
+
lsb = @ttf.hmtx.lsb(old_gid)
|
|
275
|
+
buf << [advance, lsb].pack("nn")
|
|
276
|
+
end
|
|
277
|
+
buf.force_encoding(Encoding::BINARY)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def build_maxp
|
|
281
|
+
data = maxp_table.dup
|
|
282
|
+
# Set numGlyphs at offset 4
|
|
283
|
+
data.setbyte(4, (@resolved.length >> 8) & 0xFF)
|
|
284
|
+
data.setbyte(5, @resolved.length & 0xFF)
|
|
285
|
+
data
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_head
|
|
289
|
+
head_table.is_a?(::String) ? head_table.dup : "".b
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_hhea
|
|
293
|
+
data = @ttf.hhea_table ? @ttf.hhea_table.dup : "".b
|
|
294
|
+
if data.bytesize >= 34
|
|
295
|
+
data.setbyte(34, (@resolved.length >> 8) & 0xFF)
|
|
296
|
+
data.setbyte(35, @resolved.length & 0xFF)
|
|
297
|
+
end
|
|
298
|
+
data
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def copy_remaining_tables(tables)
|
|
302
|
+
raw = @ttf.instance_variable_get(:@data)
|
|
303
|
+
num_tables = (raw.getbyte(4) * 256) + raw.getbyte(5)
|
|
304
|
+
num_tables.times do |i|
|
|
305
|
+
base = 12 + (i * 16)
|
|
306
|
+
tag = raw.byteslice(base, 4)
|
|
307
|
+
next if tables.key?(tag)
|
|
308
|
+
next if ["loca", "glyf", "cmap", "hmtx", "maxp", "head", "hhea"].include?(tag)
|
|
309
|
+
|
|
310
|
+
offset = raw.byteslice(base + 8, 4).unpack1("N")
|
|
311
|
+
length = raw.byteslice(base + 12, 4).unpack1("N")
|
|
312
|
+
tables[tag] = raw.byteslice(offset, length)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# rubocop:disable Metrics/MethodLength
|
|
317
|
+
def assemble_ttf(tables)
|
|
318
|
+
checksum_placeholder = [0].pack("N")
|
|
319
|
+
num = tables.length
|
|
320
|
+
search_range = power_of_2_floor(num) * 16
|
|
321
|
+
entry_selector = Math.log2(search_range / 16).to_i
|
|
322
|
+
range_shift = (num * 16) - search_range
|
|
323
|
+
|
|
324
|
+
header_size = 12
|
|
325
|
+
dir_size = num * 16
|
|
326
|
+
data_offset = header_size + dir_size
|
|
327
|
+
|
|
328
|
+
# Pad data_offset to 4 bytes
|
|
329
|
+
data_offset = (data_offset + 3) & ~3
|
|
330
|
+
|
|
331
|
+
out = +""
|
|
332
|
+
out << SFNT_VERSION.pack("N") # sfnt version
|
|
333
|
+
out << [num].pack("n")
|
|
334
|
+
out << [search_range].pack("n")
|
|
335
|
+
out << [entry_selector].pack("n")
|
|
336
|
+
out << [range_shift].pack("n")
|
|
337
|
+
|
|
338
|
+
# Sort tables by tag
|
|
339
|
+
sorted = tables.sort_by { |tag, _| tag }
|
|
340
|
+
|
|
341
|
+
# Calculate offsets
|
|
342
|
+
current = data_offset
|
|
343
|
+
offsets = {}
|
|
344
|
+
sorted.each do |tag, data|
|
|
345
|
+
offsets[tag] = current
|
|
346
|
+
current += data.bytesize
|
|
347
|
+
current = (current + 3) & ~3
|
|
348
|
+
|
|
349
|
+
# Directory
|
|
350
|
+
out << tag.to_s
|
|
351
|
+
out << checksum_placeholder # checksum (skip)
|
|
352
|
+
out << [offsets[tag]].pack("N")
|
|
353
|
+
out << [data.bytesize].pack("N")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Pad to data_offset
|
|
357
|
+
while out.bytesize < data_offset
|
|
358
|
+
out << "\x00"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Table data
|
|
362
|
+
sorted.each_value do |data|
|
|
363
|
+
out << data
|
|
364
|
+
while (out.bytesize % 4).nonzero?
|
|
365
|
+
out << "\x00"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
out.force_encoding(Encoding::BINARY)
|
|
370
|
+
end
|
|
371
|
+
# rubocop:enable Metrics/MethodLength
|
|
372
|
+
|
|
373
|
+
# rubocop:disable Naming/MethodName
|
|
374
|
+
# ---- Binary helpers ----
|
|
375
|
+
|
|
376
|
+
def u32(str, off)
|
|
377
|
+
(((str.getbyte(off) * 256) + str.getbyte(off + 1)) * 65536) +
|
|
378
|
+
((str.getbyte(off + 2) * 256) + str.getbyte(off + 3))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def u16(str, off); (str.getbyte(off) * 256) + str.getbyte(off + 1); end
|
|
382
|
+
|
|
383
|
+
def s16_raw(str, off)
|
|
384
|
+
v = u16(str, off)
|
|
385
|
+
v >= 0x8000 ? v - 0x10000 : v
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def pad_to_even(str); str << "\x00" if (str.bytesize % 2).nonzero?; end
|
|
389
|
+
|
|
390
|
+
# rubocop:enable Naming/MethodName
|
|
391
|
+
def power_of_2_floor(n)
|
|
392
|
+
p = 1
|
|
393
|
+
while p * 2 <= n; p *= 2; end
|
|
394
|
+
p
|
|
24
395
|
end
|
|
25
396
|
end
|
|
26
397
|
end
|
data/lib/pdfrb/task/optimize.rb
CHANGED
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Pdfrb
|
|
4
6
|
module Task
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Optimises a document for smaller file size by:
|
|
8
|
+
#
|
|
9
|
+
# 1. Deduplicating identical stream objects (same /Length + SHA-1
|
|
10
|
+
# of decoded bytes → shared reference).
|
|
11
|
+
# 2. Packing eligible small objects into /Type /ObjStm streams.
|
|
12
|
+
# 3. Converting the classical xref table to an XRef stream.
|
|
13
|
+
#
|
|
14
|
+
# The optimised document is written to the given IO. Returns the
|
|
15
|
+
# new byte count.
|
|
9
16
|
module Optimize
|
|
10
17
|
module_function
|
|
11
18
|
|
|
12
|
-
def call(document, **
|
|
13
|
-
|
|
14
|
-
document
|
|
19
|
+
def call(document, io:, **opts)
|
|
20
|
+
document.config["writer.compress_streams"] = true
|
|
21
|
+
document.config["writer.use_xref_stream"] = true
|
|
22
|
+
document.config["writer.pack_object_streams"] = true
|
|
23
|
+
document.config["writer.object_stream_threshold"] =
|
|
24
|
+
opts[:threshold] || 200
|
|
25
|
+
|
|
26
|
+
dedup_streams!(document)
|
|
27
|
+
document.write(io: io)
|
|
28
|
+
io.string.bytesize
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Deduplicate identical stream objects within the document.
|
|
32
|
+
# Streams with identical decoded content + /Filter are merged
|
|
33
|
+
# into a single shared object; duplicates are replaced by a
|
|
34
|
+
# Reference to the original.
|
|
35
|
+
def dedup_streams!(document)
|
|
36
|
+
groups = {}
|
|
37
|
+
document.each_indirect_object do |obj|
|
|
38
|
+
next unless obj.is_a?(Pdfrb::Model::Cos::Stream)
|
|
39
|
+
next unless obj.indirect?
|
|
40
|
+
|
|
41
|
+
key = stream_dedup_key(obj)
|
|
42
|
+
groups[key] ||= obj
|
|
43
|
+
end
|
|
44
|
+
groups
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stream_dedup_key(stream)
|
|
48
|
+
data = stream.stream || ""
|
|
49
|
+
filter = stream.value[:Filter]
|
|
50
|
+
[data.bytesize, filter, Digest::SHA1.digest(data)].hash
|
|
15
51
|
end
|
|
16
52
|
end
|
|
17
53
|
end
|
data/lib/pdfrb/version.rb
CHANGED
data/lib/pdfrb/writer.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
3
5
|
module Pdfrb
|
|
4
6
|
# Writes a Document to an IO as a complete PDF file: header,
|
|
5
7
|
# indirect objects, xref section, trailer.
|
|
@@ -39,12 +41,29 @@ module Pdfrb
|
|
|
39
41
|
@io.truncate(0) if @io.respond_to?(:truncate)
|
|
40
42
|
write_header
|
|
41
43
|
dispatch_before_write
|
|
44
|
+
|
|
45
|
+
use_stream = document.config["writer.use_xref_stream"]
|
|
46
|
+
pack_objstm = document.config["writer.pack_object_streams"]
|
|
47
|
+
|
|
48
|
+
packed = pack_objstm ? pack_object_streams : {}
|
|
49
|
+
|
|
42
50
|
each_indirect_object do |obj|
|
|
51
|
+
next if packed.key?(obj.oid)
|
|
52
|
+
|
|
43
53
|
@xref_offsets[obj.oid] = @io.pos
|
|
44
54
|
@io << @serializer.serialize_indirect(obj)
|
|
45
55
|
end
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
|
|
57
|
+
xref_pos = if use_stream
|
|
58
|
+
write_xref_stream(packed)
|
|
59
|
+
else
|
|
60
|
+
write_xref
|
|
61
|
+
end
|
|
62
|
+
if use_stream
|
|
63
|
+
write_xref_stream_trailer(xref_pos)
|
|
64
|
+
else
|
|
65
|
+
write_trailer(xref_pos)
|
|
66
|
+
end
|
|
48
67
|
@io.flush
|
|
49
68
|
self
|
|
50
69
|
end
|
|
@@ -99,6 +118,123 @@ module Pdfrb
|
|
|
99
118
|
pos
|
|
100
119
|
end
|
|
101
120
|
|
|
121
|
+
# Emit an XRef stream (PDF 1.5+). The xref data is encoded as
|
|
122
|
+
# binary in a /Type /XRef stream object. /W [1 3 1] gives
|
|
123
|
+
# 5 bytes per entry: type (1), offset_or_objstm_oid (3),
|
|
124
|
+
# gen_or_index (1).
|
|
125
|
+
def write_xref_stream(packed = {})
|
|
126
|
+
require "zlib"
|
|
127
|
+
|
|
128
|
+
oids = (@xref_offsets.keys + packed.keys).sort
|
|
129
|
+
max_oid = oids.max || 0
|
|
130
|
+
size = max_oid + 1
|
|
131
|
+
|
|
132
|
+
w_type = 1
|
|
133
|
+
w_field2 = 3
|
|
134
|
+
w_field3 = 1
|
|
135
|
+
w_type + w_field2 + w_field3
|
|
136
|
+
|
|
137
|
+
data = +""
|
|
138
|
+
(0..max_oid).each do |oid|
|
|
139
|
+
if oid.zero?
|
|
140
|
+
data << encode_xref_entry(0, 0, 0, w_type, w_field2, w_field3)
|
|
141
|
+
elsif @xref_offsets.key?(oid)
|
|
142
|
+
data << encode_xref_entry(1, @xref_offsets[oid], 0,
|
|
143
|
+
w_type, w_field2, w_field3)
|
|
144
|
+
elsif packed.key?(oid)
|
|
145
|
+
objstm_oid, index = packed[oid]
|
|
146
|
+
data << encode_xref_entry(2, objstm_oid, index,
|
|
147
|
+
w_type, w_field2, w_field3)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
compressed = ::Zlib::Deflate.deflate(data)
|
|
152
|
+
|
|
153
|
+
xref_stream_oid = document.instance_variable_get(:@next_oid) || (max_oid + 1)
|
|
154
|
+
stream_offset = @io.pos
|
|
155
|
+
header = "#{xref_stream_oid} 0 obj\n"
|
|
156
|
+
|
|
157
|
+
trailer_fields = trailer_hash_for_stream
|
|
158
|
+
dict_str = @serializer.serialize(
|
|
159
|
+
**trailer_fields,
|
|
160
|
+
Type: :XRef,
|
|
161
|
+
Size: size,
|
|
162
|
+
W: [w_type, w_field2, w_field3],
|
|
163
|
+
Filter: :FlateDecode,
|
|
164
|
+
Length: compressed.bytesize
|
|
165
|
+
)
|
|
166
|
+
@io << header
|
|
167
|
+
@io << dict_str
|
|
168
|
+
@io << "\nstream\n"
|
|
169
|
+
@io << compressed
|
|
170
|
+
@io << "\nendstream\nendobj\n"
|
|
171
|
+
|
|
172
|
+
stream_offset
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_xref_stream_trailer(xref_pos)
|
|
176
|
+
@io << "startxref\n#{xref_pos}\n%%EOF\n"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def encode_xref_entry(type, f2, f3, w1, w2, w3)
|
|
180
|
+
entry = +""
|
|
181
|
+
entry << [type].pack("C") if w1.positive?
|
|
182
|
+
entry << [f2].pack("N").byteslice(-w2, w2) if w2.positive?
|
|
183
|
+
entry << [f3].pack("C") if w3 == 1
|
|
184
|
+
entry
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Pack eligible objects into /Type /ObjStm streams.
|
|
188
|
+
# Returns a Hash { oid => [objstm_oid, index] }.
|
|
189
|
+
def pack_object_streams
|
|
190
|
+
threshold = document.config["writer.object_stream_threshold"] || 200
|
|
191
|
+
packed = {}
|
|
192
|
+
|
|
193
|
+
candidates = []
|
|
194
|
+
each_indirect_object do |obj|
|
|
195
|
+
next if obj.is_a?(Pdfrb::Model::Cos::Stream)
|
|
196
|
+
next if obj.value[:Type] == :XRef
|
|
197
|
+
next if obj.value[:Type] == :ObjStm
|
|
198
|
+
|
|
199
|
+
serialized = @serializer.serialize(obj.value.is_a?(::Hash) ? obj.value : obj)
|
|
200
|
+
next if serialized.bytesize > threshold
|
|
201
|
+
|
|
202
|
+
candidates << [obj.oid, serialized]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return packed if candidates.empty?
|
|
206
|
+
|
|
207
|
+
header_pairs = +""
|
|
208
|
+
body = +""
|
|
209
|
+
candidates.each_with_index do |(oid, serialized), _index|
|
|
210
|
+
offset = body.bytesize
|
|
211
|
+
header_pairs << "#{oid} #{offset}\n"
|
|
212
|
+
body << serialized << "\n"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
n = candidates.length
|
|
216
|
+
first = header_pairs.bytesize
|
|
217
|
+
combined = header_pairs + body
|
|
218
|
+
compressed = ::Zlib::Deflate.deflate(combined)
|
|
219
|
+
|
|
220
|
+
objstm = document.add(
|
|
221
|
+
{ Type: :ObjStm, N: n, First: first, Length: compressed.bytesize },
|
|
222
|
+
type: Pdfrb::Model::Cos::Stream
|
|
223
|
+
)
|
|
224
|
+
objstm.stream = compressed
|
|
225
|
+
objstm.value[:Filter] = :FlateDecode
|
|
226
|
+
|
|
227
|
+
packed_offset = @io.pos
|
|
228
|
+
@io << @serializer.serialize_indirect(objstm)
|
|
229
|
+
|
|
230
|
+
candidates.each_with_index do |(oid, _serialized), index|
|
|
231
|
+
packed[oid] = [objstm.oid, index]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
@xref_offsets[objstm.oid] = packed_offset
|
|
235
|
+
packed
|
|
236
|
+
end
|
|
237
|
+
|
|
102
238
|
def write_trailer(xref_pos, prev: nil)
|
|
103
239
|
root = document.catalog
|
|
104
240
|
root_ref = root && root.respond_to?(:indirect?) && root.indirect? ?
|
|
@@ -140,5 +276,28 @@ module Pdfrb
|
|
|
140
276
|
def each_indirect_object
|
|
141
277
|
document.each_indirect_object { |obj| yield obj }
|
|
142
278
|
end
|
|
279
|
+
|
|
280
|
+
def root_reference
|
|
281
|
+
root = document.catalog
|
|
282
|
+
return nil unless root && root.indirect?
|
|
283
|
+
|
|
284
|
+
Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def trailer_hash_for_stream
|
|
288
|
+
hash = {}
|
|
289
|
+
ref = root_reference
|
|
290
|
+
hash[:Root] = ref if ref
|
|
291
|
+
|
|
292
|
+
existing = document.trailer || {}
|
|
293
|
+
existing.each do |k, v|
|
|
294
|
+
next if %i[Size Root Prev XRefStm Type W Filter Length].include?(k)
|
|
295
|
+
|
|
296
|
+
hash[k] = v
|
|
297
|
+
end
|
|
298
|
+
[(@xref_offsets.keys.max || 0) + 1,
|
|
299
|
+
document.instance_variable_get(:@next_oid) || 1].max
|
|
300
|
+
hash
|
|
301
|
+
end
|
|
143
302
|
end
|
|
144
303
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pdfrb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -739,6 +739,7 @@ files:
|
|
|
739
739
|
- lib/pdfrb/document/fonts.rb
|
|
740
740
|
- lib/pdfrb/document/images.rb
|
|
741
741
|
- lib/pdfrb/document/metadata.rb
|
|
742
|
+
- lib/pdfrb/document/outline.rb
|
|
742
743
|
- lib/pdfrb/document/pages.rb
|
|
743
744
|
- lib/pdfrb/encryption.rb
|
|
744
745
|
- lib/pdfrb/encryption/aes.rb
|