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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41ce5a83b1405e6756b6b2238b4796d1c7331e82f1abb51df2747befa61dbc26
4
- data.tar.gz: a1c9fab02c1ce65eb761c96084c81b72117aee45ac2c0db8c1326ce94651f7a4
3
+ metadata.gz: 94c8fd60adf62c2f1ac3dd92b4a45b5b4254d11284c255c37c86f4d7887d0f14
4
+ data.tar.gz: 704273654b825478068079526b21ec8b4f32c9b1b93d4f5c4854a47b22e6f0b7
5
5
  SHA512:
6
- metadata.gz: 9c1a85c89e74b002d14d6dd2aaa7153e34e746636ad6059b8db6a83f302b7058d4efb7430346463fc614ba0b61b7991226c44d724b62479d22ab5b1a007b419a
7
- data.tar.gz: dfc30baf5b1803d38d7087eed1b24960fe79c3021f119b859d695458e128ed23baf74d961779b2bdb39f1071a0ed7cd09f51d1aa3881eb3076190f1a8d5a63b0
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). Fallback covers every text element,
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 "<#{hex(gids)}> Tj" unless adjusted?
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
- private
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
- @open << (properties[:MCID] if properties.is_a?(Hash))
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
- def end_marked_content = @open.pop
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
- font = @state.current_font
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.10.0"
4
+ VERSION = "0.10.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson