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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b4392d590986dd2cf33d0596f355ea5cf05c32cf8bf0635ad069aa27bc1dfa4
4
- data.tar.gz: c23d124eff4500baa4488ba6fa3c5353e37b191ae795708006aad46402f67758
3
+ metadata.gz: 06cc47c628b9d603882cbfc10b2b5fc0beb6b2e1eabf672fa215c6ee3ae94429
4
+ data.tar.gz: a9da7630cee4f6ada3c6a7c06260c7416cf9de6cb3efac7d844361c557d8df6e
5
5
  SHA512:
6
- metadata.gz: efd253a06b41184caaa0f24dc3652a8216efe7bac7b926d8af3b645158fd467b1a3d2b2c12a5ab8c8c11da9c18abc4945934936e20e2b163008327d6a1421d8e
7
- data.tar.gz: 3331dccbeff017114f54ce22cc36fe5977d20d497d889ebb4417602b9ca172f86564ce1af33913fba8b94e9376216904c8c85a4eaddf8adcf0b8fcfe82063592
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
@@ -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
@@ -2,27 +2,131 @@
2
2
 
3
3
  module Pdfrb
4
4
  class Document
5
- # Attached-files facade (stub). Full implementation lands in TODO 128.
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
- def add(_io, name:, **_opts)
14
- raise NotImplementedError,
15
- "File embedding lands in TODO 128 (per App Note 002 — Associated Files)"
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
- names_tree = document.catalog.value.dig(:Names, :EmbeddedFiles)
22
- return self unless names_tree
23
- # Real implementation walks the name-tree.
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
@@ -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 =~ /\Abeginbfchar\b/
49
+ if lines[i].strip =~ /beginbfchar\b/
50
50
  i += 1
51
- until lines[i].strip == "endbfchar"
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
- if raw.length == 4
88
- [raw.to_i(16)].pack("U")
89
- else
90
- raw.scan(/.{4}/).map { |h| h.to_i(16) }.pack("U*")
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 +bfchar+ mapping. Used when
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
- def initialize(cmap_name:, cid_system_info:, mapping:)
12
- @cmap_name = cmap_name
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 << " <0000> <FFFF>\n"
57
+ buffer << " <#{lo}> <#{hi}>\n"
31
58
  buffer << "endcodespacerange\n"
32
- buffer << "#{@mapping.length} beginbfchar\n"
33
- @mapping.each do |code, unicode|
34
- buffer << "<%04X> <%04X>\n" % [code, unicode]
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
- buffer << "endbfchar\n"
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
@@ -83,7 +83,6 @@ module Pdfrb
83
83
  # must check the type explicitly.
84
84
  def as_parms_list(parms)
85
85
  case parms
86
- when nil then []
87
86
  when ::Array then parms
88
87
  when Pdfrb::Model::PdfArray then parms.value
89
88
  when ::Hash then [parms]
@@ -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
- # Placeholder for the optimise task. Real implementation needs
6
- # TODO 31 (object-stream packing) + dedup hash + xref-stream
7
- # conversion. For now: no-op pass-through so the CLI can wire
8
- # without raising.
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, **_opts)
13
- # TODO: dedup, pack into ObjStm, switch to xref stream.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pdfrb
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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
- xref_pos = write_xref
47
- write_trailer(xref_pos)
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.1.1
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