sakusei 0.3.0 → 0.5.8

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.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Sakusei
6
+ class ConverterBase
7
+ def initialize(content, style_pack, options = {})
8
+ @content = content
9
+ @style_pack = style_pack
10
+ @options = options
11
+ @source_dir = options[:source_dir]
12
+ end
13
+
14
+ protected
15
+
16
+ def copy_images(temp_dir)
17
+ return unless @source_dir
18
+
19
+ image_paths.each do |rel_path|
20
+ src = File.expand_path(rel_path, @source_dir)
21
+ next unless File.exist?(src)
22
+
23
+ dest = File.join(temp_dir, rel_path)
24
+ FileUtils.mkdir_p(File.dirname(dest))
25
+ FileUtils.cp(src, dest)
26
+ end
27
+ end
28
+
29
+ def image_paths
30
+ paths = []
31
+ @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
32
+ @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
33
+ paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
34
+ .uniq
35
+ end
36
+
37
+ def page_chrome_prefix
38
+ return '' unless @style_pack
39
+ %i[header footer].map do |part|
40
+ path = @style_pack.public_send(part)
41
+ path ? File.read(path) + "\n" : ''
42
+ end.join
43
+ end
44
+
45
+ def build_command(temp_md, temp_dir, extra_flags = [])
46
+ cmd = ['npx', 'md-to-pdf']
47
+
48
+ config = @options[:config] || @style_pack&.config
49
+ cmd << '--config-file' << config if config
50
+
51
+ stylesheets = [StylePack.base_stylesheet]
52
+ pack_stylesheet = @options[:stylesheet] || @style_pack&.stylesheet
53
+ stylesheets << pack_stylesheet if pack_stylesheet
54
+ stylesheets.each { |s| cmd << '--stylesheet' << s }
55
+
56
+ cmd << '--basedir' << temp_dir
57
+ extra_flags.each { |f| cmd << f }
58
+ cmd << temp_md
59
+ cmd
60
+ end
61
+ end
62
+ end
@@ -5,6 +5,8 @@ module Sakusei
5
5
  class FileResolver
6
6
  INCLUDE_PATTERN = /<!--\s*@include\s+(\S+)\s*-->/
7
7
 
8
+ attr_reader :resolved_files
9
+
8
10
  def initialize(source_file)
9
11
  @source_file = source_file
10
12
  @source_dir = File.dirname(source_file)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require_relative 'converter_base'
6
+
7
+ module Sakusei
8
+ # Converts markdown content to a styled HTML string by invoking
9
+ # `npx md-to-pdf --as-html`. Used by the live preview server.
10
+ class HtmlConverter < ConverterBase
11
+ def convert
12
+ Dir.mktmpdir('sakusei-html') do |temp_dir|
13
+ temp_md = File.join(temp_dir, 'input.md')
14
+ # Header/footer files are injected as @page margin chrome by PreviewServer.
15
+ # Don't prepend them to the body the way MdToPdfConverter does for PDF output.
16
+ File.write(temp_md, @content)
17
+
18
+ copy_images(temp_dir)
19
+
20
+ cmd = build_command(temp_md, temp_dir, ['--as-html'])
21
+ stdout, stderr, status = Open3.capture3(*cmd)
22
+
23
+ unless status.success?
24
+ raise Error, "HTML conversion failed: #{stderr.strip}\n#{stdout.strip}"
25
+ end
26
+
27
+ html_path = File.join(temp_dir, 'input.html')
28
+ raise Error, "md-to-pdf did not produce HTML at #{html_path}" unless File.exist?(html_path)
29
+
30
+ File.read(html_path)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,16 +2,14 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'tmpdir'
5
+ require_relative 'converter_base'
5
6
 
6
7
  module Sakusei
7
8
  # Converts markdown content to PDF using md-to-pdf
8
- class MdToPdfConverter
9
+ class MdToPdfConverter < ConverterBase
9
10
  def initialize(content, output_path, style_pack, options = {})
10
- @content = content
11
+ super(content, style_pack, options)
11
12
  @output_path = output_path
