sakusei 0.4.0 → 0.5.9

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,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