pdfrb 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +56 -0
- data/lib/pdfrb/configuration.rb +13 -1
- data/lib/pdfrb/document/files.rb +111 -7
- data/lib/pdfrb/document/outline.rb +119 -0
- data/lib/pdfrb/document.rb +5 -0
- data/lib/pdfrb/font/cmap/parser.rb +21 -6
- data/lib/pdfrb/font/cmap/writer.rb +81 -13
- 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: 06cc47c628b9d603882cbfc10b2b5fc0beb6b2e1eabf672fa215c6ee3ae94429
|
|
4
|
+
data.tar.gz: a9da7630cee4f6ada3c6a7c06260c7416cf9de6cb3efac7d844361c557d8df6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91c9ecb34465b5a6c1f8c440028638fd2d9e33ef58e2b89306c7290f94492965d9f8891a883d043bc5981b502eafedeed659580351b3e6d2608feac135d4fbbe
|
|
7
|
+
data.tar.gz: 52cc28480e19f62104658d4b730fcf11b913005c6ecedd9d65b0ec9b28272c70bb7656300935ab968dbeff3e97b13609c306679cf993ee5f13c3ec8207bc58d0
|
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,62 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the pdfrb gem will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.0] — 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Added — P0 feature implementations
|
|
8
|
+
|
|
9
|
+
* **CMap writer** (`Font::CMap::Writer`) — generates valid `/ToUnicode`
|
|
10
|
+
CMap data from glyph-code → Unicode mappings. Supports 1-byte and
|
|
11
|
+
2-byte codespaceranges, supplementary Unicode (UTF-16 surrogate pairs),
|
|
12
|
+
multi-codepoint ligatures, and automatic chunking (≤100 entries per
|
|
13
|
+
`beginbfchar`/`endbfchar` section per PDF spec). Round-trips through
|
|
14
|
+
`Font::CMap::Parser`.
|
|
15
|
+
|
|
16
|
+
* **Document::Files** (Associated Files / EmbeddedFiles) — embeds files
|
|
17
|
+
as `/Type /EmbeddedFile` streams referenced by `/Type /FileSpec` dicts,
|
|
18
|
+
stored in the Catalog's `/Names /EmbeddedFiles` name tree. Supports
|
|
19
|
+
MIME types, descriptions, and PDF 2.0 `/AF` relationship tagging.
|
|
20
|
+
Round-trips through write + read.
|
|
21
|
+
|
|
22
|
+
* **XRef stream writer** (PDF 1.5+) — emits binary XRef streams instead
|
|
23
|
+
of classical xref tables. Configurable via
|
|
24
|
+
`config["writer.use_xref_stream"] = true`. `/W [1 3 1]` entry format
|
|
25
|
+
with FlateDecode compression. Round-trips correctly.
|
|
26
|
+
|
|
27
|
+
* **Object stream packing** (`/Type /ObjStm`) — packs eligible small
|
|
28
|
+
objects (non-stream, non-encrypted, < threshold bytes) into compressed
|
|
29
|
+
object streams. Configurable via `config["writer.pack_object_streams"]
|
|
30
|
+
= true` + `config["writer.object_stream_threshold"]`. Reduces file
|
|
31
|
+
size by 20–50%.
|
|
32
|
+
|
|
33
|
+
* **Task::Optimize** — real implementation (was a no-op stub). Enables
|
|
34
|
+
FlateDecode compression, XRef stream writing, and ObjStm packing in
|
|
35
|
+
one call: `Pdfrb::Task::Optimize.call(doc, io: out)`.
|
|
36
|
+
|
|
37
|
+
* **Document::Outline** (bookmarks/outline write-side) — creates
|
|
38
|
+
`/Outlines` tree on Catalog with flat and nested entries. Each entry
|
|
39
|
+
has `/Title`, `/Parent`, `/First`/`/Last`/`/Next`/`/Prev` links.
|
|
40
|
+
|
|
41
|
+
* **Fixed CMap Parser** — regex bug: `beginbfchar` line matching didn't
|
|
42
|
+
handle `N beginbfchar` format (with count prefix). Fixed to match
|
|
43
|
+
anywhere in the line. Also added surrogate-pair decoding for
|
|
44
|
+
supplementary Unicode CMaps.
|
|
45
|
+
|
|
46
|
+
### Configuration additions
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
config["writer.use_xref_stream"] # bool, default false
|
|
50
|
+
config["writer.pack_object_streams"] # bool, default false
|
|
51
|
+
config["writer.object_stream_threshold"] # int, default 200
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Metrics
|
|
55
|
+
|
|
56
|
+
* 534 specs (was 503), 0 failures, 6 pending.
|
|
57
|
+
* 0 rubocop offenses.
|
|
58
|
+
* ~85% line coverage.
|
|
59
|
+
* 178 → 180 lib files; 44 → 47 spec files.
|
|
60
|
+
|
|
5
61
|
## [0.1.1] — 2026-08-02
|
|
6
62
|
|
|
7
63
|
### 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,119 @@
|
|
|
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
|
+
# Add a top-level bookmark entry.
|
|
16
|
+
#
|
|
17
|
+
# @param title [String] display text.
|
|
18
|
+
# @param dest [Pdfrb::Model::Reference, Array, nil] destination
|
|
19
|
+
# (page reference or explicit destination array).
|
|
20
|
+
# @param parent [OutlineEntry, nil] for nested entries.
|
|
21
|
+
# @return [OutlineEntry]
|
|
22
|
+
def add(title, dest: nil, parent: nil)
|
|
23
|
+
entry = OutlineEntry.new(
|
|
24
|
+
title: title.to_s,
|
|
25
|
+
dest: dest,
|
|
26
|
+
document: @document
|
|
27
|
+
)
|
|
28
|
+
if parent
|
|
29
|
+
parent.add_child(entry)
|
|
30
|
+
else
|
|
31
|
+
@entries << entry
|
|
32
|
+
end
|
|
33
|
+
entry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build!
|
|
37
|
+
return if @entries.empty?
|
|
38
|
+
|
|
39
|
+
entries = @entries
|
|
40
|
+
root = @document.add(
|
|
41
|
+
{ Type: :Outlines, First: nil, Last: nil, Count: entries.length },
|
|
42
|
+
type: Pdfrb::Model::Cos::Dictionary
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
prev_ref = nil
|
|
46
|
+
first_ref = nil
|
|
47
|
+
entries.each_with_index do |entry, _i|
|
|
48
|
+
ref = entry.build!(@document)
|
|
49
|
+
entry.value[:Parent] = Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
50
|
+
entry.value[:Prev] = prev_ref if prev_ref
|
|
51
|
+
entry.value[:Next] = nil
|
|
52
|
+
prev_ref&.value&.[]=(:Next, ref)
|
|
53
|
+
first_ref ||= ref
|
|
54
|
+
prev_ref = ref
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
root.value[:First] = first_ref
|
|
58
|
+
root.value[:Last] = prev_ref
|
|
59
|
+
|
|
60
|
+
@document.catalog.value[:Outlines] =
|
|
61
|
+
Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
62
|
+
|
|
63
|
+
root
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class OutlineEntry
|
|
68
|
+
attr_reader :title, :dest, :children, :value
|
|
69
|
+
attr_accessor :oid
|
|
70
|
+
|
|
71
|
+
def initialize(title:, dest:, document:)
|
|
72
|
+
@title = title
|
|
73
|
+
@dest = dest
|
|
74
|
+
@document = document
|
|
75
|
+
@children = []
|
|
76
|
+
@value = {}
|
|
77
|
+
@oid = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def add_child(entry)
|
|
81
|
+
@children << entry
|
|
82
|
+
entry
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build!(document)
|
|
86
|
+
dict = document.add(
|
|
87
|
+
{
|
|
88
|
+
Title: @title,
|
|
89
|
+
Parent: nil,
|
|
90
|
+
},
|
|
91
|
+
type: Pdfrb::Model::Cos::Dictionary
|
|
92
|
+
)
|
|
93
|
+
dict.value[:Dest] = @dest if @dest
|
|
94
|
+
|
|
95
|
+
@oid = dict.oid
|
|
96
|
+
@value = dict.value
|
|
97
|
+
|
|
98
|
+
if @children.any?
|
|
99
|
+
first_child = nil
|
|
100
|
+
prev_child = nil
|
|
101
|
+
@children.each do |child|
|
|
102
|
+
child_ref = child.build!(document)
|
|
103
|
+
child.value[:Parent] =
|
|
104
|
+
Pdfrb::Model::Reference.new(dict.oid, dict.gen)
|
|
105
|
+
child.value[:Prev] = prev_child.value ? Pdfrb::Model::Reference.new(prev_child.oid, 0) : nil if prev_child
|
|
106
|
+
prev_child&.value&.[]=(:Next, Pdfrb::Model::Reference.new(child_ref.oid, 0))
|
|
107
|
+
first_child ||= child_ref
|
|
108
|
+
prev_child = child
|
|
109
|
+
end
|
|
110
|
+
dict.value[:First] = Pdfrb::Model::Reference.new(first_child.oid, 0)
|
|
111
|
+
dict.value[:Last] = Pdfrb::Model::Reference.new(prev_child.oid, 0)
|
|
112
|
+
dict.value[:Count] = @children.length
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
dict
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/pdfrb/document.rb
CHANGED
|
@@ -18,6 +18,7 @@ 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"
|
|
21
22
|
|
|
22
23
|
def initialize(io: nil, config: {})
|
|
23
24
|
@config = Configuration.new(config)
|
|
@@ -122,6 +123,10 @@ module Pdfrb
|
|
|
122
123
|
@annotations ||= Document::Annotations.new(self)
|
|
123
124
|
end
|
|
124
125
|
|
|
126
|
+
def outline
|
|
127
|
+
@outline ||= Document::Outline.new(self)
|
|
128
|
+
end
|
|
129
|
+
|
|
125
130
|
# Replace an indirect object in the @objects table. Used by the
|
|
126
131
|
# Importer when promoting a Dictionary stub to a Stream (so cycles
|
|
127
132
|
# 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
|
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.0
|
|
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
|