12
- @style_pack = style_pack
13
- @options = options
14
- @source_dir = options[:source_dir]
15
13
  end
16
14
 
17
15
  def convert
@@ -24,7 +22,7 @@ module Sakusei
24
22
  # so md-to-pdf's HTTP server can serve them by their relative paths.
25
23
  copy_images(temp_dir)
26
24
 
27
- cmd = build_command(temp_md, temp_dir)
25
+ cmd = build_command(temp_md, temp_dir).join(' ')
28
26
  result = system(cmd)
29
27
  raise Error, 'PDF conversion failed' unless result
30
28
 
@@ -33,65 +31,5 @@ module Sakusei
33
31
 
34
32
  @output_path
35
33
  end
36
-
37
- private
38
-
39
- # Scans content for relative image paths (markdown and HTML img src),
40
- # and copies each referenced file into temp_dir at the same relative path.
41
- def copy_images(temp_dir)
42
- return unless @source_dir
43
-
44
- image_paths.each do |rel_path|
45
- src = File.expand_path(rel_path, @source_dir)
46
- next unless File.exist?(src)
47
-
48
- dest = File.join(temp_dir, rel_path)
49
- FileUtils.mkdir_p(File.dirname(dest))
50
- FileUtils.cp(src, dest)
51
- end
52
- end
53
-
54
- # Extracts relative image paths from both markdown syntax and HTML img tags.
55
- def image_paths
56
- paths = []
57
-
58
- # Markdown: ![alt](path)
59
- @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
60
-
61
- # HTML: src="path" (covers Vue-rendered img tags)
62
- @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
63
-
64
- # Filter to relative paths only (skip http, https, data URIs, absolute paths)
65
- paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
66
- .uniq
67
- end
68
-
69
- def page_chrome_prefix
70
- return '' unless @style_pack
71
- %i[header footer].map do |part|
72
- path = @style_pack.public_send(part)
73
- path ? File.read(path) + "\n" : ''
74
- end.join
75
- end
76
-
77
- def build_command(temp_path, temp_dir)
78
- cmd = ['npx', 'md-to-pdf']
79
-
80
- config = @options[:config] || @style_pack.config
81
- cmd << '--config-file' << config if config
82
-
83
- stylesheets = [StylePack.base_stylesheet]
84
- pack_stylesheet = @options[:stylesheet] || @style_pack.stylesheet
85
- stylesheets << pack_stylesheet if pack_stylesheet
86
- stylesheets.each { |s| cmd << '--stylesheet' << s }
87
-
88
- # Set basedir to temp_dir so relativePath resolves to /input.md,
89
- # making image src="images/foo.jpg" resolve to http://localhost:PORT/images/foo.jpg
90
- # which is served from temp_dir where we've copied the assets.
91
- cmd << '--basedir' << temp_dir
92
-
93
- cmd << temp_path
94
- cmd.join(' ')
95
- end
96
34
  end
