@entropicwarrior/sdoc 0.2.18 → 0.2.19
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.
- package/README.md +5 -2
- package/docs/reference/sdoc-authoring.sdoc +1 -1
- package/docs/reference/slide-authoring.sdoc +441 -20
- package/package.json +4 -1
- package/src/slide-geometry.js +470 -0
- package/src/slide-layouts.js +514 -0
- package/src/slide-pdf.js +9 -3
- package/src/slide-pptx.js +599 -0
- package/src/slide-renderer.js +93 -57
- package/src/theme.js +141 -0
- package/src/zip.js +102 -0
package/src/slide-renderer.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// const html = renderSlides(nodes, { meta, themeCss, themeJs });
|
|
9
9
|
|
|
10
10
|
const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
|
|
11
|
+
const { extractConfig, buildBody, accentClass, slug } = require("./slide-layouts");
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Inline rendering — produces clean HTML without sdoc-* classes
|
|
@@ -164,35 +165,18 @@ function renderChildren(nodes) {
|
|
|
164
165
|
return nodes.map((node) => renderNode(node)).join("\n");
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// Callbacks handed to the layout builders so slide-layouts.js stays free of
|
|
169
|
+
// parser and KaTeX dependencies.
|
|
170
|
+
const LAYOUT_CONTEXT = { renderChildren, renderInline, escapeHtml };
|
|
171
|
+
|
|
172
|
+
// Layouts whose bare name is also emitted as a slide class, for themes written
|
|
173
|
+
// before `layout-*` existed. New layouts are not added here.
|
|
174
|
+
const LEGACY_LAYOUT_CLASSES = new Set(["center", "two-column"]);
|
|
175
|
+
|
|
167
176
|
// ---------------------------------------------------------------------------
|
|
168
177
|
// Slide-level extraction
|
|
169
178
|
// ---------------------------------------------------------------------------
|
|
170
179
|
|
|
171
|
-
// Extracts config: lines from the beginning of a scope's children.
|
|
172
|
-
// Returns { config: { key: value, ... }, contentNodes: [...] }
|
|
173
|
-
function extractSlideConfig(children) {
|
|
174
|
-
const config = {};
|
|
175
|
-
const contentNodes = [];
|
|
176
|
-
let pastConfig = false;
|
|
177
|
-
|
|
178
|
-
for (const child of children) {
|
|
179
|
-
if (!pastConfig && child.type === "paragraph") {
|
|
180
|
-
const match = child.text.match(/^config\s*:\s*(.+)$/i);
|
|
181
|
-
if (match) {
|
|
182
|
-
const value = match[1].trim();
|
|
183
|
-
// Support multiple config lines; last one wins for the same key
|
|
184
|
-
// For now, config values are simple strings; primary use is layout
|
|
185
|
-
config.layout = value;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
pastConfig = true;
|
|
190
|
-
contentNodes.push(child);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return { config, contentNodes };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
180
|
// Separates @notes child scope from other children
|
|
197
181
|
function extractNotes(children) {
|
|
198
182
|
const notes = [];
|
|
@@ -237,13 +221,25 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
|
|
|
237
221
|
// Pull :detail children out first so they don't appear inline in the spine
|
|
238
222
|
// slide's content; they're rendered as sibling vertical slides instead.
|
|
239
223
|
const { contentNodes: afterDetails } = extractDetails(scope.children);
|
|
240
|
-
const { config, contentNodes: afterConfig } =
|
|
224
|
+
const { config, contentNodes: afterConfig } = extractConfig(afterDetails);
|
|
241
225
|
const { notes, contentNodes } = extractNotes(afterConfig);
|
|
242
226
|
|
|
227
|
+
const layout = config.layout || "";
|
|
243
228
|
const classes = ["slide"];
|
|
244
|
-
if (
|
|
245
|
-
|
|
229
|
+
if (layout) {
|
|
230
|
+
// `layout-*` is the class themes target. The bare layout name is emitted
|
|
231
|
+
// only for the two layouts that predate it, because themes in the wild
|
|
232
|
+
// are written against `.center` and `.two-column`. It is not emitted for
|
|
233
|
+
// the structured layouts: those put a container class of the same name
|
|
234
|
+
// inside the slide (`.columns`, `.stats`, `.rows`…), and a slide also
|
|
235
|
+
// carrying that class would match the container's own rules.
|
|
236
|
+
if (LEGACY_LAYOUT_CLASSES.has(layout)) classes.push(layout);
|
|
237
|
+
// Slugged, so no configuration value can close the class attribute.
|
|
238
|
+
const layoutSlug = slug(layout);
|
|
239
|
+
if (layoutSlug) classes.push(`layout-${layoutSlug}`);
|
|
246
240
|
}
|
|
241
|
+
const accent = accentClass(config.accent).trim();
|
|
242
|
+
if (accent) classes.push(accent);
|
|
247
243
|
if (position && position.detail === 0 && position.hasDetails) {
|
|
248
244
|
classes.push("slide-has-details");
|
|
249
245
|
}
|
|
@@ -251,29 +247,33 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
|
|
|
251
247
|
classes.push("slide-detail");
|
|
252
248
|
}
|
|
253
249
|
|
|
254
|
-
|
|
255
|
-
|
|
250
|
+
// On a title slide the kicker sits beneath the statement rather than above
|
|
251
|
+
// it, so the eye lands on the name first.
|
|
252
|
+
const isTitleLayout = layout === "title";
|
|
253
|
+
const kickerHtml = config.kicker
|
|
254
|
+
? `<div class="kicker">${renderInline(config.kicker)}</div>`
|
|
256
255
|
: "";
|
|
257
256
|
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
const preamble = nonColumns.length ? renderChildren(nonColumns) : "";
|
|
264
|
-
const columnsHtml = columns
|
|
265
|
-
.map((col) => {
|
|
266
|
-
const colTitle = col.hasHeading !== false && col.title
|
|
267
|
-
? `<h3>${renderInline(col.title)}</h3>`
|
|
268
|
-
: "";
|
|
269
|
-
const colContent = renderChildren(col.children);
|
|
270
|
-
return `<div class="column">${colTitle}\n${colContent}</div>`;
|
|
271
|
-
})
|
|
272
|
-
.join("\n");
|
|
273
|
-
bodyHtml = preamble + `\n<div class="columns">\n${columnsHtml}\n</div>`;
|
|
274
|
-
} else {
|
|
275
|
-
bodyHtml = renderChildren(contentNodes);
|
|
257
|
+
const headParts = [];
|
|
258
|
+
if (kickerHtml && !isTitleLayout) headParts.push(kickerHtml);
|
|
259
|
+
if (scope.hasHeading !== false && scope.title) {
|
|
260
|
+
const tag = isTitleLayout ? "h1" : "h2";
|
|
261
|
+
headParts.push(`<${tag}>${renderInline(scope.title)}</${tag}>`);
|
|
276
262
|
}
|
|
263
|
+
if (config.lede) headParts.push(`<p class="lede">${renderInline(config.lede)}</p>`);
|
|
264
|
+
const title = headParts.length
|
|
265
|
+
? `<header class="slide-head">\n${headParts.join("\n")}\n</header>`
|
|
266
|
+
: "";
|
|
267
|
+
|
|
268
|
+
const bodyInner = buildBody(layout, { config, contentNodes }, LAYOUT_CONTEXT);
|
|
269
|
+
|
|
270
|
+
const tailParts = [];
|
|
271
|
+
if (kickerHtml && isTitleLayout) tailParts.push(kickerHtml);
|
|
272
|
+
if (config.status) tailParts.push(`<div class="status">${renderInline(config.status)}</div>`);
|
|
273
|
+
if (config.footnote) tailParts.push(`<div class="footnote">${renderInline(config.footnote)}</div>`);
|
|
274
|
+
const tailHtml = tailParts.length ? `\n${tailParts.join("\n")}` : "";
|
|
275
|
+
|
|
276
|
+
const bodyHtml = `<div class="slide-body">\n${bodyInner}\n</div>${tailHtml}`;
|
|
277
277
|
|
|
278
278
|
const notesHtml = notes.length
|
|
279
279
|
? `\n<aside class="notes">${notes.map((n) => renderChildren(n.children)).join("\n")}</aside>`
|
|
@@ -310,9 +310,27 @@ function renderSlides(nodes, options = {}) {
|
|
|
310
310
|
meta = {},
|
|
311
311
|
themeCss = "",
|
|
312
312
|
themeJs = "",
|
|
313
|
-
darkMode = false
|
|
313
|
+
darkMode = false,
|
|
314
|
+
themeConfig = {},
|
|
315
|
+
fit = null
|
|
314
316
|
} = options;
|
|
315
317
|
|
|
318
|
+
// The design box and print page come from the theme (themes/<name>/theme.json).
|
|
319
|
+
// They must agree: the box in CSS pixels is the page in inches at 96 dpi, which
|
|
320
|
+
// is what makes screen and PDF the same geometry.
|
|
321
|
+
const slideW = (themeConfig.slide && themeConfig.slide.width) || 1280;
|
|
322
|
+
const slideH = (themeConfig.slide && themeConfig.slide.height) || 720;
|
|
323
|
+
const pageW = (themeConfig.page && themeConfig.page.width) || 13.333;
|
|
324
|
+
const pageH = (themeConfig.page && themeConfig.page.height) || 7.5;
|
|
325
|
+
|
|
326
|
+
// How the design box meets a window of a different shape.
|
|
327
|
+
// contain — scale to fit, letterbox the remainder (the default)
|
|
328
|
+
// cover — scale to fill, crop the overflow
|
|
329
|
+
// stretch — scale each axis independently, distorting the slide
|
|
330
|
+
const FIT_MODES = new Set(["contain", "cover", "stretch"]);
|
|
331
|
+
const requested = (fit || themeConfig.fit || "contain").toLowerCase();
|
|
332
|
+
const fitMode = FIT_MODES.has(requested) ? requested : "contain";
|
|
333
|
+
|
|
316
334
|
// The nodes from extractMeta have @meta already stripped.
|
|
317
335
|
// If there's a document scope wrapper, unwrap it to get the slides.
|
|
318
336
|
let slideScopes;
|
|
@@ -401,8 +419,9 @@ function renderSlides(nodes, options = {}) {
|
|
|
401
419
|
author's layout is preserved verbatim at every window size, and screen
|
|
402
420
|
and PDF are the same geometry by construction.
|
|
403
421
|
|
|
404
|
-
--sdoc-slide-scale
|
|
405
|
-
themes/default/theme.js) on load and on
|
|
422
|
+
--sdoc-slide-scale and --sdoc-slide-scale-y are written by the theme
|
|
423
|
+
runtime (fitSlidesToWindow in themes/default/theme.js) on load and on
|
|
424
|
+
resize, according to the fit mode on <html data-sdoc-fit>. If JS never runs the
|
|
406
425
|
scale stays 1 and the deck renders at its natural design size, which is
|
|
407
426
|
the pre-scaling behaviour rather than a broken one.
|
|
408
427
|
|
|
@@ -411,9 +430,18 @@ function renderSlides(nodes, options = {}) {
|
|
|
411
430
|
aspect ratio should override --sdoc-slide-w / --sdoc-slide-h on :root
|
|
412
431
|
(and the @page size in the print block, if PDF output matters). */
|
|
413
432
|
:root {
|
|
414
|
-
--sdoc-slide-w:
|
|
415
|
-
--sdoc-slide-h:
|
|
433
|
+
--sdoc-slide-w: ${slideW}px;
|
|
434
|
+
--sdoc-slide-h: ${slideH}px;
|
|
416
435
|
--sdoc-slide-scale: 1;
|
|
436
|
+
/* Defaults to the horizontal scale, so a theme shipping its own runtime
|
|
437
|
+
that writes only --sdoc-slide-scale still scales uniformly. Only the
|
|
438
|
+
stretch fit mode ever sets these two to different values. */
|
|
439
|
+
--sdoc-slide-scale-y: var(--sdoc-slide-scale);
|
|
440
|
+
/* The ground behind the slide, seen wherever the window is not the slide's
|
|
441
|
+
shape. A theme that leaves this equal to its slide background gets an
|
|
442
|
+
invisible letterbox: the slide has no edge and anything pinned to its
|
|
443
|
+
bottom looks stranded. */
|
|
444
|
+
--sdoc-letterbox: #000;
|
|
417
445
|
}
|
|
418
446
|
.slide {
|
|
419
447
|
position: absolute;
|
|
@@ -421,7 +449,8 @@ function renderSlides(nodes, options = {}) {
|
|
|
421
449
|
left: 50%;
|
|
422
450
|
width: var(--sdoc-slide-w);
|
|
423
451
|
height: var(--sdoc-slide-h);
|
|
424
|
-
transform: translate(-50%, -50%)
|
|
452
|
+
transform: translate(-50%, -50%)
|
|
453
|
+
scale(var(--sdoc-slide-scale), var(--sdoc-slide-scale-y));
|
|
425
454
|
transform-origin: center center;
|
|
426
455
|
overflow: hidden;
|
|
427
456
|
}
|
|
@@ -461,8 +490,15 @@ function renderSlides(nodes, options = {}) {
|
|
|
461
490
|
beforeprint handler to shrink overflowing content to fit the page. */
|
|
462
491
|
.slide-content-scale { display: contents; }
|
|
463
492
|
|
|
493
|
+
/* Head and body wrappers are transparent to layout by default, so a theme
|
|
494
|
+
written before they existed sees exactly the box tree it saw then: the
|
|
495
|
+
heading and the content as direct children of the slide. A theme that
|
|
496
|
+
wants a real header band or a body that fills the remaining height
|
|
497
|
+
overrides these two rules. */
|
|
498
|
+
.slide-head, .slide-body { display: contents; }
|
|
499
|
+
|
|
464
500
|
@media print {
|
|
465
|
-
@page { size:
|
|
501
|
+
@page { size: ${pageW}in ${pageH}in; margin: 0; }
|
|
466
502
|
body { overflow: visible; height: auto; }
|
|
467
503
|
.slide {
|
|
468
504
|
display: block !important;
|
|
@@ -470,7 +506,7 @@ function renderSlides(nodes, options = {}) {
|
|
|
470
506
|
opacity: 1 !important;
|
|
471
507
|
pointer-events: auto !important;
|
|
472
508
|
page-break-after: always; break-after: page;
|
|
473
|
-
/* The design box IS the page (
|
|
509
|
+
/* The design box IS the page (the theme's px box at 96dpi),
|
|
474
510
|
so no fit-to-window scaling applies here — each slide flows as one
|
|
475
511
|
page at its natural size. Overflowing content is still shrunk by
|
|
476
512
|
the inner .slide-content-scale wrapper (see fitSlidesForPrint). */
|
|
@@ -522,7 +558,7 @@ blockquote p { color: #9d9d9d; }
|
|
|
522
558
|
: "";
|
|
523
559
|
|
|
524
560
|
return `<!DOCTYPE html>
|
|
525
|
-
<html lang="en">
|
|
561
|
+
<html lang="en" data-sdoc-fit="${fitMode}">
|
|
526
562
|
<head>
|
|
527
563
|
<meta charset="UTF-8">
|
|
528
564
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SDOC Slides — theme loading.
|
|
2
|
+
//
|
|
3
|
+
// A theme is a directory holding `theme.css`, optionally `theme.js`, and
|
|
4
|
+
// optionally assets the CSS refers to: web fonts, background images, textures.
|
|
5
|
+
//
|
|
6
|
+
// Loading a theme inlines those assets. Every `url(...)` in the stylesheet
|
|
7
|
+
// that names a path relative to the theme directory is replaced by a `data:`
|
|
8
|
+
// URI, so the built deck stays a single self-contained file that renders the
|
|
9
|
+
// same offline, on another machine, and inside headless Chrome during PDF
|
|
10
|
+
// export. Absolute URLs, protocol-relative URLs and existing `data:` URIs are
|
|
11
|
+
// left alone.
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const MIME_TYPES = {
|
|
17
|
+
".woff2": "font/woff2",
|
|
18
|
+
".woff": "font/woff",
|
|
19
|
+
".ttf": "font/ttf",
|
|
20
|
+
".otf": "font/otf",
|
|
21
|
+
".eot": "application/vnd.ms-fontobject",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".gif": "image/gif",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
".avif": "image/avif",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isExternal(url) {
|
|
32
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Replaces relative url(...) references in `css` with data: URIs read from
|
|
36
|
+
// `baseDir`. Returns { css, inlined, missing }.
|
|
37
|
+
function inlineCssAssets(css, baseDir) {
|
|
38
|
+
const inlined = [];
|
|
39
|
+
const missing = [];
|
|
40
|
+
|
|
41
|
+
const out = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (match, _quote, url) => {
|
|
42
|
+
const target = url.trim();
|
|
43
|
+
if (!target || isExternal(target)) return match;
|
|
44
|
+
|
|
45
|
+
// Strip any ?query or #fragment before resolving against the file system.
|
|
46
|
+
const clean = target.replace(/[?#].*$/, "");
|
|
47
|
+
const filePath = path.resolve(baseDir, clean);
|
|
48
|
+
|
|
49
|
+
// Refuse to read outside the theme directory.
|
|
50
|
+
const relative = path.relative(baseDir, filePath);
|
|
51
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
52
|
+
missing.push(target);
|
|
53
|
+
return match;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(filePath)) {
|
|
57
|
+
missing.push(target);
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mime = MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
62
|
+
const data = fs.readFileSync(filePath).toString("base64");
|
|
63
|
+
inlined.push(relative);
|
|
64
|
+
return `url("data:${mime};base64,${data}")`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { css: out, inlined, missing };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The design box and print page a theme uses when it declares none.
|
|
71
|
+
// 1280 x 720 CSS px is exactly 13.333 x 7.5 in at 96 dpi, so screen and PDF
|
|
72
|
+
// are the same geometry by construction.
|
|
73
|
+
const DEFAULT_THEME_CONFIG = {
|
|
74
|
+
slide: { width: 1280, height: 720 },
|
|
75
|
+
page: { width: 13.333, height: 7.5 },
|
|
76
|
+
// How the slide meets a window of a different shape: contain, cover or
|
|
77
|
+
// stretch. The CLI's --fit overrides whatever a theme declares.
|
|
78
|
+
fit: "contain",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function readThemeConfig(themeDir) {
|
|
82
|
+
const jsonPath = path.join(themeDir, "theme.json");
|
|
83
|
+
if (!fs.existsSync(jsonPath)) return { config: { ...DEFAULT_THEME_CONFIG }, warning: null };
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
|
86
|
+
return {
|
|
87
|
+
config: {
|
|
88
|
+
fit: DEFAULT_THEME_CONFIG.fit,
|
|
89
|
+
...parsed,
|
|
90
|
+
slide: { ...DEFAULT_THEME_CONFIG.slide, ...(parsed.slide || {}) },
|
|
91
|
+
page: { ...DEFAULT_THEME_CONFIG.page, ...(parsed.page || {}) },
|
|
92
|
+
},
|
|
93
|
+
warning: null,
|
|
94
|
+
};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
config: { ...DEFAULT_THEME_CONFIG },
|
|
98
|
+
warning: `theme.json could not be parsed (${err.message}); using the default design box`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Reads a theme directory. `fallbackDir` supplies theme.js when the theme
|
|
104
|
+
// ships none, which is how a theme opts into the default runtime (keyboard
|
|
105
|
+
// navigation, touch, fit-to-window scaling) without copying it.
|
|
106
|
+
function loadTheme(themeDir, fallbackDir) {
|
|
107
|
+
const resolved = path.resolve(themeDir);
|
|
108
|
+
const cssPath = path.join(resolved, "theme.css");
|
|
109
|
+
const jsPath = path.join(resolved, "theme.js");
|
|
110
|
+
|
|
111
|
+
const warnings = [];
|
|
112
|
+
let themeCss = "";
|
|
113
|
+
let themeJs = "";
|
|
114
|
+
|
|
115
|
+
const { config: themeConfig, warning: configWarning } = readThemeConfig(resolved);
|
|
116
|
+
if (configWarning) warnings.push(configWarning);
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(cssPath)) {
|
|
119
|
+
const raw = fs.readFileSync(cssPath, "utf-8");
|
|
120
|
+
const { css, missing } = inlineCssAssets(raw, resolved);
|
|
121
|
+
themeCss = css;
|
|
122
|
+
for (const ref of missing) {
|
|
123
|
+
warnings.push(`theme asset not found, left as a plain reference: ${ref}`);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
warnings.push(`theme.css not found at ${cssPath}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (fs.existsSync(jsPath)) {
|
|
130
|
+
themeJs = fs.readFileSync(jsPath, "utf-8");
|
|
131
|
+
} else if (fallbackDir) {
|
|
132
|
+
const fallbackJs = path.join(path.resolve(fallbackDir), "theme.js");
|
|
133
|
+
if (fs.existsSync(fallbackJs)) {
|
|
134
|
+
themeJs = fs.readFileSync(fallbackJs, "utf-8");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { themeCss, themeJs, themeConfig, warnings, dir: resolved };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { loadTheme, inlineCssAssets, readThemeConfig, DEFAULT_THEME_CONFIG };
|
package/src/zip.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SDOC — minimal ZIP writer.
|
|
2
|
+
//
|
|
3
|
+
// Enough of the format to build an OOXML package: stored or deflated entries,
|
|
4
|
+
// local headers, a central directory and an end-of-central-directory record.
|
|
5
|
+
// Node's zlib supplies the compression, so this stays dependency-free.
|
|
6
|
+
|
|
7
|
+
const zlib = require("zlib");
|
|
8
|
+
|
|
9
|
+
const CRC_TABLE = (() => {
|
|
10
|
+
const table = new Int32Array(256);
|
|
11
|
+
for (let i = 0; i < 256; i++) {
|
|
12
|
+
let c = i;
|
|
13
|
+
for (let k = 0; k < 8; k++) {
|
|
14
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
15
|
+
}
|
|
16
|
+
table[i] = c;
|
|
17
|
+
}
|
|
18
|
+
return table;
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
function crc32(buffer) {
|
|
22
|
+
let crc = -1;
|
|
23
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
24
|
+
crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buffer[i]) & 0xff];
|
|
25
|
+
}
|
|
26
|
+
return (crc ^ -1) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fixed timestamp so the same input produces the same archive byte for byte.
|
|
30
|
+
const DOS_TIME = 0; // 00:00:00
|
|
31
|
+
const DOS_DATE = (1980 - 1980) << 9 | (1 << 5) | 1; // 1980-01-01
|
|
32
|
+
|
|
33
|
+
// files: [{ name, data: Buffer|string, store?: boolean }]
|
|
34
|
+
function zip(files) {
|
|
35
|
+
const locals = [];
|
|
36
|
+
const central = [];
|
|
37
|
+
let offset = 0;
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const nameBuf = Buffer.from(file.name, "utf-8");
|
|
41
|
+
const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, "utf-8");
|
|
42
|
+
const crc = crc32(raw);
|
|
43
|
+
|
|
44
|
+
// Already-compressed payloads (PNG, JPEG, woff2) gain nothing from
|
|
45
|
+
// deflate and cost time, so they go in stored.
|
|
46
|
+
const store = file.store === true;
|
|
47
|
+
const body = store ? raw : zlib.deflateRawSync(raw, { level: 9 });
|
|
48
|
+
const method = store ? 0 : 8;
|
|
49
|
+
|
|
50
|
+
const localHeader = Buffer.alloc(30);
|
|
51
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
52
|
+
localHeader.writeUInt16LE(20, 4); // version needed
|
|
53
|
+
localHeader.writeUInt16LE(0, 6); // flags
|
|
54
|
+
localHeader.writeUInt16LE(method, 8);
|
|
55
|
+
localHeader.writeUInt16LE(DOS_TIME, 10);
|
|
56
|
+
localHeader.writeUInt16LE(DOS_DATE, 12);
|
|
57
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
58
|
+
localHeader.writeUInt32LE(body.length, 18);
|
|
59
|
+
localHeader.writeUInt32LE(raw.length, 22);
|
|
60
|
+
localHeader.writeUInt16LE(nameBuf.length, 26);
|
|
61
|
+
localHeader.writeUInt16LE(0, 28); // extra length
|
|
62
|
+
|
|
63
|
+
locals.push(localHeader, nameBuf, body);
|
|
64
|
+
|
|
65
|
+
const centralHeader = Buffer.alloc(46);
|
|
66
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
67
|
+
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
68
|
+
centralHeader.writeUInt16LE(20, 6); // version needed
|
|
69
|
+
centralHeader.writeUInt16LE(0, 8);
|
|
70
|
+
centralHeader.writeUInt16LE(method, 10);
|
|
71
|
+
centralHeader.writeUInt16LE(DOS_TIME, 12);
|
|
72
|
+
centralHeader.writeUInt16LE(DOS_DATE, 14);
|
|
73
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
74
|
+
centralHeader.writeUInt32LE(body.length, 20);
|
|
75
|
+
centralHeader.writeUInt32LE(raw.length, 24);
|
|
76
|
+
centralHeader.writeUInt16LE(nameBuf.length, 28);
|
|
77
|
+
centralHeader.writeUInt16LE(0, 30); // extra
|
|
78
|
+
centralHeader.writeUInt16LE(0, 32); // comment
|
|
79
|
+
centralHeader.writeUInt16LE(0, 34); // disk
|
|
80
|
+
centralHeader.writeUInt16LE(0, 36); // internal attrs
|
|
81
|
+
centralHeader.writeUInt32LE(0, 38); // external attrs
|
|
82
|
+
centralHeader.writeUInt32LE(offset, 42);
|
|
83
|
+
|
|
84
|
+
central.push(centralHeader, nameBuf);
|
|
85
|
+
offset += localHeader.length + nameBuf.length + body.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const centralBuf = Buffer.concat(central);
|
|
89
|
+
const end = Buffer.alloc(22);
|
|
90
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
91
|
+
end.writeUInt16LE(0, 4);
|
|
92
|
+
end.writeUInt16LE(0, 6);
|
|
93
|
+
end.writeUInt16LE(files.length, 8);
|
|
94
|
+
end.writeUInt16LE(files.length, 10);
|
|
95
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
96
|
+
end.writeUInt32LE(offset, 16);
|
|
97
|
+
end.writeUInt16LE(0, 20);
|
|
98
|
+
|
|
99
|
+
return Buffer.concat([...locals, centralBuf, end]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { zip, crc32 };
|