przn 0.1.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c6b2f14ade1a9803cb8d50af8ea637db4cc4455bd07c4274acadef61c0c24d4
4
- data.tar.gz: 3cf2e893b448133d50c7d4283838d521b06ed1e9d129ea620e799925edfe0284
3
+ metadata.gz: b33677433cd4802ee75ef9d7eb821d2dbb6faf8b1d2364d535b6aa28bffa6eb3
4
+ data.tar.gz: 930ecad39096eea771759134cd6c7e4a83c55a00ae98c59ead5afea1dcc46f17
5
5
  SHA512:
6
- metadata.gz: 0b0b41885598b6176fa0de0363f683ded12937a0f0365462fbfb9cffeb867f7c126dadaaecc93244b20e9ceebf94a7d582c076080176d40f7486a28d4792fc66
7
- data.tar.gz: 814096bacf4013f4b225b30615176dc2579199bce5c08b8bfa718b00407c28c14a7e8b06958808e24a28b04dd4762c46d6baca8b1340ee2074e48f968c0a2b3e
6
+ metadata.gz: 08eafdea3c2f55a29ccfd879b3bfa1541576be74fbb042cc33c527e39289e3dcf1375a10df033820e37f69e9f6726f580cd19ae468d633d73c9d65a23736674b
7
+ data.tar.gz: fcbf194e2a090de86a823fd8a0076977db73cf69f857cea6f4406f9fe31c2f44dab60120d61dbda7f10545e2fd750089df8e34233f0808fefd096bcec3cc25e1
data/README.md CHANGED
@@ -15,15 +15,30 @@ gem install przn
15
15
  przn your_slides.md
16
16
  ```
17
17
 
18
+ To open the presentation directly at a specific slide, append `@N` (1-based):
19
+
20
+ ```
21
+ przn your_slides.md @42
22
+ ```
23
+
24
+ Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
25
+
18
26
  ### PDF export
19
27
 
28
+ Two flavors:
29
+
20
30
  ```
21
- przn --export your_slides.md
31
+ przn --export your_slides.md # vector capture (default)
22
32
  przn --export pdf your_slides.md
23
33
  przn --export pdf -o output.pdf your_slides.md
34
+
35
+ przn --export prawn your_slides.md # Prawn (headless fallback)
36
+ przn --export prawn -o output.pdf your_slides.md
24
37
  ```
25
38
 
26
- Requires a TrueType font (with `glyf` outlines) for proper rendering. Prawn does not support CFF-based fonts (most `.otf` files). Fonts are auto-detected in this order: NotoSansJP TTF, HackGen, Arial Unicode.
39
+ **`--export pdf`** (default) drives the live renderer for each slide and asks the terminal to save the rendered pane as a one-page **vector PDF**, then concatenates the per-slide PDFs into a single multi-page PDF. Output is an exact match of what's on screen — gradients, proportional fonts, OSC 66 sized text, custom bullets, all show up exactly as you'd see them — but vector, so the file stays small, scales infinitely, and text remains selectable. Requires running inside a terminal that implements the OSC 7772 `capture` command to a `.pdf` path (currently [Echoes](https://github.com/amatsuda/echoes)). The slides flicker through the visible pane during export.
40
+
41
+ **`--export prawn`** is the headless fallback: it renders the deck directly into a vector PDF via Prawn, without touching the terminal. Useful for CI or environments where Echoes isn't available, but diverges from the on-screen rendering for any feature the live renderer adds (OSC 66 sized text, OSC 7772 backgrounds, proportional fonts). Requires a TrueType font (with `glyf` outlines) for proper rendering — Prawn does not support CFF-based fonts (most `.otf` files). Fonts are auto-detected in this order: NotoSansJP TTF, HackGen, Arial Unicode.
27
42
 
28
43
  ### Key bindings
29
44
 
@@ -35,6 +50,10 @@ Requires a TrueType font (with `glyf` outlines) for proper rendering. Prawn does
35
50
  | `G` | Last slide |
36
51
  | `q` `Ctrl-C` | Quit |
37
52
 
53
+ ### Selecting and copying text
54
+
55
+ `przn` doesn't capture mouse events, so drag-to-select and the terminal's own copy shortcut (Kitty: `Cmd+C` on macOS, `Ctrl+Shift+C` on Linux) work normally on a slide. Mouse-tracking modes that may have leaked from a previously crashed program are explicitly disabled on entry, so drag selection is reliable.
56
+
38
57
  ## Markdown format
39
58
 
40
59
  przn's Markdown format is compatible with [Rabbit](https://rabbit-shocker.org/)'s Markdown mode.
@@ -62,6 +81,8 @@ more content
62
81
  `inline code`
63
82
  ```
