pdfrb 0.1.0 → 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: f000ed06c1fae85295e309ae1608acd27eac3c778d974e11aa94bb148e6670c4
4
- data.tar.gz: 574f308269fc21fbb8f673da74fe24ff6c69d35db7b979bfd8f5fff98d40fdff
3
+ metadata.gz: 06cc47c628b9d603882cbfc10b2b5fc0beb6b2e1eabf672fa215c6ee3ae94429
4
+ data.tar.gz: a9da7630cee4f6ada3c6a7c06260c7416cf9de6cb3efac7d844361c557d8df6e
5
5
  SHA512:
6
- metadata.gz: 52475bfc6f98607fbabffd15154896dc2881e63411e9c6b0d86d1f25098bcab42b4f02a2f5a3ca774ef083fbff494830aa6041ff3776fcdaf5eec656681e27b0
7
- data.tar.gz: 5717089d679437ccbf20c2fbc67a2bae9bdf156ecc268cb7fc3a75a4bf2136cc623e1e09f6268e25e157c726b0a10b9164cef319b87e2731b67f1f95aac92ff7
6
+ metadata.gz: 91c9ecb34465b5a6c1f8c440028638fd2d9e33ef58e2b89306c7290f94492965d9f8891a883d043bc5981b502eafedeed659580351b3e6d2608feac135d4fbbe
7
+ data.tar.gz: 52cc28480e19f62104658d4b730fcf11b913005c6ecedd9d65b0ec9b28272c70bb7656300935ab968dbeff3e97b13609c306679cf993ee5f13c3ec8207bc58d0
data/.rubocop.yml CHANGED
@@ -20,3 +20,13 @@ AllCops:
20
20
 
21
21
  # Project-specific: TSV files are data, not code; PDFs are binary.
22
22
  # Both are excluded via AllCops above. Local overrides go here.
23
+
24
+ # Integration tests use string describes ("FOP-generated PDF reading")
25
+ # because they exercise multiple classes through a real workflow.
26
+ RSpec/DescribeClass:
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,123 @@
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
+
61
+ ## [0.1.1] — 2026-08-02
62
+
63
+ ### Housekeeping
64
+
65
+ * **Layout/Composer extracted to sibling `arroolio` gem.** Pdfrb is now
66
+ pure PDF: bytes ↔ model. No page templates, no flows, no Knuth-Plass,
67
+ no tables, no SVG. Removed `lib/pdfrb/layout/`, `lib/pdfrb/composer.rb`,
68
+ `lib/pdfrb/layout.rb`, `spec/pdfrb/layout/`, `TODO.layout/`.
69
+ * **Removed `rexml` runtime dependency** (was only needed for the FO
70
+ parser, which moved to Arroolio).
71
+ * **Fixed all 12 rubocop offenses.** 0 offenses remaining. Disabled
72
+ `RSpec/DescribeClass` (legitimate string describes in integration tests).
73
+ * **Added SimpleCov** (opt-in via `COVERAGE=1` env var). Current line
74
+ coverage: **84.5%**. Grouped by Source / Model / Filters / Content /
75
+ Font / Encryption / Tasks / CLI / Document.
76
+ * **Replaced 3 `pending` specs with `skip`** — clearer exit semantics
77
+ for strict CI runs.
78
+ * **Fixed `Model::Cos::Stream#as_parms_list`** — Hash parms now
79
+ correctly wraps to `[parms]` instead of `Array(parms)` which
80
+ produced `[[:k, :v], ...]`. Bug was hidden by tests that didn't
81
+ exercise DecodeParms as a Hash through the Stream facade.
82
+ * **Renamed CMap format dispatchers** — `parse_format_4` →
83
+ `parse_format_four`, etc. (rubocop `Naming/VariableNumber`).
84
+ * **README/CLAUDE.md** updated to reflect pure-PDF scope; layout
85
+ concerns documented as living in `arroolio`.
86
+
87
+ ### Verified
88
+
89
+ * 503 examples, 0 failures, 3 pending (conformance specs awaiting
90
+ fixture corpus).
91
+ * 0 rubocop offenses.
92
+ * `gem build pdfrb.gemspec` produces `pdfrb-0.1.1.gem`.
93
+ * End-to-end round-trip: build PDF → write → read → extract text →
94
+ matches.
95
+
96
+ ## [0.1.0] — 2026-08-02
97
+
98
+ ### Initial release
99
+
100
+ Pure-Ruby PDF library: byte-level reader, Arlington-model-driven typed
101
+ domain model, and serializer.
102
+
103
+ * Source: bytes → tokens → COS graph.
104
+ * Model: typed COS values + Type::* semantics, sourced from vendored
105
+ Arlington PDF Model TSVs (ISO 32000-2:2020).
106
+ * Filter pipeline: FlateDecode, ASCII-Hex, ASCII-85, LZW, RunLength,
107
+ CCITTFax, DCT, JPX, JBIG2, Crypt.
108
+ * Content: 52 content-stream operators + Canvas drawing API.
109
+ * Encryption: RC4 (pure Ruby), AES (OpenSSL), StandardSecurityHandler
110
+ (V1-V6 / R2-R6).
111
+ * Font machinery: AFM parser, 5 encoding tables, CMap parser, TrueType
112
+ file parser (head/hhea/hmtx/cmap/OS-2), Type1 metrics.
113
+ * Image loaders: JPEG, PNG, PDF page import.
114
+ * Tasks: ExtractText (with /ToUnicode CMap), ExtractImages, Merge,
115
+ Optimize.
116
+ * CLI: version, info, tree, merge, extract-text/images, encrypt,
117
+ decrypt, optimize, form.
118
+ * Conformance: PDF/A + PDF/UA rule subsets.
119
+ * Round-trip property tested against ISO 32000-2 Annex H examples
120
+ (12 PDFs).
121
+
5
122
  ## [Unreleased]
