stationery 0.10.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +3 -1
- data/lib/stationery/fonts/font.rb +5 -2
- data/lib/stationery/fonts/glyph_run.rb +35 -5
- data/lib/stationery/testing/marked_text.rb +34 -4
- data/lib/stationery/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94c8fd60adf62c2f1ac3dd92b4a45b5b4254d11284c255c37c86f4d7887d0f14
|
|
4
|
+
data.tar.gz: 704273654b825478068079526b21ec8b4f32c9b1b93d4f5c4854a47b22e6f0b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07175d385d770227a5d5af7f542f608d86215ecf3172852a7f75738c426273dfe75fc272db81b929136be496e9b0b7394e1febb5b2a326ec2e06d53b012242e7
|
|
7
|
+
data.tar.gz: 864623a98fc783adecd1f8e51a7ae85bbeb1a2bd7c393a6cf72fcf8a548d48221333e29d6ca1336a100bda62576869ccae1fce084ec57b3393758226fd85ac4f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1 (2026-09-28)
|
|
4
|
+
|
|
5
|
+
- Extracted text keeps characters no font has: each run of `.notdef` glyphs is wrapped in a `/Span` with `/ActualText` holding the written characters, and ToUnicode maps glyph 0 to U+FFFD instead of the first missing character drawn, so `日本語` set in a font without those glyphs copies out as `日本語`, not `日日日`. The `MissingGlyph` warning and the bytes of text the fonts cover are unchanged.
|
|
6
|
+
|
|
3
7
|
## 0.10.0 (2026-09-28)
|
|
4
8
|
|
|
5
9
|
CSS for `html`, better line and page breaking, timestamped signatures, and streaming to a block.
|
data/README.md
CHANGED
|
@@ -794,7 +794,9 @@ a figure space, a narrow no-break space, …) is drawn as a blank of the
|
|
|
794
794
|
character's conventional width, never as `.notdef`. Any other glyph no font
|
|
795
795
|
has is drawn as the family's `.notdef` and reported as a
|
|
796
796
|
`Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
|
|
797
|
-
on it).
|
|
797
|
+
on it). The characters themselves travel as the `ActualText` of a `Span`
|
|
798
|
+
around the glyphs, so the text still extracts, copies and reads aloud as
|
|
799
|
+
written. Fallback covers every text element,
|
|
798
800
|
table cell, list marker, table of contents entry and page template text;
|
|
799
801
|
direct `canvas.text` calls draw with the font they are given.
|
|
800
802
|
|
|
@@ -24,6 +24,7 @@ module Stationery
|
|
|
24
24
|
0x3000 => 1.0 }.freeze
|
|
25
25
|
SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
|
|
26
26
|
NO_FEATURES = [].freeze
|
|
27
|
+
REPLACEMENT = "\uFFFD"
|
|
27
28
|
|
|
28
29
|
attr_reader :ttf
|
|
29
30
|
|
|
@@ -107,9 +108,11 @@ module Stationery
|
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
# { code => text } for every glyph drawn so far; a ligature's text is
|
|
110
|
-
# every character it stands for.
|
|
111
|
+
# every character it stands for. Glyph 0, .notdef, stands for every
|
|
112
|
+
# character the font lacks, so it maps to the replacement character
|
|
113
|
+
# and the run's ActualText carries the real ones (see GlyphRun).
|
|
111
114
|
def used_codes
|
|
112
|
-
@used.to_h { |gid, char| [code(gid), char] }
|
|
115
|
+
@used.to_h { |gid, char| [code(gid), gid.zero? ? REPLACEMENT : char] }
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
def build(writer)
|
|
@@ -7,6 +7,11 @@ module Stationery
|
|
|
7
7
|
# source text of each glyph (several characters for a ligature). All-zero
|
|
8
8
|
# adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
|
|
9
9
|
# are written as the font's character codes (see Font#code).
|
|
10
|
+
#
|
|
11
|
+
# A character no font has is glyph 0, .notdef, which stands for no
|
|
12
|
+
# character in particular: each stretch of them is shown inside a Span
|
|
13
|
+
# whose ActualText is the characters, so the text still extracts, copies
|
|
14
|
+
# and reads aloud as written.
|
|
10
15
|
GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
|
|
11
16
|
def width(size, letter_spacing: 0)
|
|
12
17
|
units = gids.sum { |gid| font.ttf.advance(gid) }
|
|
@@ -18,20 +23,45 @@ module Stationery
|
|
|
18
23
|
with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
def missing? = gids.include?(0)
|
|
27
|
+
|
|
21
28
|
def to_operator
|
|
22
|
-
return
|
|
29
|
+
return show unless missing?
|
|
30
|
+
|
|
31
|
+
last = gids.size - 1
|
|
32
|
+
gids.each_index.chunk_while { |a, b| gids[a].zero? == gids[b].zero? }.map do |indices|
|
|
33
|
+
piece = with(gids: gids.values_at(*indices), adjust: adjust.values_at(*indices),
|
|
34
|
+
chars: chars.values_at(*indices))
|
|
35
|
+
piece.marked(trailing: indices.last < last)
|
|
36
|
+
end.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# One stretch of a run: .notdef glyphs inside their Span. `trailing`
|
|
42
|
+
# keeps the adjustment after the last glyph, which a stretch followed
|
|
43
|
+
# by another needs and the end of a run does not.
|
|
44
|
+
def marked(trailing:)
|
|
45
|
+
return show(trailing:) unless missing?
|
|
46
|
+
|
|
47
|
+
"/Span <</ActualText <FEFF#{chars.join.encode(Encoding::UTF_16BE).unpack1("H*").upcase}>>> BDC\n" \
|
|
48
|
+
"#{show(trailing:)}\nEMC"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def show(trailing: false)
|
|
54
|
+
return "<#{hex(gids)}> Tj" unless adjusted?(trailing)
|
|
23
55
|
|
|
24
56
|
parts = gids.each_index.slice_after { |i| !adjust[i].zero? }.flat_map do |indices|
|
|
25
57
|
last = indices.last
|
|
26
58
|
chunk = ["<#{hex(indices.map { |i| gids[i] })}>"]
|
|
27
|
-
last == gids.size - 1 || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
|
|
59
|
+
(last == gids.size - 1 && !trailing) || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
|
|
28
60
|
end
|
|
29
61
|
"[#{parts.join(" ")}] TJ"
|
|
30
62
|
end
|
|
31
63
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def adjusted? = adjust[0...-1].any? { |a| !a.zero? }
|
|
64
|
+
def adjusted?(trailing) = (trailing ? adjust : adjust[0...-1]).any? { |a| !a.zero? }
|
|
35
65
|
|
|
36
66
|
def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
|
|
37
67
|
end
|
|
@@ -16,6 +16,8 @@ module Stationery
|
|
|
16
16
|
set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
|
|
17
17
|
set_text_rendering_mode].freeze
|
|
18
18
|
|
|
19
|
+
UTF16_BOM = "\xFE\xFF".b
|
|
20
|
+
|
|
19
21
|
def_delegators :@state, *STATE
|
|
20
22
|
|
|
21
23
|
# { MCID => text } and the strings shown outside marked content.
|
|
@@ -31,20 +33,30 @@ module Stationery
|
|
|
31
33
|
@unmarked = []
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# A sequence with ActualText reads as that text, whatever it shows.
|
|
34
37
|
def begin_marked_content_with_pl(_tag, properties)
|
|
35
|
-
|
|
38
|
+
properties = {} unless properties.is_a?(Hash)
|
|
39
|
+
@open << properties[:MCID]
|
|
40
|
+
return unless properties[:ActualText]
|
|
41
|
+
|
|
42
|
+
@actual = decode(properties[:ActualText])
|
|
43
|
+
@actual_depth = @open.size
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
def begin_marked_content(_tag) = @open << nil
|
|
39
|
-
|
|
47
|
+
|
|
48
|
+
def end_marked_content
|
|
49
|
+
@actual = nil if @actual_depth && @open.size <= @actual_depth
|
|
50
|
+
@open.pop
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
def show_text(string) = append(string)
|
|
41
54
|
def show_text_with_positioning(params) = params.grep(String).each { |string| append(string) }
|
|
42
55
|
|
|
43
56
|
private
|
|
44
57
|
|
|
45
58
|
def append(string)
|
|
46
|
-
|
|
47
|
-
text = font.unpack(string).map { |code| font.to_utf8(code) }.join
|
|
59
|
+
text = shown(string)
|
|
48
60
|
return @unmarked << text if @open.empty?
|
|
49
61
|
|
|
50
62
|
mcid = @open.compact.last
|
|
@@ -55,6 +67,24 @@ module Stationery
|
|
|
55
67
|
@texts[mcid] = "#{@texts[mcid]}#{separator}#{text}"
|
|
56
68
|
@baselines[mcid] = baseline
|
|
57
69
|
end
|
|
70
|
+
|
|
71
|
+
# The characters a string shows: its ActualText once, when it has one.
|
|
72
|
+
def shown(string)
|
|
73
|
+
if @actual
|
|
74
|
+
text = @actual
|
|
75
|
+
@actual = ""
|
|
76
|
+
return text
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
font = @state.current_font
|
|
80
|
+
font.unpack(string).map { |code| font.to_utf8(code) }.join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decode(text)
|
|
84
|
+
return text.dup.force_encoding(Encoding::UTF_8) unless text.b.start_with?(UTF16_BOM)
|
|
85
|
+
|
|
86
|
+
text.b.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
|
|
87
|
+
end
|
|
58
88
|
end
|
|
59
89
|
end
|
|
60
90
|
end
|
data/lib/stationery/version.rb
CHANGED