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.
- checksums.yaml +4 -4
- data/.claude/settings.json +8 -0
- data/CLAUDE.md +86 -0
- data/Gemfile.lock +82 -0
- data/bin/sakusei-preview +40 -0
- data/lib/sakusei/builder.rb +35 -18
- data/lib/sakusei/cli.rb +2 -14
- data/lib/sakusei/converter_base.rb +62 -0
- data/lib/sakusei/file_resolver.rb +2 -0
- data/lib/sakusei/html_converter.rb +34 -0
- data/lib/sakusei/md_to_pdf_converter.rb +4 -66
- data/lib/sakusei/page_chrome_translator.rb +268 -0
- data/lib/sakusei/preview_server.rb +444 -0
- data/lib/sakusei/version.rb +1 -1
- data/lib/sakusei.rb +24 -0
- data/sakusei.gemspec +3 -1
- metadata +39 -2
|
@@ -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
|