pdfrb 0.6.0 → 0.7.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.
@@ -10,46 +10,103 @@ module Pdfrb
10
10
  info = parse_header(data)
11
11
  return nil if info.empty?
12
12
 
13
- image = document.add(
14
- {
15
- Type: :XObject,
16
- Subtype: :Image,
17
- Width: info[:width],
18
- Height: info[:height],
19
- ColorSpace: info[:color_space],
20
- BitsPerComponent: info[:bits],
21
- Filter: :FlateDecode,
22
- DecodeParms: { Predictor: 15, Columns: info[:width] },
23
- Length: 0,
24
- },
25
- type: Pdfrb::Model::Type::XObjectImage
26
- )
13
+ dict = {
14
+ Type: :XObject,
15
+ Subtype: :Image,
16
+ Width: info[:width],
17
+ Height: info[:height],
18
+ ColorSpace: info[:color_space],
19
+ BitsPerComponent: info[:bits],
20
+ Filter: :FlateDecode,
21
+ DecodeParms: { Predictor: 15, Columns: info[:width] },
22
+ Length: 0,
23
+ }
24
+ mask = color_key_mask(info)
25
+ dict[:Mask] = mask if mask
26
+
27
+ image = document.add(dict, type: Pdfrb::Model::Type::XObjectImage)
27
28
  image.stream = ""
28
29
  image
29
30
  end
30
31
 
31
32
  def parse_header(data)
32
- return {} unless data.is_a?(String) && data.bytesize >= 24
33
+ return {} unless data.is_a?(::String) && data.bytesize >= 24
33
34
  return {} unless data.start_with?("\x89PNG\r\n\x1A\n".b)
34
35
  return {} unless data.bytes[12, 4].pack("C*") == "IHDR"
35
36
 
36
37
  off = 16