97
35
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'json'
5
+ require 'open3'
6
+
7
+ module Sakusei
8
+ # Builds the live-preview's page chrome from the style pack's PDF
9
+ # configuration, matching what Puppeteer renders for the PDF as closely as
10
+ # possible.
11
+ #
12
+ # Source of truth is `pdf_options.headerTemplate` / `footerTemplate` /
13
+ # `format` / `margin` inside `config.js`. We evaluate `config.js` with Node
14
+ # to expand JS template literals (e.g. `${wordmarkDataUri}`), then translate
15
+ # the resulting HTML strings into running elements + @page CSS.
16
+ #
17
+ # Strategy: Puppeteer's headerTemplate / footerTemplate are arbitrary HTML
18
+ # (with images, flex layouts, multi-column structures). CSS @page margin
19
+ # boxes only accept a content string, not arbitrary HTML — so we use
20
+ # CSS Paged Media's running-element mechanism instead:
21
+ #
22
+ # .sakusei-running-header { position: running(sk_header); }
23
+ # @page { @top-center { content: element(sk_header); } }
24
+ #
25
+ # Placeholder rewrites (matching Puppeteer's chrome semantics):
26
+ # - <span class="pageNumber"></span> → <span class="sk-pn"></span>, with
27
+ # .sk-pn::after { content: counter(page); }
28
+ # - <span class="totalPages"></span> → <span class="sk-tp"></span>, similar
29
+ # - <span class="title"></span> → "" (Puppeteer leaves it blank by default)
30
+ # - <span class="date"></span> → today's date (static at build time)
31
+ # - <span class="url"></span> → ""
32
+ # - <img src="rel/path"> → <img src="/__pack/rel/path"> so the browser can
33
+ # fetch images from the style-pack directory. Data URIs and absolute
34
+ # URLs pass through unchanged.
35
+ #
36
+ # Returns { css:, html: } — caller injects css into <head>, prepends html
37
+ # into <body>.
38
+ class PageChromeTranslator
39
+ HEADER_RUNNING_NAME = 'sk_header'
40
+ FOOTER_RUNNING_NAME = 'sk_footer'
41
+ HEADER_CLASS = 'sakusei-running-header'
42
+ FOOTER_CLASS = 'sakusei-running-footer'
43
+ PAGE_NUMBER_CLASS = 'sk-pn'
44
+ TOTAL_PAGES_CLASS = 'sk-tp'
45
+
46
+ NODE_EXTRACT_SCRIPT = <<~JS.freeze
47
+ try {
48
+ const c = require(process.argv[1]);
49
+ const o = (c && c.pdf_options) || {};
50
+ const enabled = o.displayHeaderFooter !== false;
51
+ process.stdout.write(JSON.stringify({
52
+ format: o.format || null,
53
+ margin: o.margin || null,
54
+ headerTemplate: enabled ? (o.headerTemplate || null) : null,
55
+ footerTemplate: enabled ? (o.footerTemplate || null) : null
56
+ }));
57
+ } catch (e) {
58
+ process.stderr.write(String(e && e.stack || e));
59
+ process.exit(2);
60
+ }
61
+ JS
62
+
63
+ def initialize(style_pack)
64
+ @style_pack = style_pack
65
+ end
66
+
67
+ def build
68
+ return empty_result unless @style_pack
69
+ opts = read_pdf_options
70
+
71
+ # Extract chrome strip backgrounds from the templates BEFORE we strip them.
72
+ # Puppeteer's PDF chrome uses a `position: fixed; top: -10cm; bottom: -10cm`
73
+ # div that breaks out to fill the full chrome strip. paged.js's running
74
+ # elements live inside the @top-center / @bottom-center margin boxes which
75
+ # are constrained to the page's content width, so the same trick covers the
76
+ # whole page (wrong) instead of just the strip. We replicate the strip via
77
+ # .pagedjs_page::before / ::after pseudo-elements with the extracted color.
78
+ header_bg = extract_chrome_bg(opts[:header_template])
79
+ footer_bg = extract_chrome_bg(opts[:footer_template])
80
+
81
+ header_html = transform_template(opts[:header_template])
82
+ footer_html = transform_template(opts[:footer_template])
83
+
84
+ html = +''
85
+ html << wrap_running(:header, header_html) if header_html && !header_html.empty?
86
+ html << wrap_running(:footer, footer_html) if footer_html && !footer_html.empty?
87
+
88
+ { css: build_css(opts, header_html, footer_html, header_bg, footer_bg), html: html }
89
+ end
90
+
91
+ private
92
+
93
+ def empty_result
94
+ { css: '', html: '' }
95
+ end
96
+
97
+ def wrap_running(kind, inner)
98
+ class_name = kind == :header ? HEADER_CLASS : FOOTER_CLASS
99
+ tag = kind == :header ? 'header' : 'footer'
100
+ %(<#{tag} class="#{class_name}">#{inner}</#{tag}>)
101
+ end
102
+
103
+ def build_css(opts, header_html, footer_html, header_bg, footer_bg)
104
+ pieces = []
105
+
106
+ page_decls = []
107
+ page_decls << "size: #{opts[:size]};" if opts[:size]
108
+ page_decls << "margin: #{opts[:margin]};" if opts[:margin]
109
+ page_decls << "@top-center { content: element(#{HEADER_RUNNING_NAME}); }" if header_html && !header_html.empty?
110
+ page_decls << "@bottom-center { content: element(#{FOOTER_RUNNING_NAME}); }" if footer_html && !footer_html.empty?
111
+ pieces << "@page { #{page_decls.join(' ')} }" unless page_decls.empty?
112
+
113
+ pieces << ".#{HEADER_CLASS} { position: running(#{HEADER_RUNNING_NAME}); }" if header_html && !header_html.empty?
114
+ pieces << ".#{FOOTER_CLASS} { position: running(#{FOOTER_RUNNING_NAME}); }" if footer_html && !footer_html.empty?
115
+
116
+ pieces << ".#{PAGE_NUMBER_CLASS}::after { content: counter(page); }"
117
+ pieces << ".#{TOTAL_PAGES_CLASS}::after { content: counter(pages); }"
118
+
119
+ sides = opts[:margin_sides] || {}
120
+
121
+ # Full-page-width chrome strips, sized by the page's top/bottom margins.
122
+ if header_bg && sides['top']
123
+ pieces << <<~CSS
124
+ .pagedjs_page { position: relative; }
125
+ .pagedjs_page::before {
126
+ content: "";
127
+ position: absolute;
128
+ top: 0; left: 0; right: 0;
129
+ height: #{sides['top']};
130
+ background: #{header_bg};
131
+ z-index: 0;
132
+ -webkit-print-color-adjust: exact;
133
+ print-color-adjust: exact;
134
+ }
135
+ CSS
136
+ end
137
+
138
+ if footer_bg && sides['bottom']
139
+ pieces << <<~CSS
140
+ .pagedjs_page::after {
141
+ content: "";
142
+ position: absolute;
143
+ bottom: 0; left: 0; right: 0;
144
+ height: #{sides['bottom']};
145
+ background: #{footer_bg};
146
+ z-index: 0;
147
+ -webkit-print-color-adjust: exact;
148
+ print-color-adjust: exact;
149
+ }
150
+ CSS
151
+ end
152
+
153
+ # Make the running content extend to the full page width by overflowing
154
+ # the @top-center / @bottom-center margin box on the left and right by
155
+ # the page's side margins. Restores the Puppeteer chrome's edge-to-edge
156
+ # padding semantics (e.g. `padding: 0.4cm 26px 0.6cm 26px` inside the
157
+ # template now positions content from page edges, not from inner content
158
+ # area). Also lifts the content above the chrome strip pseudo-elements.
159
+ if sides['left'] || sides['right']
160
+ left = sides['left'] || '0'
161
+ right = sides['right'] || '0'
162
+ pieces << <<~CSS
163
+ .pagedjs_margin-top-center > .pagedjs_margin-content > *,
164
+ .pagedjs_margin-bottom-center > .pagedjs_margin-content > * {
165
+ margin-left: -#{left};
166
+ margin-right: -#{right};
167
+ position: relative;
168
+ z-index: 1;
169
+ }
170
+ CSS
171
+ end
172
+
173
+ pieces.join("\n")
174
+ end
175
+
176
+ # Spawns Node to require() config.js and emit pdf_options as JSON.
177
+ # Returns { size:, margin:, header_template:, footer_template: }, all
178
+ # nil-safe. On any failure, returns {} and logs to stderr.
179
+ def read_pdf_options
180
+ return {} unless @style_pack.config && File.exist?(@style_pack.config)
181
+
182
+ stdout, stderr, status = Open3.capture3(
183
+ 'node', '-e', NODE_EXTRACT_SCRIPT, @style_pack.config
184
+ )
185
+ unless status.success?
186
+ $stderr.puts "[sakusei-preview] could not read pdf_options from config.js: #{stderr.strip}"
187
+ return {}
188
+ end
189
+
190
+ raw = JSON.parse(stdout)
191
+ sides = raw['margin'].is_a?(Hash) ? raw['margin'] : nil
192
+ {
193
+ size: raw['format'],
194
+ margin: format_margin(sides),
195
+ margin_sides: sides,
196
+ header_template: raw['headerTemplate'],
197
+ footer_template: raw['footerTemplate']
198
+ }
199
+ rescue StandardError => e
200
+ $stderr.puts "[sakusei-preview] failed to extract chrome from config.js: #{e.message}"
201
+ {}
202
+ end
203
+
204
+ def format_margin(margin)
205
+ return nil unless margin.is_a?(Hash)
206
+ sides = %w[top right bottom left].map { |s| margin[s] || '0' }
207
+ sides.join(' ')
208
+ end
209
+
210
+ # Heuristic: Puppeteer chrome templates often use a position-fixed
211
+ # background div to color the chrome strip. Find the first such background
212
+ # color so we can replicate the strip via .pagedjs_page::before / ::after.
213
+ # Looks for either `.X-bg { ... background: <color>; ... }` in a <style>
214
+ # block, or `<div ... style="...background: <color>...">` with a *-bg class.
215
+ def extract_chrome_bg(template)
216
+ return nil unless template
217
+ if template =~ /<style\b[^>]*>[\s\S]*?\.\w+-bg\s*\{[^}]*?background:\s*([^;}]+?)\s*[;}]/m
218
+ return Regexp.last_match(1).strip
219
+ end
220
+ if template =~ /<div\b[^>]*class=["'][^"']*\b\w+-bg\b[^"']*["'][^>]*style=["'][^"']*background:\s*([^;"]+)/m
221
+ return Regexp.last_match(1).strip
222
+ end
223
+ nil
224
+ end
225
+
226
+ # Transform a Puppeteer chrome template (HTML string) into preview HTML.
227
+ def transform_template(raw)
228
+ return nil if raw.nil?
229
+ s = raw.to_s.dup
230
+ return nil if s.strip.empty?
231
+
232
+ strip_chrome_bg_markup!(s)
233
+ s.gsub!(/<!--.*?-->/m, '')
234
+ s.gsub!(/<span\s+class=["']pageNumber["']\s*>\s*<\/span>/i, %(<span class="#{PAGE_NUMBER_CLASS}"></span>))
235
+ s.gsub!(/<span\s+class=["']totalPages["']\s*>\s*<\/span>/i, %(<span class="#{TOTAL_PAGES_CLASS}"></span>))
236
+ s.gsub!(/<span\s+class=["']title["']\s*>\s*<\/span>/i, '')
237
+ s.gsub!(/<span\s+class=["']date["']\s*>\s*<\/span>/i, Date.today.strftime('%Y-%m-%d'))
238
+ s.gsub!(/<span\s+class=["']url["']\s*>\s*<\/span>/i, '')
239
+ rewrite_image_paths!(s)
240
+ s.strip!
241
+ s
242
+ end
243
+
244
+ # Remove Puppeteer-specific markup that we replicate via paged.js CSS
245
+ # (the <style> block defining .X-bg and the empty <div class="X-bg">).
246
+ # Leaving them in would cover the whole page in paged.js because
247
+ # `position: fixed` resolves against .pagedjs_page, not the chrome strip.
248
+ def strip_chrome_bg_markup!(html)
249
+ html.sub!(/<style\b[^>]*>[\s\S]*?\.\w+-bg\s*\{[\s\S]*?\}[\s\S]*?<\/style>/m, '')
250
+ html.gsub!(/<div\b[^>]*class=["'][^"']*\b\w+-bg\b[^"']*["'][^>]*>\s*<\/div>/m, '')
251
+ end
252
+
253
+ # Rewrite <img src="rel/path"> to <img src="/__pack/rel/path"> for any
254
+ # path that looks relative (no scheme, no leading slash). Data URIs and
255
+ # absolute URLs pass through.
256
+ def rewrite_image_paths!(html)
257
+ html.gsub!(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/i) do
258
+ prefix = Regexp.last_match(1)
259
+ src = Regexp.last_match(2)
260
+ suffix = Regexp.last_match(3)
261
+ new_src = if src.match?(%r{\A(https?:|data:|/)}) then src
262
+ else "/__pack/#{src}"
263
+ end
264
+ "#{prefix}#{new_src}#{suffix}"
265
+ end
266
+ end
267
+ end
268
+ end