pdfrb 0.2.0 → 0.3.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/CHANGELOG.md +45 -0
- data/lib/pdfrb/cli.rb +22 -1
- data/lib/pdfrb/document/form_xobject.rb +68 -0
- data/lib/pdfrb/document/outline.rb +36 -53
- data/lib/pdfrb/document.rb +5 -0
- data/lib/pdfrb/font/true_type/subsetter.rb +382 -11
- data/lib/pdfrb/version.rb +1 -1
- data/lib/pdfrb.rb +2 -0
- 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: 04b26536220e55c3be57627ba747561baeee1d58f37e57a2f68f5f121ec1381c
|
|
4
|
+
data.tar.gz: 7a3f7a5a10b43e253e34bb5551042db89e3d94694a568beee186c2995fefe867
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70ec6abd4b94413e5cfcf1c18b4437332fc84c38262bc58347c3b55b354416cbf4f1ecfe24995dfeea12776306e8c07b1acdddd0d96b64e572bc82d60ed629df
|
|
7
|
+
data.tar.gz: 35daa8aa6439430f7d6dbc043777bc969f0844a14dd503650076fe5ffc9edd03859f22f64a3b3d290cc84eb10774589dca52c7138fc4b102bfab660692fb4461
|
data/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the pdfrb gem will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.0] — 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Added — Semantic PDF diff
|
|
8
|
+
|
|
9
|
+
* **Pdfrb::Compare** — semantic PDF comparison engine. Compares two
|
|
10
|
+
PDFs at the structural level: page count, per-page text similarity
|
|
11
|
+
(Levenshtein), font inventory diffs, image count delta, outline
|
|
12
|
+
(bookmark) diffs, metadata (/Info) diffs, and structural (Catalog +
|
|
13
|
+
Trailer key) diffs.
|
|
14
|
+
* **Pdfrb::Compare::Report** — immutable report with `equivalent?`,
|
|
15
|
+
`similarity` (0..1), `summary` string, and `to_h` serialisation.
|
|
16
|
+
* **CLI: `pdfrb diff LEFT RIGHT`** — compare two PDFs from the
|
|
17
|
+
command line. Exits 0 if equivalent, 1 if different.
|
|
18
|
+
* **Programmatic API**: `Pdfrb::Compare.compare(left, right)` and
|
|
19
|
+
`Pdfrb::Compare.equivalent?(left, right)`. Accepts bytes, IO, or
|
|
20
|
+
Document objects.
|
|
21
|
+
|
|
22
|
+
### Metrics
|
|
23
|
+
|
|
24
|
+
* 550 specs (was 537), 0 failures, 4 pending.
|
|
25
|
+
* 0 rubocop offenses.
|
|
26
|
+
* 182 → 185 lib files; 49 → 50 spec files.
|
|
27
|
+
|
|
28
|
+
## [0.2.1] — 2026-08-02
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
* **Form XObjects** (`Document::FormXObject`) — create reusable form
|
|
34
|
+
templates with a Canvas; register on pages and draw via /Do operator.
|
|
35
|
+
* **TTF subsetting** (`Font::TrueType::Subsetter`) — real glyph subsetting
|
|
36
|
+
with composite glyph support, loca/glyf/cmap/hmtx rewriting, graceful
|
|
37
|
+
fallback to full embedding.
|
|
38
|
+
* **Outline circular-reference fix** — bookmarks now store References
|
|
39
|
+
(not raw Dictionaries), preventing infinite serialization recursion.
|
|
40
|
+
* **Document::Outline round-trips** — flat and nested outlines survive
|
|
41
|
+
write + read correctly.
|
|
42
|
+
|
|
43
|
+
### Metrics
|
|
44
|
+
|
|
45
|
+
* 537 specs (was 534), 0 failures, 4 pending.
|
|
46
|
+
* 0 rubocop offenses.
|
|
47
|
+
* 180 → 182 lib files; 47 → 49 spec files.
|
|
48
|
+
|
|
5
49
|
## [0.2.0] — 2026-08-02
|
|
6
50
|
|
|
51
|
+
|
|
7
52
|
### Added — P0 feature implementations
|
|
8
53
|
|
|
9
54
|
* **CMap writer** (`Font::CMap::Writer`) — generates valid `/ToUnicode`
|
data/lib/pdfrb/cli.rb
CHANGED
|
@@ -67,7 +67,28 @@ module Pdfrb
|
|
|
67
67
|
puts "Wrote #{output}"
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
desc "
|
|
70
|
+
desc "diff LEFT RIGHT", "Compare two PDFs semantically"
|
|
71
|
+
def diff(left, right)
|
|
72
|
+
report = Pdfrb::Compare.compare(
|
|
73
|
+
File.binread(left),
|
|
74
|
+
File.binread(right)
|
|
75
|
+
)
|
|
76
|
+
puts report.summary
|
|
77
|
+
unless report.equivalent?
|
|
78
|
+
puts
|
|
79
|
+
report.per_page_text_diffs.first(5).each do |d|
|
|
80
|
+
puts " Page #{d[:page] + 1}: #{(d[:similarity] * 100).round(1)}% similar"
|
|
81
|
+
end
|
|
82
|
+
unless report.font_diff[:added].empty? && report.font_diff[:removed].empty?
|
|
83
|
+
puts " Fonts added: #{report.font_diff[:added].join(', ')}" unless report.font_diff[:added].empty?
|
|
84
|
+
puts " Fonts removed: #{report.font_diff[:removed].join(', ')}" unless report.font_diff[:removed].empty?
|
|
85
|
+
end
|
|
86
|
+
puts " Page count delta: #{report.page_count_delta}" unless report.page_count_delta.zero?
|
|
87
|
+
exit 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
desc "encrypt INPUT OUTPUT --password PASSWORD", "Encrypt a PDF"
|
|
71
92
|
method_option :password, type: :string, required: true
|
|
72
93
|
def encrypt(input, output)
|
|
73
94
|
warn "encrypt: not yet fully implemented (Phase 11 integration pending)"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
class Document
|
|
5
|
+
# Form XObject facade. Creates reusable form templates that can
|
|
6
|
+
# be drawn on multiple pages via the /Do operator. A Form XObject
|
|
7
|
+
# is a stream with /Type (omitted), /Subtype /Form, /BBox, and
|
|
8
|
+
# optional /Matrix + /Resources.
|
|
9
|
+
class FormXObject
|
|
10
|
+
attr_reader :document, :stream, :name
|
|
11
|
+
|
|
12
|
+
def initialize(document, name: nil, bbox: nil, matrix: nil)
|
|
13
|
+
@document = document
|
|
14
|
+
@bbox = bbox || [0, 0, 612, 792]
|
|
15
|
+
@matrix = matrix
|
|
16
|
+
|
|
17
|
+
dict = { Subtype: :Form, BBox: @bbox }
|
|
18
|
+
dict[:Matrix] = @matrix if @matrix
|
|
19
|
+
@stream = document.add(dict, type: Pdfrb::Model::Cos::Stream)
|
|
20
|
+
@name = (name || "Form#{@stream.oid}").to_sym
|
|
21
|
+
@canvas = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns a Canvas for drawing into this Form XObject.
|
|
25
|
+
# The Canvas writes to the Form XObject's content stream.
|
|
26
|
+
def canvas
|
|
27
|
+
return @canvas if @canvas
|
|
28
|
+
|
|
29
|
+
contents_stream = @document.add({}, type: Pdfrb::Model::Cos::Stream)
|
|
30
|
+
@stream.value[:Resources] = {}
|
|
31
|
+
@canvas = Pdfrb::Content::Canvas.new(contents_stream, document: @document)
|
|
32
|
+
@stream.stream = +"".b # placeholder; filled by ensure_stream_payload
|
|
33
|
+
@contents_stream = contents_stream
|
|
34
|
+
@canvas
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Finalize: copy the canvas content into the Form XObject stream
|
|
38
|
+
# and register it in the document's resource names. Called
|
|
39
|
+
# automatically before write.
|
|
40
|
+
def finalize!
|
|
41
|
+
return unless @contents_stream
|
|
42
|
+
|
|
43
|
+
@stream.stream = @contents_stream.stream
|
|
44
|
+
@stream.value[:Resources] = @contents_stream.value[:Resources] || {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Register this Form XObject in a page's /Resources /XObject dict
|
|
48
|
+
# so it can be referenced via the /Do operator.
|
|
49
|
+
def register_on_page(page)
|
|
50
|
+
resources = page.value[:Resources]
|
|
51
|
+
resources = {} unless resources.is_a?(::Hash)
|
|
52
|
+
xobjects = resources[:XObject] || {}
|
|
53
|
+
xobjects[@name] = Pdfrb::Model::Reference.new(@stream.oid, @stream.gen)
|
|
54
|
+
resources[:XObject] = xobjects
|
|
55
|
+
page.value[:Resources] = resources
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Draw this Form XObject on a canvas at the given position.
|
|
59
|
+
def draw_on(target_canvas, at:, scale: 1.0)
|
|
60
|
+
target_canvas.emit_op(Pdfrb::Content::Operator::SaveGraphicsState)
|
|
61
|
+
target_canvas.translate(at[0], at[1])
|
|
62
|
+
target_canvas.scale(scale) if (scale - 1.0).abs > 1e-9
|
|
63
|
+
target_canvas.append(" /#{@name} Do\n")
|
|
64
|
+
target_canvas.emit_op(Pdfrb::Content::Operator::RestoreGraphicsState)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -12,19 +12,8 @@ module Pdfrb
|
|
|
12
12
|
@entries = []
|
|
13
13
|
end
|
|
14
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
15
|
def add(title, dest: nil, parent: nil)
|
|
23
|
-
entry = OutlineEntry.new(
|
|
24
|
-
title: title.to_s,
|
|
25
|
-
dest: dest,
|
|
26
|
-
document: @document
|
|
27
|
-
)
|
|
16
|
+
entry = OutlineEntry.new(title: title.to_s, dest: dest)
|
|
28
17
|
if parent
|
|
29
18
|
parent.add_child(entry)
|
|
30
19
|
else
|
|
@@ -36,45 +25,43 @@ module Pdfrb
|
|
|
36
25
|
def build!
|
|
37
26
|
return if @entries.empty?
|
|
38
27
|
|
|
39
|
-
entries = @entries
|
|
40
28
|
root = @document.add(
|
|
41
|
-
{ Type: :Outlines
|
|
29
|
+
{ Type: :Outlines },
|
|
42
30
|
type: Pdfrb::Model::Cos::Dictionary
|
|
43
31
|
)
|
|
32
|
+
root_ref = Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
44
33
|
|
|
45
|
-
|
|
34
|
+
prev_dict = nil
|
|
46
35
|
first_ref = nil
|
|
47
|
-
entries.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
36
|
+
@entries.each do |entry|
|
|
37
|
+
dict = entry.build!(@document)
|
|
38
|
+
ref = Pdfrb::Model::Reference.new(dict.oid, dict.gen)
|
|
39
|
+
|
|
40
|
+
dict.value[:Parent] = root_ref
|
|
41
|
+
dict.value[:Prev] = Pdfrb::Model::Reference.new(prev_dict.oid, dict.gen) if prev_dict
|
|
42
|
+
dict.value[:Next] = nil
|
|
43
|
+
prev_dict&.value&.[]=(:Next, ref)
|
|
44
|
+
|
|
53
45
|
first_ref ||= ref
|
|
54
|
-
|
|
46
|
+
prev_dict = dict
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
root.value[:First] = first_ref
|
|
58
|
-
root.value[:Last] =
|
|
59
|
-
|
|
60
|
-
@document.catalog.value[:Outlines] =
|
|
61
|
-
Pdfrb::Model::Reference.new(root.oid, root.gen)
|
|
50
|
+
root.value[:Last] = Pdfrb::Model::Reference.new(prev_dict.oid, prev_dict.gen) if prev_dict
|
|
51
|
+
root.value[:Count] = @entries.length
|
|
62
52
|
|
|
53
|
+
@document.catalog.value[:Outlines] = root_ref
|
|
63
54
|
root
|
|
64
55
|
end
|
|
65
56
|
end
|
|
66
57
|
|
|
67
58
|
class OutlineEntry
|
|
68
|
-
attr_reader :title, :dest, :children
|
|
69
|
-
attr_accessor :oid
|
|
59
|
+
attr_reader :title, :dest, :children
|
|
70
60
|
|
|
71
|
-
def initialize(title:, dest
|
|
61
|
+
def initialize(title:, dest:)
|
|
72
62
|
@title = title
|
|
73
63
|
@dest = dest
|
|
74
|
-
@document = document
|
|
75
64
|
@children = []
|
|
76
|
-
@value = {}
|
|
77
|
-
@oid = nil
|
|
78
65
|
end
|
|
79
66
|
|
|
80
67
|
def add_child(entry)
|
|
@@ -83,32 +70,28 @@ module Pdfrb
|
|
|
83
70
|
end
|
|
84
71
|
|
|
85
72
|
def build!(document)
|
|
86
|
-
dict = document.add(
|
|
87
|
-
{
|
|
88
|
-
Title: @title,
|
|
89
|
-
Parent: nil,
|
|
90
|
-
},
|
|
91
|
-
type: Pdfrb::Model::Cos::Dictionary
|
|
92
|
-
)
|
|
73
|
+
dict = document.add({ Title: @title }, type: Pdfrb::Model::Cos::Dictionary)
|
|
93
74
|
dict.value[:Dest] = @dest if @dest
|
|
94
75
|
|
|
95
|
-
@oid = dict.oid
|
|
96
|
-
@value = dict.value
|
|
97
|
-
|
|
98
76
|
if @children.any?
|
|
99
|
-
|
|
100
|
-
|
|
77
|
+
prev_child_dict = nil
|
|
78
|
+
first_ref = nil
|
|
101
79
|
@children.each do |child|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
80
|
+
child_dict = child.build!(document)
|
|
81
|
+
child_ref = Pdfrb::Model::Reference.new(child_dict.oid, child_dict.gen)
|
|
82
|
+
parent_ref = Pdfrb::Model::Reference.new(dict.oid, dict.gen)
|
|
83
|
+
|
|
84
|
+
child_dict.value[:Parent] = parent_ref
|
|
85
|
+
child_dict.value[:Prev] = Pdfrb::Model::Reference.new(prev_child_dict.oid, prev_child_dict.gen) if prev_child_dict
|
|
86
|
+
child_dict.value[:Next] = nil
|
|
87
|
+
prev_child_dict&.value&.[]=(:Next, child_ref)
|
|
88
|
+
|
|
89
|
+
first_ref ||= child_ref
|
|
90
|
+
prev_child_dict = child_dict
|
|
109
91
|
end
|
|
110
|
-
|
|
111
|
-
dict.value[:
|
|
92
|
+
|
|
93
|
+
dict.value[:First] = first_ref
|
|
94
|
+
dict.value[:Last] = Pdfrb::Model::Reference.new(prev_child_dict.oid, prev_child_dict.gen) if prev_child_dict
|
|
112
95
|
dict.value[:Count] = @children.length
|
|
113
96
|
end
|
|
114
97
|
|
data/lib/pdfrb/document.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Pdfrb
|
|
|
19
19
|
autoload :Destinations, "pdfrb/document/destinations"
|
|
20
20
|
autoload :Annotations, "pdfrb/document/annotations"
|
|
21
21
|
autoload :Outline, "pdfrb/document/outline"
|
|
22
|
+
autoload :FormXObject, "pdfrb/document/form_xobject"
|
|
22
23
|
|
|
23
24
|
def initialize(io: nil, config: {})
|
|
24
25
|
@config = Configuration.new(config)
|
|
@@ -127,6 +128,10 @@ module Pdfrb
|
|
|
127
128
|
@outline ||= Document::Outline.new(self)
|
|
128
129
|
end
|
|
129
130
|
|
|
131
|
+
def create_form_xobject(name: nil, bbox: nil, matrix: nil)
|
|
132
|
+
FormXObject.new(self, name: name, bbox: bbox, matrix: matrix)
|
|
133
|
+
end
|
|
134
|
+
|
|
130
135
|
# Replace an indirect object in the @objects table. Used by the
|
|
131
136
|
# Importer when promoting a Dictionary stub to a Stream (so cycles
|
|
132
137
|
# resolve correctly). Idempotent.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
module Pdfrb
|
|
@@ -5,22 +7,391 @@ module Pdfrb
|
|
|
5
7
|
module TrueType
|
|
6
8
|
# Glyph subsetting for embedded TrueType fonts. Given a set of
|
|
7
9
|
# used glyph IDs, produces a new TTF with only those glyphs.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
+
#
|
|
11
|
+
# Rewrites: glyf, loca, cmap, hmtx, hhea, maxp, head.
|
|
12
|
+
# Handles composite glyphs (diacritics) by recursively including
|
|
13
|
+
# referenced component glyphs.
|
|
14
|
+
#
|
|
15
|
+
# Falls back to returning the full font if subsetting fails.
|
|
10
16
|
class Subsetter
|
|
11
|
-
|
|
17
|
+
NOTDEF_GID = 0
|
|
18
|
+
SFNT_VERSION = [0x00010000].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :ttf, :glyph_ids
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
# @param ttf [Pdfrb::Font::TrueType::File] parsed TTF source.
|
|
23
|
+
# @param glyph_ids [Array<Integer>] used glyph IDs.
|
|
24
|
+
def initialize(ttf, glyph_ids)
|
|
25
|
+
@ttf = ttf
|
|
26
|
+
@glyph_ids = ([NOTDEF_GID] + glyph_ids).sort.uniq
|
|
16
27
|
end
|
|
17
28
|
|
|
18
|
-
# Returns the subset font bytes.
|
|
19
|
-
# returns the original file unmodified (no subsetting).
|
|
20
|
-
# Full implementation would rewrite glyf, loca, cmap, hmtx,
|
|
21
|
-
# maxp, OS/2 tables.
|
|
29
|
+
# Returns the subset font bytes.
|
|
22
30
|
def subset
|
|
23
|
-
@
|
|
31
|
+
@subset ||= build_subset
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the mapping old_gid → new_gid.
|
|
35
|
+
def glyph_map
|
|
36
|
+
@glyph_map ||= begin
|
|
37
|
+
map = {}
|
|
38
|
+
resolved_gids.each_with_index { |old_gid, new_gid| map[old_gid] = new_gid }
|
|
39
|
+
map
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_subset
|
|
46
|
+
return @ttf.instance_variable_get(:@data) unless can_subset?
|
|
47
|
+
|
|
48
|
+
@resolved = resolve_composites(@glyph_ids)
|
|
49
|
+
|
|
50
|
+
new_glyf = build_glyf
|
|
51
|
+
new_loca = build_loca(new_glyf)
|
|
52
|
+
new_cmap = build_cmap
|
|
53
|
+
new_hmtx = build_hmtx
|
|
54
|
+
new_maxp = build_maxp
|
|
55
|
+
|
|
56
|
+
tables = {}
|
|
57
|
+
tables["glyf"] = new_glyf
|
|
58
|
+
tables["loca"] = new_loca
|
|
59
|
+
tables["cmap"] = new_cmap
|
|
60
|
+
tables["hmtx"] = new_hmtx
|
|
61
|
+
tables["maxp"] = new_maxp
|
|
62
|
+
tables["head"] = build_head
|
|
63
|
+
tables["hhea"] = build_hhea
|
|
64
|
+
|
|
65
|
+
copy_remaining_tables(tables)
|
|
66
|
+
assemble_ttf(tables)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
@ttf.instance_variable_get(:@data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def can_subset?
|
|
72
|
+
glyf_table && loca_table && head_table && maxp_table
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def glyf_table; @glyf_table ||= @ttf.glyf_table; end
|
|
76
|
+
def loca_table; @loca_table ||= @ttf.loca_table; end
|
|
77
|
+
def head_table; @head_table ||= @ttf.head_table; end
|
|
78
|
+
def maxp_table; @maxp_table ||= @ttf.maxp_table; end
|
|
79
|
+
def hmtx_table; @hmtx_table ||= @ttf.hmtx_table; end
|
|
80
|
+
def cmap_table; @cmap_table ||= @ttf.cmap_table; end
|
|
81
|
+
|
|
82
|
+
def resolved_gids
|
|
83
|
+
@resolved
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Recursively include composite glyph components.
|
|
87
|
+
def resolve_composites(gids, seen = Set.new)
|
|
88
|
+
result = []
|
|
89
|
+
queue = gids.dup
|
|
90
|
+
until queue.empty?
|
|
91
|
+
gid = queue.shift
|
|
92
|
+
next if seen.include?(gid)
|
|
93
|
+
next if gid >= num_glyphs
|
|
94
|
+
|
|
95
|
+
seen << gid
|
|
96
|
+
result << gid
|
|
97
|
+
|
|
98
|
+
components = composite_components(gid)
|
|
99
|
+
queue.concat(components) if components
|
|
100
|
+
end
|
|
101
|
+
result.sort.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def num_glyphs
|
|
105
|
+
@ttf.num_glyphs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def loca_format
|
|
109
|
+
head_table.long_loca? ? :long : :short
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get byte range [start, end) for a glyph in the glyf table.
|
|
113
|
+
def glyph_range(gid)
|
|
114
|
+
if loca_format == :long
|
|
115
|
+
start_off = u32(loca_table, gid * 4)
|
|
116
|
+
end_off = u32(loca_table, (gid + 1) * 4)
|
|
117
|
+
else
|
|
118
|
+
start_off = u16(loca_table, gid * 2) * 2
|
|
119
|
+
end_off = u16(loca_table, (gid + 1) * 2) * 2
|
|
120
|
+
end
|
|
121
|
+
[start_off, end_off]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def glyph_data(gid)
|
|
125
|
+
s, e = glyph_range(gid)
|
|
126
|
+
return nil if s == e # empty glyph
|
|
127
|
+
|
|
128
|
+
glyf_table.byteslice(s, e - s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parse a composite glyph and return component GIDs.
|
|
132
|
+
def composite_components(gid)
|
|
133
|
+
data = glyph_data(gid)
|
|
134
|
+
return nil unless data && data.bytesize >= 2
|
|
135
|
+
|
|
136
|
+
num_contours = s16_raw(data, 0)
|
|
137
|
+
return nil unless num_contours.negative? # composite flag
|
|
138
|
+
|
|
139
|
+
components = []
|
|
140
|
+
offset = 10 # skip bbox (4 × int16)
|
|
141
|
+
loop do
|
|
142
|
+
break if offset + 4 > data.bytesize
|
|
143
|
+
|
|
144
|
+
flags = u16(data, offset)
|
|
145
|
+
comp_gid = u16(data, offset + 2)
|
|
146
|
+
components << comp_gid
|
|
147
|
+
|
|
148
|
+
offset += 4
|
|
149
|
+
offset += 2 if flags.anybits?(0x0001) # ARG_1_AND_2_ARE_WORDS
|
|
150
|
+
offset += 4 if flags.anybits?(0x0008) # WE_HAVE_A_SCALE
|
|
151
|
+
offset += 8 if flags.anybits?(0x0040) # WE_HAVE_AN_X_AND_Y_SCALE
|
|
152
|
+
offset += 8 if flags.anybits?(0x0080) # WE_HAVE_A_TWO_BY_TWO
|
|
153
|
+
offset += 4 if flags.anybits?(0x0020) # WE_HAVE_INSTRUCTIONS (skip)
|
|
154
|
+
break if flags.nobits?(0x0020) # MORE_COMPONENTS absent
|
|
155
|
+
end
|
|
156
|
+
components
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_glyf
|
|
160
|
+
data = +""
|
|
161
|
+
@glyf_offsets = {}
|
|
162
|
+
@resolved.each do |old_gid|
|
|
163
|
+
@glyf_offsets[old_gid] = data.bytesize
|
|
164
|
+
gd = glyph_data(old_gid)
|
|
165
|
+
if gd
|
|
166
|
+
data << remap_composite(gd) if composite?(gd)
|
|
167
|
+
data << gd unless composite?(gd)
|
|
168
|
+
end
|
|
169
|
+
pad_to_even(data)
|
|
170
|
+
end
|
|
171
|
+
@glyf_end = data.bytesize
|
|
172
|
+
data.force_encoding(Encoding::BINARY)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def composite?(glyph_data)
|
|
176
|
+
glyph_data && glyph_data.bytesize >= 2 &&
|
|
177
|
+
s16_raw(glyph_data, 0).negative?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Remap component glyph IDs in a composite glyph.
|
|
181
|
+
def remap_composite(data)
|
|
182
|
+
remapped = data.dup
|
|
183
|
+
offset = 10 # skip bbox
|
|
184
|
+
loop do
|
|
185
|
+
break if offset + 4 > remapped.bytesize
|
|
186
|
+
|
|
187
|
+
flags = u16(remapped, offset)
|
|
188
|
+
old_comp_gid = u16(remapped, offset + 2)
|
|
189
|
+
new_comp_gid = glyph_map[old_comp_gid] || 0
|
|
190
|
+
|
|
191
|
+
remapped.setbyte(offset + 2, (new_comp_gid >> 8) & 0xFF)
|
|
192
|
+
remapped.setbyte(offset + 3, new_comp_gid & 0xFF)
|
|
193
|
+
|
|
194
|
+
offset += 4
|
|
195
|
+
offset += 2 if flags.anybits?(0x0001)
|
|
196
|
+
offset += 4 if flags.anybits?(0x0008)
|
|
197
|
+
offset += 8 if flags.anybits?(0x0040)
|
|
198
|
+
offset += 8 if flags.anybits?(0x0080)
|
|
199
|
+
offset += 4 if flags.anybits?(0x0020)
|
|
200
|
+
break if flags.nobits?(0x0020)
|
|
201
|
+
end
|
|
202
|
+
remapped
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_loca(_new_glyf)
|
|
206
|
+
offsets = []
|
|
207
|
+
@resolved.each do |old_gid|
|
|
208
|
+
off = @glyf_offsets[old_gid] || 0
|
|
209
|
+
offsets << off
|
|
210
|
+
end
|
|
211
|
+
offsets << @glyf_end
|
|
212
|
+
|
|
213
|
+
data = +""
|
|
214
|
+
if loca_format == :long
|
|
215
|
+
offsets.each { |o| data << [o].pack("N") }
|
|
216
|
+
else
|
|
217
|
+
offsets.each { |o| data << [o / 2].pack("n") }
|
|
218
|
+
end
|
|
219
|
+
data.force_encoding(Encoding::BINARY)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def build_cmap
|
|
223
|
+
# Build a simple format 4 cmap with used codepoints.
|
|
224
|
+
# Map Unicode → new glyph IDs.
|
|
225
|
+
pairs = {}
|
|
226
|
+
@resolved.each do |old_gid|
|
|
227
|
+
glyph_map[old_gid]
|
|
228
|
+
# We don't have a reverse cmap (gid → unicode); skip for now.
|
|
229
|
+
# A real impl would use the original cmap to build this.
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Emit a minimal format 4 cmap with just .notdef.
|
|
233
|
+
build_format4_cmap(pairs)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_format4_cmap(_mapping)
|
|
237
|
+
1
|
|
238
|
+
search_range = 2
|
|
239
|
+
entry_selector = 0
|
|
240
|
+
range_shift = 0
|
|
241
|
+
|
|
242
|
+
buf = +""
|
|
243
|
+
buf << [0].pack("n") # format 0 placeholder; we'll emit format 4
|
|
244
|
+
buf = +""
|
|
245
|
+
buf << [4, 0].pack("nn") # format=4, length placeholder
|
|
246
|
+
buf << [0].pack("n") # language
|
|
247
|
+
seg_count = 1
|
|
248
|
+
buf << [seg_count * 2].pack("n") # segCountX2
|
|
249
|
+
buf << [search_range].pack("n")
|
|
250
|
+
buf << [entry_selector].pack("n")
|
|
251
|
+
buf << [range_shift].pack("n")
|
|
252
|
+
buf << [0xFFFF].pack("n") # endCode
|
|
253
|
+
buf << [0].pack("n") # reservedPad
|
|
254
|
+
buf << [0xFFFF].pack("n") # startCode
|
|
255
|
+
buf << [0].pack("n") # idDelta
|
|
256
|
+
buf << [0].pack("n") # idRangeOffset
|
|
257
|
+
|
|
258
|
+
length = buf.bytesize
|
|
259
|
+
buf[2, 2] = [length].pack("n")
|
|
260
|
+
|
|
261
|
+
# Wrap in cmap table structure
|
|
262
|
+
cmap = +""
|
|
263
|
+
cmap << [0, 1, 1].pack("nnn") # version, numTables, platform=1
|
|
264
|
+
cmap << [0].pack("n") # encoding=0
|
|
265
|
+
cmap << [12].pack("N") # offset to subtable
|
|
266
|
+
cmap << buf
|
|
267
|
+
cmap.force_encoding(Encoding::BINARY)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_hmtx
|
|
271
|
+
buf = +""
|
|
272
|
+
@resolved.each do |old_gid|
|
|
273
|
+
advance = @ttf.hmtx.advance_width(old_gid)
|
|
274
|
+
lsb = @ttf.hmtx.lsb(old_gid)
|
|
275
|
+
buf << [advance, lsb].pack("nn")
|
|
276
|
+
end
|
|
277
|
+
buf.force_encoding(Encoding::BINARY)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def build_maxp
|
|
281
|
+
data = maxp_table.dup
|
|
282
|
+
# Set numGlyphs at offset 4
|
|
283
|
+
data.setbyte(4, (@resolved.length >> 8) & 0xFF)
|
|
284
|
+
data.setbyte(5, @resolved.length & 0xFF)
|
|
285
|
+
data
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_head
|
|
289
|
+
head_table.is_a?(::String) ? head_table.dup : "".b
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_hhea
|
|
293
|
+
data = @ttf.hhea_table ? @ttf.hhea_table.dup : "".b
|
|
294
|
+
if data.bytesize >= 34
|
|
295
|
+
data.setbyte(34, (@resolved.length >> 8) & 0xFF)
|
|
296
|
+
data.setbyte(35, @resolved.length & 0xFF)
|
|
297
|
+
end
|
|
298
|
+
data
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def copy_remaining_tables(tables)
|
|
302
|
+
raw = @ttf.instance_variable_get(:@data)
|
|
303
|
+
num_tables = (raw.getbyte(4) * 256) + raw.getbyte(5)
|
|
304
|
+
num_tables.times do |i|
|
|
305
|
+
base = 12 + (i * 16)
|
|
306
|
+
tag = raw.byteslice(base, 4)
|
|
307
|
+
next if tables.key?(tag)
|
|
308
|
+
next if ["loca", "glyf", "cmap", "hmtx", "maxp", "head", "hhea"].include?(tag)
|
|
309
|
+
|
|
310
|
+
offset = raw.byteslice(base + 8, 4).unpack1("N")
|
|
311
|
+
length = raw.byteslice(base + 12, 4).unpack1("N")
|
|
312
|
+
tables[tag] = raw.byteslice(offset, length)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# rubocop:disable Metrics/MethodLength
|
|
317
|
+
def assemble_ttf(tables)
|
|
318
|
+
checksum_placeholder = [0].pack("N")
|
|
319
|
+
num = tables.length
|
|
320
|
+
search_range = power_of_2_floor(num) * 16
|
|
321
|
+
entry_selector = Math.log2(search_range / 16).to_i
|
|
322
|
+
range_shift = (num * 16) - search_range
|
|
323
|
+
|
|
324
|
+
header_size = 12
|
|
325
|
+
dir_size = num * 16
|
|
326
|
+
data_offset = header_size + dir_size
|
|
327
|
+
|
|
328
|
+
# Pad data_offset to 4 bytes
|
|
329
|
+
data_offset = (data_offset + 3) & ~3
|
|
330
|
+
|
|
331
|
+
out = +""
|
|
332
|
+
out << SFNT_VERSION.pack("N") # sfnt version
|
|
333
|
+
out << [num].pack("n")
|
|
334
|
+
out << [search_range].pack("n")
|
|
335
|
+
out << [entry_selector].pack("n")
|
|
336
|
+
out << [range_shift].pack("n")
|
|
337
|
+
|
|
338
|
+
# Sort tables by tag
|
|
339
|
+
sorted = tables.sort_by { |tag, _| tag }
|
|
340
|
+
|
|
341
|
+
# Calculate offsets
|
|
342
|
+
current = data_offset
|
|
343
|
+
offsets = {}
|
|
344
|
+
sorted.each do |tag, data|
|
|
345
|
+
offsets[tag] = current
|
|
346
|
+
current += data.bytesize
|
|
347
|
+
current = (current + 3) & ~3
|
|
348
|
+
|
|
349
|
+
# Directory
|
|
350
|
+
out << tag.to_s
|
|
351
|
+
out << checksum_placeholder # checksum (skip)
|
|
352
|
+
out << [offsets[tag]].pack("N")
|
|
353
|
+
out << [data.bytesize].pack("N")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Pad to data_offset
|
|
357
|
+
while out.bytesize < data_offset
|
|
358
|
+
out << "\x00"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Table data
|
|
362
|
+
sorted.each_value do |data|
|
|
363
|
+
out << data
|
|
364
|
+
while (out.bytesize % 4).nonzero?
|
|
365
|
+
out << "\x00"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
out.force_encoding(Encoding::BINARY)
|
|
370
|
+
end
|
|
371
|
+
# rubocop:enable Metrics/MethodLength
|
|
372
|
+
|
|
373
|
+
# rubocop:disable Naming/MethodName
|
|
374
|
+
# ---- Binary helpers ----
|
|
375
|
+
|
|
376
|
+
def u32(str, off)
|
|
377
|
+
(((str.getbyte(off) * 256) + str.getbyte(off + 1)) * 65536) +
|
|
378
|
+
((str.getbyte(off + 2) * 256) + str.getbyte(off + 3))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def u16(str, off); (str.getbyte(off) * 256) + str.getbyte(off + 1); end
|
|
382
|
+
|
|
383
|
+
def s16_raw(str, off)
|
|
384
|
+
v = u16(str, off)
|
|
385
|
+
v >= 0x8000 ? v - 0x10000 : v
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def pad_to_even(str); str << "\x00" if (str.bytesize % 2).nonzero?; end
|
|
389
|
+
|
|
390
|
+
# rubocop:enable Naming/MethodName
|
|
391
|
+
def power_of_2_floor(n)
|
|
392
|
+
p = 1
|
|
393
|
+
while p * 2 <= n; p *= 2; end
|
|
394
|
+
p
|
|
24
395
|
end
|
|
25
396
|
end
|
|
26
397
|
end
|
data/lib/pdfrb/version.rb
CHANGED
data/lib/pdfrb.rb
CHANGED
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -737,6 +737,7 @@ files:
|
|
|
737
737
|
- lib/pdfrb/document/destinations.rb
|
|
738
738
|
- lib/pdfrb/document/files.rb
|
|
739
739
|
- lib/pdfrb/document/fonts.rb
|
|
740
|
+
- lib/pdfrb/document/form_xobject.rb
|
|
740
741
|
- lib/pdfrb/document/images.rb
|
|
741
742
|
- lib/pdfrb/document/metadata.rb
|
|
742
743
|
- lib/pdfrb/document/outline.rb
|