64
83
 
84
+ Long lines wrap at whitespace boundaries (not mid-word) for English-style text. A single word that's longer than the line — a URL, a class name — still wraps at the character it has to. CJK runs without inter-character whitespace fall back to per-character splitting.
85
+
65
86
  ### Lists
66
87
 
67
88
  ```markdown
@@ -125,8 +146,41 @@ Uses Rabbit-compatible `{::tag}` notation. Supported size names: `xx-small`, `x-
125
146
  {::tag name="7"}Maximum size{:/tag}
126
147
  ```
127
148
 
149
+ An XML-style alternative is also accepted:
150
+
151
+ ```markdown
152
+ <size=x-large>Big text</size>
153
+ <size=7>Maximum size</size>
154
+ ```
155
+
128
156
  On [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminals, sized text is rendered using the OSC 66 text sizing protocol. On other terminals, the markup is silently ignored.
129
157
 
158
+ ### Color
159
+
160
+ Named ANSI colors (`red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, plus `bright_*` variants) and 6-digit hex. Use `{::tag name="..."}` (kramdown form) or the `color` attribute on `<font>` (see [Font](#font)).
161
+
162
+ ```markdown
163
+ {::tag name="red"}warning{:/tag}
164
+ {::tag name="ff5555"}custom hex{:/tag}
165
+
166
+ <font color="red">warning</font>
167
+ <font color="ff5555">custom hex</font>
168
+ ```
169
+
170
+ ### Font
171
+
172
+ HTML 4-style `<font>` tag with `face`, `size`, and `color` attributes. Any subset, in any order. The kramdown shape is also accepted.
173
+
174
+ ```markdown
175
+ <font face="Helvetica Neue">Title</font>
176
+ <font face="Menlo" size="3">code</font>
177
+ <font face="Menlo" size="3" color="red">flagged</font>
178
+
179
+ {::font name="Helvetica Neue"}Title{:/font}
180
+ ```
181
+
182
+ `face` requires a terminal that honors the OSC 66 `f=` extension (e.g. [Echoes](https://github.com/amatsuda/echoes)). For PDF export, the family is registered with Prawn via fontconfig — families that can't be found fall through to the default font.
183
+
130
184
  ### Alignment
131
185
 
132
186
  ```markdown
@@ -137,6 +191,33 @@ centered text
137
191
  right-aligned text
138
192
  ```
139
193
 
194
+ XML form (single-line, paragraph-level):
195
+
196
+ ```markdown
197
+ <center>centered <size=3>text</size></center>
198
+ <right>right-aligned</right>
199
+ ```
200
+
201
+ ### Slide background
202
+
203
+ Set a per-slide background — solid color or linear gradient — via a self-closing block-level directive. Uses the [Echoes](https://github.com/amatsuda/echoes) OSC 7772 extension; other terminals ignore the escape sequence.
204
+
205
+ ```markdown
206
+ # Title
207
+
208
+ <bg color="#1a1a2e"/>
209
+
210
+ content...
211
+
212
+ # Second slide
213
+
214
+ <bg from="#1a1a2e" to="#16213e" angle="90"/>
215
+
216
+ content...
217
+ ```
218
+
219
+ The previous slide's background is cleared on every navigation, and on `przn` exit, so your shell isn't left tinted.
220
+
140
221
  ### Comments
141
222
 
142
223
  ```markdown
@@ -149,8 +230,75 @@ This text is hidden from the presentation.
149
230
 
150
231
  ```markdown
151
232
  Visible text {::note}(speaker note){:/note}
233
+ Visible text <note>(speaker note)</note>
234
+ ```
235
+
236
+ ### Escaping `<`, `>`, `&`
237
+
238
+ To show literal markup characters that would otherwise be interpreted as a tag, use HTML-style entity references:
239
+
240
+ ```markdown
241
+ &lt;note&gt; renders as: <note>
242
+ 2 &lt; 3 renders as: 2 < 3
243
+ A &amp; B renders as: A & B
152
244
  ```
153
245
 
246
+ A bare `<` not followed by a recognized tag name renders literally as well, so most accidental `<` characters are fine. The entities are only needed when you'd otherwise hit one of the tag patterns (`<size=...>`, `<font ...>`, `<note>`, `<wait/>`, `<center>`, `<right>`, `<bg .../>`).
247
+
248
+ ### Wait marker
249
+
250
+ Self-closing presentation flow marker, consumed at parse time:
251
+
252
+ ```markdown
253
+ {::wait/}
254
+ <wait/>
255
+ ```
256
+
257
+ ## Theming
258
+
259
+ Theme resolution:
260
+
261
+ 1. **`theme.yml` in the deck's directory** — loaded automatically if present. No flag needed.
262
+ 2. **`--theme path/to/your.yml`** — overrides step 1 with any other file you point to.
263
+ 3. **`default_theme.yml`** (the file bundled with the gem) — used when neither of the above is found.
264
+
265
+ All keys are optional — anything you don't set falls back to the bundled defaults.
266
+
267
+ ```yaml
268
+ font:
269
+ family: # body text font; terminal: OSC 66 f=, PDF: Prawn font
270
+ size: 18 # base PDF font size in pt
271
+ color: # body text color; named ANSI or 6-digit hex
272
+
273
+ title: # h1 typography (slide titles)
274
+ family: # font family
275
+ size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large
276
+ color: # named ANSI or 6-digit hex
277
+
278
+ bullet: # unordered-list marker; also h2–h6 prefix
279
+ text: "・" # the glyph
280
+ size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
281
+
282
+ background: # default slide background (Echoes OSC 7772)
283
+ color: # solid, e.g. "#1a1a2e"
284
+ from: # gradient endpoint
285
+ to: # gradient endpoint
286
+ angle: # gradient angle in degrees
287
+
288
+ colors:
289
+ code_bg: "313244"
290
+ dim: "6c7086"
291
+ inline_code: "a6e3a1"
292
+ ```
293
+
294
+ Notes:
295
+
296
+ - **`font.color`** — deck-wide default text color (terminal: ANSI fg; PDF: Prawn fg). Inline `<color=...>` / `<font color="...">` runs still win per-segment.
297
+ - **`bullet`** — `bullet.text` is the character; `bullet.size` is the OSC 66 scale used to render it. When `bullet.size` is smaller than the body text scale, the bullet is rendered with fractional scaling and vertical centering so it still aligns with the body line.
298
+ - **`font.family`** — applied to body text (terminal: via OSC 66 `f=`, requires Echoes; PDF: registered via fontconfig). Inline `<font face="...">` runs override it per-segment.
299
+ - **`title`** — h1 typography. Each attribute is independent from `font`: `title.family` does **not** inherit `font.family`, `title.color` does **not** inherit `font.color`. `title.size` defaults to x-large (OSC 66 `s=4`). When `title.family` is proportional, every h1 OSC 66 sequence is emitted with `h=2` so a terminal that honors centered horizontal alignment ([Echoes](https://github.com/amatsuda/echoes)) keeps the title visually centered against its reserved cell block. h2–h6 stay body text.
300
+ - **`background`** — the deck-wide default background. A per-slide `<bg .../>` directive overrides it for that slide. The Prawn fallback paints the PDF page in `background.color` when set; otherwise it leaves the page Prawn's default (white).
301
+
154
302
  ## License
155
303
 
156
304
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/default_theme.yml CHANGED
@@ -1,16 +1,35 @@
1
- # Default przn theme
1
+ # Default przn theme. Used when there's neither a `theme.yml` alongside the
2
+ # deck nor a `--theme path/to/file.yml` flag.
2
3
  #
3
- # Copy this file and pass your copy via --theme to customize.
4
+ # To customize: drop a `theme.yml` next to your deck (loaded automatically),
5
+ # or pass `--theme path/to/your.yml` to override that.
4
6
  # All keys are optional — unspecified values fall back to these defaults.
5
7
 
8
+ font:
9
+ family: # body text font; terminal: OSC 66 f= (Echoes); PDF: Prawn font (auto-detected if blank)
10
+ size: 18 # base font size in pt for PDF export
11
+ color: # body text color (terminal: ANSI fg, PDF: Prawn fg); named ANSI or 6-digit hex
12
+
13
+ title: # h1 typography (slide titles); each attribute is independent of `font`
14
+ family: # font family (terminal: OSC 66 f=, requires Echoes)
15
+ size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large (4)
16
+ color: # named ANSI or 6-digit hex; falls back to the terminal's default fg when unset
17
+
18
+ bullet: # unordered-list marker; also h2–h6 heading prefix
19
+ text: "・" # the glyph
20
+ size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
21
+
22
+ # Default slide background, applied when a slide has no <bg .../> directive.
23
+ # Echoes-only (OSC 7772). Use either `color` (solid) or `from`/`to`/`angle`
24
+ # (linear gradient). Both unset = no background override.
25
+ background:
26
+ color: # solid color, e.g. "#1a1a2e"
27
+ from: # gradient endpoint, e.g. "#1a1a2e"
28
+ to: # gradient endpoint, e.g. "#16213e"
29
+ angle: # gradient angle in degrees (0 = left→right, 90 = bottom→top)
30
+ type: # gradient type; defaults to "linear"
31
+
6
32
  colors:
7
- background: "000000"
8
- foreground: "ffffff"
9
- heading: # falls back to foreground
10
33
  code_bg: "313244"
11
34
  dim: "6c7086"
12
35
  inline_code: "a6e3a1"
13
-
14
- font:
15
- family: # auto-detect
16
- size: 18 # base font size in pt for PDF export
data/exe/przn CHANGED
@@ -8,7 +8,7 @@ require 'optparse'
8
8
  options = {}
9
9
  OptionParser.new do |opts|
10
10
  opts.banner = "Usage: przn [options] <presentation.md>"
11
- opts.on('--export [FORMAT]', 'Export to a format (default: pdf)') { |v|
11
+ opts.on('--export [FORMAT]', 'Export to a format (pdf | prawn; default: pdf)') { |v|
12
12
  if v && v.end_with?('.md')
13
13
  ARGV.unshift(v)
14
14
  options[:export] = 'pdf'
@@ -28,17 +28,33 @@ if options[:generate_theme]
28
28
  exit
29
29
  end
30
30
 
31
+ # Extract a leading-`@N` positional like `przn deck.md @42` and treat it as
32
+ # the slide to open the presentation at (1-based). Removed from ARGV so the
33
+ # remaining positional is the deck file.
34
+ start_at = nil
35
+ if (idx = ARGV.index { |a| a =~ /\A@(\d+)\z/ })
36
+ start_at = ARGV.delete_at(idx)[1..].to_i
37
+ end
38
+
31
39
  file = ARGV[0]
32
40
  unless file
33
- $stderr.puts "Usage: przn [options] <presentation.md>"
41
+ $stderr.puts "Usage: przn [options] <presentation.md> [@N]"
34
42
  exit 1
35
43
  end
36
44
 
37
- theme = options[:theme] ? Przn::Theme.load(options[:theme]) : nil
45
+ theme = if options[:theme]
46
+ Przn::Theme.load(options[:theme])
47
+ else
48
+ Przn::Theme.auto_discover(near: file)
49
+ end
38
50
 
39
- if options[:export] == 'pdf'
51
+ case options[:export]
52
+ when 'pdf'
40
53
  output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
41
54
  Przn.export_pdf(file, output, theme: theme)
55
+ when 'prawn'
56
+ output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
57
+ Przn.export_pdf_prawn(file, output, theme: theme)
42
58
  else
43
- Przn.start(file, theme: theme).run
59
+ Przn.start(file, theme: theme, start_at: start_at).run
44
60
  end
@@ -6,6 +6,7 @@ module Przn
6
6
  @presentation = presentation
7
7
  @terminal = terminal
8
8
  @renderer = renderer
9
+ @preload_gen = 0
9
10
  end
10
11
 
11
12
  def run
@@ -34,6 +35,9 @@ module Przn
34
35
  end
35
36
  end
36
37
  ensure
38
+ @preload_gen += 1
39
+ @preload_thread&.join
40
+ @terminal.write "\e]7772;bg-clear\a"
37
41
  @terminal.show_cursor
38
42
  @terminal.leave_alt_screen
39
43
  end
@@ -46,6 +50,29 @@ module Przn
46
50
  current: @presentation.current,
47
51
  total: @presentation.total
48
52
  )
53
+ schedule_preload
54
+ end
55
+
56
+ # Kick off a background thread that pre-uploads images for the slides the
57
+ # user is most likely to visit next (the immediate neighbors). Uses a
58
+ # generation counter so a navigation that lands while a preload is still
59
+ # running causes that preload to exit early instead of stacking work.
60
+ def schedule_preload
61
+ @preload_gen += 1
62
+ gen = @preload_gen
63
+ cur = @presentation.current
64
+ total = @presentation.total
65
+ indices = [cur + 1, cur - 1].select { |i| i.between?(0, total - 1) }
66
+ return if indices.empty?
67
+
68
+ @preload_thread = Thread.new do
69
+ indices.each do |idx|
70
+ break if gen != @preload_gen
71
+ @renderer.preload(@presentation.slides[idx])
72
+ end
73
+ rescue StandardError
74
+ # Background work must not crash the presentation.
75
+ end
49
76
  end
50
77
 
51
78
  def read_key
@@ -65,6 +65,30 @@ module Przn
65
65
  ENV['TERM'] == 'xterm-kitty' || ENV['TERM_PROGRAM'] == 'kitty'
66
66
  end
67
67
 
68
+ PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
69
+
70
+ def png?(path)
71
+ File.open(path, 'rb') { |f| f.read(8)&.b == PNG_MAGIC }
72
+ rescue Errno::ENOENT
73
+ false
74
+ end
75
+
76
+ # Kitty Graphics Protocol: upload a PNG file by path with the given id.
77
+ # Kitty reads the file directly from disk; we just send a small APC
78
+ # control sequence with the base64-encoded path. Use this once per
79
+ # image; subsequent renders only need a placement command.
80
+ # https://sw.kovidgoyal.net/kitty/graphics-protocol/
81
+ def kitty_upload_png(path, image_id:)
82
+ encoded = [File.expand_path(path)].pack('m0')
83
+ "\e_Ga=t,t=f,f=100,i=#{image_id},q=2;#{encoded}\e\\"
84
+ end
85
+
86
+ # Kitty Graphics Protocol: place a previously-uploaded image at the
87
+ # current cursor position, scaled to fit `cols` x `rows` cells.
88
+ def kitty_place(image_id:, cols:, rows:)
89
+ "\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
90
+ end
91
+
68
92
  # Sixel via img2sixel
69
93
  def sixel_available?
70
94
  @sixel_available = system('command -v img2sixel > /dev/null 2>&1') if @sixel_available.nil?
@@ -10,10 +10,13 @@ module Przn
10
10
 
11
11
  module_function
12
12
 
13
- def sized(text, s:, h: nil, v: nil)
13
+ def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil)
14
14
  params = +"s=#{s}"
15
+ params << ":n=#{n}" if n
16
+ params << ":d=#{d}" if d
15
17
  params << ":h=#{h}" if h
16
18
  params << ":v=#{v}" if v
19
+ params << ":f=#{f}" if f
17
20
  "\e]66;#{params};#{text}\a"
18
21
  end
19
22
 
data/lib/przn/parser.rb CHANGED
@@ -71,6 +71,17 @@ module Przn
71
71
  when /\A\s*\{:\.(\w+)\}\s*\z/
72
72
  blocks << {type: :align, align: Regexp.last_match(1).to_sym}
73
73
 
74
+ # Block alignment, XML form: <center>content</center> or <right>content</right>
75
+ when /\A\s*<(center|right)>(.*)<\/\1>\s*\z/
76
+ blocks << {type: :align, align: Regexp.last_match(1).to_sym}
77
+ blocks << {type: :paragraph, content: Regexp.last_match(2)}
78
+
79
+ # Slide background (Echoes OSC 7772):
80
+ # <bg color="#..."/> — solid (bg-color)
81
+ # <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
82
+ when /\A\s*<bg((?:\s+\w+="[^"]+")*)\s*\/>\s*\z/
83
+ blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
84
+
74
85
  # Fenced code block
75
86
  when /\A\s*```(\w*)\s*\z/
76
87
  lang = Regexp.last_match(1)
@@ -217,6 +228,25 @@ module Przn
217
228
  Slide.new(blocks)
218
229
  end
219
230
 
231
+ # HTML4-style <font face="..." size="..." color="..."> attributes.
232
+ # Kramdown's {::font name="..."} legacy spelling for the family is also
233
+ # accepted and folded into :face so the renderer has one shape to handle.
234
+ def parse_font_attrs(str)
235
+ attrs = parse_xml_attrs(str)
236
+ attrs[:face] = attrs.delete(:name) if attrs.key?(:name) && !attrs.key?(:face)
237
+ attrs.slice(:face, :size, :color)
238
+ end
239
+
240
+ # Generic attribute scanner — `key="value"` pairs, returned as a hash with
241
+ # symbolized keys. Doesn't validate which keys are allowed; callers slice.
242
+ def parse_xml_attrs(str)
243
+ attrs = {}
244
+ str.scan(/(\w+)="([^"]+)"/) do |key, value|
245
+ attrs[key.to_sym] = value
246
+ end
247
+ attrs
248
+ end
249
+
220
250
  def parse_image_attrs(str, attrs)
221
251
  str = str.sub(/\A:?\s*/, '')
222
252
  str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
@@ -243,10 +273,24 @@ module Przn
243
273
  until scanner.eos?
244
274
  if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
245
275
  segments << [:tag, scanner[2], scanner[1]]
276
+ elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
277
+ segments << [:tag, scanner[2], scanner[1]]
278
+ elsif scanner.scan(/<font((?:\s+\w+="[^"]+")+)\s*>(.*?)<\/font>/)
279
+ segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
280
+ elsif scanner.scan(/\{::font((?:\s+\w+="[^"]+")+)\}(.*?)\{:\/font\}/)
281
+ segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
246
282
  elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
247
283
  segments << [:note, scanner[1]]
248
- elsif scanner.scan(/\{::wait\/\}/)
284
+ elsif scanner.scan(/<note>(.*?)<\/note>/)
285
+ segments << [:note, scanner[1]]
286
+ elsif scanner.scan(/\{::wait\/\}/) || scanner.scan(/<wait\s*\/>/)
249
287
  # skip wait markers in inline text
288
+ elsif scanner.scan(/&lt;/)
289
+ segments << [:text, "<"]
290
+ elsif scanner.scan(/&gt;/)
291
+ segments << [:text, ">"]
292
+ elsif scanner.scan(/&amp;/)
293
+ segments << [:text, "&"]
250
294
  elsif scanner.scan(/`([^`]+)`/)
251
295
  segments << [:code, scanner[1]]
252
296
  elsif scanner.scan(/\*\*(.+?)\*\*/)
@@ -256,7 +300,7 @@ module Przn
256
300
  elsif scanner.scan(/~~(.+?)~~/)
257
301
  segments << [:strikethrough, scanner[1]]
258
302
  else
259
- segments << [:text, scanner.scan(/[^`*~{]+|./)]
303
+ segments << [:text, scanner.scan(/[^`*~{<&]+|./)]
260
304
  end
261
305
  end
262
306
 
@@ -55,12 +55,11 @@ module Przn
55
55
  @presentation = presentation
56
56
  @base_dir = base_dir
57
57
  @theme = theme || Theme.default
58
- @bg_color = @theme.colors[:background]
59
- @fg_color = @theme.colors[:foreground]
58
+ @bg_color = @theme.background && @theme.background[:color]
59
+ @fg_color = @theme.font[:color] || "000000"
60
60
  @code_bg = @theme.colors[:code_bg]
61
61
  @dim_color = @theme.colors[:dim]
62
62
  @inline_code_color = @theme.colors[:inline_code]
63
- @heading_color = @theme.colors[:heading] || @fg_color
64
63
  base = (@theme.font[:size] || DEFAULT_FONT_SIZE).to_f
65
64
  ratio = base / DEFAULT_FONT_SIZE
66
65
  @scale_to_pt = DEFAULT_SCALE_TO_PT.transform_values { |v| v * ratio }
@@ -127,6 +126,57 @@ module Przn
127
126
  @font_registered = true
128
127
 
129
128
  register_emoji_fallback(pdf, font_path)
129
+ register_inline_fonts(pdf)
130
+ end
131
+
132
+ # Pre-register every font family referenced by an inline <font face="..."> /
133
+ # {::font name="..."} tag, so build_formatted_text can use it as a Prawn
134
+ # font: option. Families that fontconfig can't locate are silently dropped
135
+ # — those runs render in the default font instead of erroring.
136
+ def register_inline_fonts(pdf)
137
+ @registered_inline_fonts = {}
138
+ inline_font_families.each do |family|
139
+ next if @registered_inline_fonts.key?(family) || family == 'CJK'
140
+
141
+ path = fc_find(family)
142
+ next unless path
143
+ bold_path = find_bold_font(path, family)
144
+ pdf.font_families.update(family => {
145
+ normal: path,
146
+ bold: bold_path,
147
+ italic: path,
148
+ })
149
+ @registered_inline_fonts[family] = true
150
+ rescue
151
+ next
152
+ end
153
+ end
154
+
155
+ def inline_font_families
156
+ families = []
157
+ @presentation.slides.each do |slide|
158
+ slide.blocks.each do |b|
159
+ texts = []
160
+ texts << b[:content] if b[:content].is_a?(String)
161
+ texts << b[:term] if b[:term].is_a?(String)
162
+ texts << b[:definition] if b[:definition].is_a?(String)
163
+ if b[:items].is_a?(Array)
164
+ b[:items].each { |it| texts << it[:text] if it[:text].is_a?(String) }
165
+ end
166
+ if b[:rows].is_a?(Array)
167
+ (Array(b[:header]) + b[:rows].flatten).each { |c| texts << c if c.is_a?(String) }
168
+ end
169
+ texts.each do |text|
170
+ text.scan(/<font((?:\s+\w+="[^"]+")+)\s*>/) do
171
+ families << Parser.parse_font_attrs(Regexp.last_match(1))[:face]
172
+ end
173
+ text.scan(/\{::font((?:\s+\w+="[^"]+")+)\}/) do
174
+ families << Parser.parse_font_attrs(Regexp.last_match(1))[:face]
175
+ end
176
+ end
177
+ end
178
+ end
179
+ families.compact.uniq
130
180
  end
131
181
 
132
182
  def find_font
@@ -237,6 +287,8 @@ module Przn
237
287
  end
238
288
 
239
289
  def draw_background(pdf)
290
+ return unless @bg_color
291
+
240
292
  pdf.canvas do
241
293
  pdf.fill_color @bg_color
242
294
  pdf.fill_rectangle [0, PAGE_HEIGHT], PAGE_WIDTH, PAGE_HEIGHT
@@ -270,7 +322,7 @@ module Przn
270
322
  y - h - heading_margin(pt)
271
323
  else
272
324
  pt = @scale_to_pt[DEFAULT_SCALE]
273
- prefix = [{text: bullet, size: pt, color: @heading_color, styles: [:bold]}]
325
+ prefix = [{text: bullet, size: pt, color: @fg_color, styles: [:bold]}]
274
326
  formatted = prefix + build_formatted_text(text, pt)
275
327
  h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width)
276
328
  y - h - 4
@@ -455,6 +507,25 @@ module Przn
455
507
  else
456
508
  {text: content, size: default_pt, color: @fg_color}
457
509
  end
510
+ when :font
511
+ attrs = segment[2].is_a?(Hash) ? segment[2] : {}
512
+ run = {text: content, size: default_pt, color: @fg_color}
513
+ if attrs[:face] && @registered_inline_fonts&.key?(attrs[:face])
514
+ run[:font] = attrs[:face]
515
+ end
516
+ if attrs[:size] && (scale = Parser::SIZE_SCALES[attrs[:size]])
517
+ run[:size] = @scale_to_pt[scale]
518
+ end
519
+ if attrs[:color]
520
+ run[:color] = if (hex = COLOR_MAP[attrs[:color]])
521
+ hex
522
+ elsif attrs[:color].match?(/\A[0-9a-fA-F]{6}\z/)
523
+ attrs[:color].upcase
524
+ else
525
+ @fg_color
526
+ end
527
+ end
528
+ run
458
529
  when :bold
459
530
  {text: content, size: default_pt, color: @fg_color, styles: [:bold]}
460
531
  when :italic
@@ -538,7 +609,7 @@ module Przn
538
609
  end
539
610
 
540
611
  def bullet
541
- @font_registered ? "\u30FB" : "-"
612
+ @font_registered ? @theme.bullet[:text] : "-"
542
613
  end
543
614
 
544
615
  end