37
- {
38
+ color_type = data.getbyte(off + 9)
39
+ result = {
38
40
  width: read_u32(data, off),
39
41
  height: read_u32(data, off + 4),
40
42
  bits: data.getbyte(off + 8),
41
- color_type: data.getbyte(off + 9),
42
- color_space: case data.getbyte(off + 9)
43
+ color_type: color_type,
44
+ color_space: case color_type
43
45
  when 0, 4 then :DeviceGray
44
46
  when 2, 6 then :DeviceRGB
45
47
  when 3 then :Indexed
46
48
  else :DeviceRGB
47
49
  end,
48
50
  }
51
+ result[:trns] = read_trns_chunk(data, color_type)
52
+ result
53
+ end
54
+
55
+ # Walk the PNG chunks looking for tRNS. Returns the raw tRNS
56
+ # payload bytes, or nil if absent. Stops at IDAT to avoid
57
+ # scanning the whole file.
58
+ def read_trns_chunk(data, color_type)
59
+ offset = 8 # skip PNG signature
60
+ while offset + 8 <= data.bytesize
61
+ length = read_u32(data, offset)
62
+ chunk_type = data.byteslice(offset + 4, 4)
63
+ offset += 8
64
+ case chunk_type
65
+ when "IHDR" # already parsed; skip
66
+ offset += length + 4
67
+ when "tRNS"
68
+ return data.byteslice(offset, length)
69
+ when "IDAT", "IEND"
70
+ return nil
71
+ else
72
+ offset += length + 4
73
+ end
74
+ end
75
+ nil
76
+ end
77
+
78
+ # Build a PDF /Mask color-key array from the tRNS chunk. Each
79
+ # entry is [min, max] per channel. Returns nil if tRNS is
80
+ # absent or for indexed color (which uses /SMask instead).
81
+ def color_key_mask(info)
82
+ trns = info[:trns]
83
+ return nil unless trns
84
+ return nil if info[:color_type] == 3 # palette uses /SMask
85
+
86
+ case info[:color_type]
87
+ when 0 # grayscale: 1 two-byte value
88
+ v = read_u16(trns, 0)
89
+ [v, v]
90
+ when 2, 6 # RGB: 3 two-byte values
91
+ r = read_u16(trns, 0)
92
+ g = read_u16(trns, 2)
93
+ b = read_u16(trns, 4)
94
+ [r, r, g, g, b, b]
95
+ when 4 # grayscale+alpha: same as grayscale
96
+ v = read_u16(trns, 0)
97
+ [v, v]
98
+ end
99
+ end
100
+
101
+ def read_u16(data, off)
102
+ return 0 unless data && data.bytesize >= off + 2
103
+
104
+ (data.getbyte(off) << 8) | data.getbyte(off + 1)
49
105
  end
50
106
 
51
107
  def read_u32(data, off)
52
- (data.getbyte(off) << 24) | (data.getbyte(off + 1) << 16) | (data.getbyte(off + 2) << 8) | data.getbyte(off + 3)
108
+ (data.getbyte(off) << 24) | (data.getbyte(off + 1) << 16) |
109
+ (data.getbyte(off + 2) << 8) | data.getbyte(off + 3)
53
110
  end
54
111
  end
55
112
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Model
5
+ module Type
6
+ # Page-piece dictionary (s14.5). Per-page metadata recording
7
+ # application-specific private data. The dictionary hangs off
8
+ # a page's /PieceInfo key and is keyed by application name;
9
+ # each value carries /LastModified, /Private, and (optionally)
10
+ # an /ASFStream reference for a stream-encoded private payload.
11
+ #
12
+ # Conforming readers MUST NOT interpret application-private
13
+ # data; it survives round-trips and incremental updates intact.
14
+ class PagePieceInfo < Pdfrb::Model::Cos::Dictionary
15
+ # The application-name key (e.g. :Adobe, :Pdfrb). Multiple
16
+ # applications may each have their own entry in the same
17
+ # /PieceInfo dict.
18
+ def application_name
19
+ value.key?(:Application) ? value[:Application] : nil
20
+ end
21
+
22
+ # /LastModified — PDF date string, parsed to a Time.
23
+ def last_modified
24
+ v = value[:LastModified]
25
+ return nil unless v.is_a?(::String)
26
+
27
+ Pdfrb::Model::Date.parse(v)
28
+ end
29
+
30
+ # /Private — application-private dict. Untyped on purpose:
31
+ # the contents are app-defined.
32
+ def private_data
33
+ value[:Private]
34
+ end
35
+
36
+ # Set the application's /LastModified to +time+. +time+ is
37
+ # converted to a PDF date string.
38
+ def last_modified=(time)
39
+ value[:LastModified] = Pdfrb::Model::Date.format(time)
40
+ end
41
+
42
+ # Merge +hash+ into /Private. If /Private is absent, it is
43
+ # created from the hash.
44
+ def merge_private!(hash)
45
+ existing = value[:Private]
46
+ if existing.is_a?(Pdfrb::Model::Cos::Dictionary)
47
+ existing.value.merge!(hash)
48
+ elsif existing.is_a?(::Hash)
49
+ existing.merge!(hash)
50
+ else
51
+ value[:Private] = hash
52
+ end
53
+ self
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -353,6 +353,9 @@ module Pdfrb
353
353
  # Structure types (s14.7).
354
354
  autoload :StructureAttributes, "pdfrb/model/type/structure_attributes"
355
355
  autoload :StructureElementKid, "pdfrb/model/type/structure_element_kid"
356
+
357
+ # Page-piece dictionary (s14.5).
358
+ autoload :PagePieceInfo, "pdfrb/model/type/page_piece_info"
356
359
  end
357
360
  end
358
361
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Task
5
+ # Walks every widget annotation in the document and regenerates
6
+ # the /AP /N appearance stream via Appearance::Generator. Use
7
+ # after mutating field values directly (e.g. via Form#set_value)
8
+ # or to repair documents with /NeedAppearances set so viewers
9
+ # don't have to synthesise appearances on the fly.
10
+ #
11
+ # Per ISO 32000-2 §12.5.5, an annotation's /AP /N is the normal
12
+ # appearance; PDF/A requires it for every widget. This task
13
+ # also clears /NeedAppearances on /AcroForm since by definition
14
+ # all fields now have a computed appearance.
15
+ module RegenerateAppearances
16
+ module_function
17
+
18
+ # @param document [Pdfrb::Document]
19
+ # @param only: [Array<Symbol>, nil] limit to the given FT values
20
+ # (e.g. [:Tx, :Btn]). Default: all field types.
21
+ # @return [Integer] count of appearances regenerated.
22
+ def call(document, only: nil)
23
+ generator = Pdfrb::Appearance::Generator.new(document)
24
+ widgets = each_widget(document).to_a
25
+ count = 0
26
+ widgets.each do |widget|
27
+ next unless matching?(widget, only)
28
+
29
+ regenerated = regenerate(generator, widget)
30
+ count += 1 if regenerated
31
+ end
32
+ clear_need_appearances(document)
33
+ count
34
+ end
35
+
36
+ def each_widget(document)
37
+ return enum_for(:each_widget, document) unless block_given?
38
+
39
+ document.each_indirect_object do |obj|
40
+ next unless obj.value.is_a?(::Hash)
41
+
42
+ subtype = obj.value[:Subtype]
43
+ yield obj if subtype == :Widget
44
+ end
45
+ end
46
+
47
+ def matching?(widget, only)
48
+ return true if only.nil?
49
+
50
+ ft = widget.value[:FT]
51
+ ft && only.include?(ft.to_sym)
52
+ end
53
+
54
+ def regenerate(generator, widget)
55
+ ft = widget.value[:FT]&.to_sym
56
+ value = widget.value[:V]
57
+ case ft
58
+ when :Tx
59
+ generator.text_field(widget, value: value.to_s)
60
+ true
61
+ when :Btn
62
+ if value.is_a?(::Hash)
63
+ # Multi-state radio; regenerate each state's appearance.
64
+ value.each_key do |state|
65
+ generator.checkbox(widget, checked: state != :Off)
66
+ end
67
+ else
68
+ generator.checkbox(widget, checked: [:Yes, true].include?(value))
69
+ end
70
+ true
71
+ when :Ch
72
+ generator.combo(widget, value: value.to_s)
73
+ true
74
+ else
75
+ false
76
+ end
77
+ rescue StandardError
78
+ # Appearance generation is best-effort; if a single field
79
+ # raises, skip it rather than aborting the whole sweep.
80
+ false
81
+ end
82
+
83
+ def clear_need_appearances(document)
84
+ catalog = document.catalog
85
+ return unless catalog
86
+
87
+ acroform = catalog.value[:AcroForm]
88
+ return unless acroform
89
+
90
+ if acroform.is_a?(Pdfrb::Model::Cos::Dictionary)
91
+ acroform.value.delete(:NeedAppearances)
92
+ elsif acroform.is_a?(::Hash)
93
+ acroform.delete(:NeedAppearances)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/pdfrb/task.rb CHANGED
@@ -9,5 +9,6 @@ module Pdfrb
9
9
  autoload :GenerateCorpus, "pdfrb/task/generate_corpus"
10
10
  autoload :Benchmark, "pdfrb/task/benchmark"
11
11
  autoload :MemoryProfile, "pdfrb/task/memory_profile"
12
+ autoload :RegenerateAppearances, "pdfrb/task/regenerate_appearances"
12
13
  end
13
14
  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.6.0"
4
+ VERSION = "0.7.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdfrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: thor
@@ -774,6 +775,7 @@ files:
774
775
  - lib/pdfrb/content/operators/color.rb
775
776
  - lib/pdfrb/content/operators/general.rb
776
777
  - lib/pdfrb/content/operators/graphics_state_params.rb
778
+ - lib/pdfrb/content/operators/inline_image.rb
777
779
  - lib/pdfrb/content/operators/marked_content.rb
778
780
  - lib/pdfrb/content/operators/painting.rb
779
781
  - lib/pdfrb/content/operators/path.rb
@@ -1057,6 +1059,7 @@ files:
1057
1059
  - lib/pdfrb/model/type/output_intent.rb
1058
1060
  - lib/pdfrb/model/type/page.rb
1059
1061
  - lib/pdfrb/model/type/page_label.rb
1062
+ - lib/pdfrb/model/type/page_piece_info.rb
1060
1063
  - lib/pdfrb/model/type/page_tree_node.rb
1061
1064
  - lib/pdfrb/model/type/page_tree_node_root.rb
1062
1065
  - lib/pdfrb/model/type/pattern.rb
@@ -1160,6 +1163,7 @@ files:
1160
1163
  - lib/pdfrb/task/memory_profile.rb
1161
1164
  - lib/pdfrb/task/merge.rb
1162
1165
  - lib/pdfrb/task/optimize.rb
1166
+ - lib/pdfrb/task/regenerate_appearances.rb
1163
1167
  - lib/pdfrb/test_utils.rb
1164
1168
  - lib/pdfrb/validator.rb
1165
1169
  - lib/pdfrb/version.rb
@@ -1181,6 +1185,7 @@ metadata:
1181
1185
  changelog_uri: https://github.com/claricle/pdfrb/blob/main/CHANGELOG.md
1182
1186
  bug_tracker_uri: https://github.com/claricle/pdfrb/issues
1183
1187
  rubygems_mfa_required: 'true'
1188
+ post_install_message:
1184
1189
  rdoc_options: []
1185
1190
  require_paths:
1186
1191
  - lib
@@ -1195,7 +1200,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
1195
1200
  - !ruby/object:Gem::Version
1196
1201
  version: '0'
1197
1202
  requirements: []
1198
- rubygems_version: 4.0.16
1203
+ rubygems_version: 3.5.22
1204
+ signing_key:
1199
1205
  specification_version: 4
1200
1206
  summary: Pure-Ruby PDF parser, Arlington-model-driven domain model, and serializer
1201
1207
  test_files: []