6
123
 
7
124
  ### Added — 2026-08-01 (layout engine session)
data/Gemfile CHANGED
@@ -11,3 +11,5 @@ gem "rubocop", "~> 1.75"
11
11
  gem "rubocop-performance", "~> 1.23"
12
12
  gem "rubocop-rake", "~> 0.7"
13
13
  gem "rubocop-rspec", "~> 3.5"
14
+
15
+ gem "simplecov", require: false, group: :test
@@ -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
@@ -74,10 +74,10 @@ module Pdfrb
74
74
  def read_format_header
75
75
  @format = u16(@subtable_offset)
76
76
  case @format
77
- when 0 then parse_format_0
78
- when 4 then parse_format_4
79
- when 6 then parse_format_6
80
- when 12 then parse_format_12
77
+ when 0 then parse_format_zero
78
+ when 4 then parse_format_four
79
+ when 6 then parse_format_six
80
+ when 12 then parse_format_twelve
81
81
  else
82
82
  @format = nil
83
83
  end
@@ -85,27 +85,27 @@ module Pdfrb
85
85
 
86
86
  def lookup(unicode)
87
87
  case @format
88
- when 0 then lookup_format_0(unicode)
89
- when 4 then lookup_format_4(unicode)
90
- when 6 then lookup_format_6(unicode)
91
- when 12 then lookup_format_12(unicode)
88
+ when 0 then lookup_format_zero(unicode)
89
+ when 4 then lookup_format_four(unicode)
90
+ when 6 then lookup_format_six(unicode)
91
+ when 12 then lookup_format_twelve(unicode)
92
92
  else 0
93
93
  end
94
94
  end
95
95
 
96
96
  # Format 0: 256-entry byte table.
97
- def parse_format_0
97
+ def parse_format_zero
98
98
  @f0_glyph_ids = (0..255).map { |i| u8(@subtable_offset + 6 + i) }
99
99
  end
100
100
 
101
- def lookup_format_0(unicode)
101
+ def lookup_format_zero(unicode)
102
102
  return 0 unless unicode < 256
103
103
 
104
104
  @f0_glyph_ids[unicode]
105
105
  end
106
106
 
107
107
  # Format 4: segment mapping. BMP only.
108
- def parse_format_4
108
+ def parse_format_four
109
109
  seg_count_x2 = u16(@subtable_offset + 6)
110
110
  seg_count = seg_count_x2 / 2
111
111
  end_codes_off = @subtable_offset + 14
@@ -123,7 +123,7 @@ module Pdfrb
123
123
  }
124
124
  end
125
125
 
126
- def lookup_format_4(unicode)
126
+ def lookup_format_four(unicode)
127
127
  return 0 unless @f4
128
128
 
129
129
  seg = @f4
@@ -154,7 +154,7 @@ module Pdfrb
154
154
  end
155
155
 
156
156
  # Format 6: trimmed table.
157
- def parse_format_6
157
+ def parse_format_six
158
158
  first = u16(@subtable_offset + 6)
159
159
  count = u16(@subtable_offset + 8)
160
160
  @f6 = {
@@ -164,7 +164,7 @@ module Pdfrb
164
164
  }
165
165
  end
166
166
 
167
- def lookup_format_6(unicode)
167
+ def lookup_format_six(unicode)
168
168
  return 0 unless @f6
169
169
 
170
170
  idx = unicode - @f6[:first_code]
@@ -174,7 +174,7 @@ module Pdfrb
174
174
  end
175
175
 
176
176
  # Format 12: sparse coverage, full Unicode.
177
- def parse_format_12
177
+ def parse_format_twelve
178
178
  num_groups = u32(@subtable_offset + 12)
179
179
  @f12 = []
180
180
  num_groups.times do |i|
@@ -187,7 +187,7 @@ module Pdfrb
187
187
  end
188
188
  end
189
189
 
190
- def lookup_format_12(unicode)
190
+ def lookup_format_twelve(unicode)
191
191
  return 0 unless @f12
192
192
 
193
193
  # Binary search for the group covering this codepoint.
@@ -83,11 +83,10 @@ 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]
90
- else [parms]
89
+ else []
91
90
  end
92
91
  end
93
92
  end
@@ -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.0"
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.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