@gentbajko/slopify 2.0.2 → 2.1.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.
- package/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/protocol.js +11 -1
- package/dist/adapters/alignment/runner.js +4 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +66 -9
- package/dist/adapters/explain.js +93 -0
- package/dist/adapters/ffmpeg.js +3 -3
- package/dist/adapters/host-cli/index.js +34 -3
- package/dist/adapters/host-cli/transport.js +4 -4
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +12 -12
- package/dist/adapters/image/codex.js +24 -8
- package/dist/adapters/image/fal.js +12 -6
- package/dist/adapters/image/google.js +18 -8
- package/dist/adapters/image/models.js +8 -2
- package/dist/adapters/image/openai.js +15 -6
- package/dist/adapters/image/replicate.js +16 -7
- package/dist/adapters/llm/claude-code-models.js +1 -1
- package/dist/adapters/llm/claude-code.js +16 -7
- package/dist/adapters/llm/cli-login-error.js +2 -1
- package/dist/adapters/llm/codex-models.js +1 -1
- package/dist/adapters/llm/codex.js +5 -6
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-models.js +1 -1
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +15 -11
- package/dist/adapters/llm/openrouter.js +21 -9
- package/dist/adapters/llm/run-cli.js +14 -11
- package/dist/adapters/tts/cartesia.js +10 -3
- package/dist/adapters/tts/elevenlabs.js +18 -6
- package/dist/adapters/tts/inworld-async.js +47 -16
- package/dist/adapters/tts/inworld.js +25 -15
- package/dist/adapters/tts/openai.js +18 -6
- package/dist/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/registry.js +13 -10
- package/dist/catalog/store.js +1 -1
- package/dist/catalog/validate.js +16 -6
- package/dist/edge/cli.js +33 -7
- package/dist/edge/docker-launch.js +6 -6
- package/dist/edge/docker-projects/engine.js +34 -13
- package/dist/edge/docker-projects/install.js +8 -8
- package/dist/edge/docker-projects/state.js +11 -7
- package/dist/edge/docker-projects/tree.js +6 -6
- package/dist/edge/docker.js +13 -13
- package/dist/edge/host-cli.js +14 -0
- package/dist/edge/http/actions.js +27 -25
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/audio-preview.js +1 -1
- package/dist/edge/http/checkpoints.js +5 -1
- package/dist/edge/http/diagnostics.js +3 -1
- package/dist/edge/http/draft-files.js +2 -2
- package/dist/edge/http/draft-problem.js +11 -1
- package/dist/edge/http/files.js +4 -4
- package/dist/edge/http/folder-location-schema.js +5 -8
- package/dist/edge/http/folder-location.js +13 -8
- package/dist/edge/http/fonts.js +22 -10
- package/dist/edge/http/host-cli.js +61 -14
- package/dist/edge/http/multipart.js +7 -5
- package/dist/edge/http/open-folder.js +2 -2
- package/dist/edge/http/planning.js +4 -4
- package/dist/edge/http/problem.js +2 -2
- package/dist/edge/http/project-templates.js +7 -3
- package/dist/edge/http/projects.js +8 -8
- package/dist/edge/http/prompts.js +9 -4
- package/dist/edge/http/providers.js +15 -7
- package/dist/edge/http/revision-files.js +8 -4
- package/dist/edge/http/revisions.js +30 -4
- package/dist/edge/http/schedules.js +9 -9
- package/dist/edge/http/settings.js +15 -9
- package/dist/edge/http/staging.js +5 -5
- package/dist/edge/http/storage.js +4 -4
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/tutorial.js +2 -2
- package/dist/edge/http/update.js +3 -3
- package/dist/host-cli/install.js +28 -6
- package/dist/host-cli/open-folder.js +95 -0
- package/dist/host-cli/paths.js +6 -6
- package/dist/host-cli/runtime.js +2 -0
- package/dist/host-cli/server.js +1 -1
- package/dist/host-cli/service.js +11 -11
- package/dist/host-cli/status.js +7 -3
- package/dist/kernel/config/index.js +6 -4
- package/dist/kernel/db/migrate.js +27 -2
- package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/lock.js +4 -3
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/ports/subtitles.js +15 -1
- package/dist/kernel/runner/attempt.js +26 -2
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +8 -4
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +98 -22
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- package/dist/slices/article/continuation.js +4 -2
- package/dist/slices/article/plain.js +2 -2
- package/dist/slices/article/provided-entries.js +3 -3
- package/dist/slices/article/run.js +2 -2
- package/dist/slices/article/segments.js +5 -3
- package/dist/slices/control/providers.js +20 -7
- package/dist/slices/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- package/dist/slices/fonts/catalog.js +1 -1
- package/dist/slices/fonts/upload.js +1 -1
- package/dist/slices/images/run.js +5 -5
- package/dist/slices/library/lint.js +13 -7
- package/dist/slices/library/model.js +1 -1
- package/dist/slices/library/slots.js +6 -3
- package/dist/slices/narration/concat.js +1 -1
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/narration/run.js +7 -7
- package/dist/slices/narration/steering.js +3 -3
- package/dist/slices/play-drafts/convert.js +35 -5
- package/dist/slices/play-drafts/readiness.js +15 -8
- package/dist/slices/play-drafts/review-inputs.js +7 -4
- package/dist/slices/play-drafts/review.js +3 -3
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/play-drafts/service.js +3 -1
- package/dist/slices/play-drafts/uploads.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +6 -1
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-narration-text.js +1 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +11 -2
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +12 -7
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- package/dist/slices/rebuild/runtime-admission.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
- package/dist/slices/rebuild/runtime-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +7 -6
- package/dist/slices/rebuild/runtime-export.js +39 -23
- package/dist/slices/rebuild/runtime-local.js +6 -6
- package/dist/slices/rebuild/runtime-narration-text.js +46 -7
- package/dist/slices/rebuild/runtime-piece-label.js +66 -0
- package/dist/slices/rebuild/runtime-provider.js +6 -4
- package/dist/slices/rebuild/runtime-publication.js +4 -3
- package/dist/slices/rebuild/runtime-run.js +22 -4
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +82 -16
- package/dist/slices/rebuild/runtime-youtube.js +84 -0
- package/dist/slices/rebuild/transition-repo.js +8 -6
- package/dist/slices/reruns/cascade.js +6 -4
- package/dist/slices/reruns/index.js +1 -1
- package/dist/slices/research/run.js +9 -7
- package/dist/slices/research/synthesis.js +4 -2
- package/dist/slices/revisions/mutation-assets.js +26 -8
- package/dist/slices/revisions/mutation-cues.js +4 -2
- package/dist/slices/revisions/projection.js +2 -1
- package/dist/slices/revisions/rules.js +18 -9
- package/dist/slices/revisions/schema.js +1 -1
- package/dist/slices/schedules/repo.js +55 -11
- package/dist/slices/schedules/scheduler.js +25 -8
- package/dist/slices/schedules/schema.js +19 -4
- package/dist/slices/schedules/service.js +2 -0
- package/dist/slices/settings/cli-paths.js +7 -4
- package/dist/slices/settings/models.js +4 -4
- package/dist/slices/settings/playback.js +2 -2
- package/dist/slices/storage/assets.js +5 -5
- package/dist/slices/storage/downloads.js +1 -0
- package/dist/slices/storage/layout.js +7 -1
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/subtitles/captions.js +1 -1
- package/dist/slices/subtitles/prepare.js +7 -6
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/telemetry/model.js +1 -1
- package/dist/slices/thumbnail/run.js +8 -8
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +129 -61
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +62 -28
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +23 -12
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- package/dist/updater/plan.js +1 -1
- package/dist/updater/service.js +4 -3
- package/dist/updater/worker.js +6 -4
- package/dist/web/assets/index-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-ComF5gwP.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +0 -1
- package/dist/web/assets/index-DPgGLX5m.js +0 -137
|
@@ -11,7 +11,7 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
|
|
|
11
11
|
const { projectId } = audioContext.stage;
|
|
12
12
|
const project = projectById(deps.db, projectId);
|
|
13
13
|
if (!project)
|
|
14
|
-
throw new Error(
|
|
14
|
+
throw new Error("This project no longer exists; it may have been deleted while it was running.");
|
|
15
15
|
const { config } = project;
|
|
16
16
|
if (config.sources.article !== "provide" || config.sources.audio !== "generate")
|
|
17
17
|
return "done";
|
|
@@ -20,11 +20,11 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
|
|
|
20
20
|
audioContext.signal.throwIfAborted();
|
|
21
21
|
const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
|
|
22
22
|
if (!article)
|
|
23
|
-
throw new Error(
|
|
23
|
+
throw new Error("Slopify hit an internal error (the project has no article stage). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
24
24
|
const outputs = outputsOf(deps.db, projectId);
|
|
25
25
|
const plain = outputs.find((output) => output.role === "article_txt");
|
|
26
26
|
if (!plain)
|
|
27
|
-
throw new Error("
|
|
27
|
+
throw new Error("The article text you provided is missing, so the intro and outro can't be written from it. Paste the article again in Edit project → Article, then Retry stage.");
|
|
28
28
|
const narration = readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8");
|
|
29
29
|
const saved = outputs.find((output) => output.stageKind === "article" && output.role === "instructions");
|
|
30
30
|
let instructions = saved
|
|
@@ -13,14 +13,14 @@ export async function runArticle(deps, context, providers) {
|
|
|
13
13
|
const { projectId } = context.stage;
|
|
14
14
|
const project = projectById(deps.db, projectId);
|
|
15
15
|
if (project === undefined) {
|
|
16
|
-
throw new Error(
|
|
16
|
+
throw new Error("This project no longer exists; it may have been deleted while it was running.");
|
|
17
17
|
}
|
|
18
18
|
const choice = project.config.llm;
|
|
19
19
|
const articlePrompt = project.config.rendered.article;
|
|
20
20
|
if (choice === undefined || articlePrompt === undefined) {
|
|
21
21
|
// Admission refuses a run whose article is Generate without both, so
|
|
22
22
|
// reaching here is a bug in admission rather than something the user did.
|
|
23
|
-
throw new Error("
|
|
23
|
+
throw new Error("No AI model or article prompt is set for writing the article. Choose a model in Edit project → Providers and check Edit project → Prompts, then Retry stage.");
|
|
24
24
|
}
|
|
25
25
|
const notes = researchNotes(deps, projectId);
|
|
26
26
|
const brief = { articlePrompt, ...(notes === undefined ? {} : { notes }) };
|
|
@@ -7,12 +7,12 @@ export async function writeSegment(providers, choice, config, category, article,
|
|
|
7
7
|
return { ok: true, value: undefined };
|
|
8
8
|
const body = config.rendered[category];
|
|
9
9
|
if (body === undefined)
|
|
10
|
-
throw new Error(`
|
|
10
|
+
throw new Error(`Slopify hit an internal error (the ${category} text was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
|
|
11
11
|
const common = { category, name: picked.name, mode: picked.mode };
|
|
12
12
|
if (picked.mode === "text")
|
|
13
13
|
return { ok: true, value: { ...common, text: body, tokens: noTokens } };
|
|
14
14
|
if (choice === undefined)
|
|
15
|
-
throw new Error(`the ${category}
|
|
15
|
+
throw new Error(`No AI model is set for writing the ${category}. Choose one in Edit project → Providers, then Retry stage.`);
|
|
16
16
|
const messages = segmentMessages(body, config, article);
|
|
17
17
|
sent.push({ label: category === "intro" ? "Intro" : "Outro", messages });
|
|
18
18
|
const answer = await providers.llm({
|
|
@@ -20,7 +20,9 @@ export async function writeSegment(providers, choice, config, category, article,
|
|
|
20
20
|
model: choice.model,
|
|
21
21
|
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
22
22
|
messages,
|
|
23
|
-
check: (given) => given.text.trim() === ""
|
|
23
|
+
check: (given) => given.text.trim() === ""
|
|
24
|
+
? `The AI model returned an empty ${category}. Retry stage; if it keeps happening, choose a different model in Edit project → Providers.`
|
|
25
|
+
: undefined,
|
|
24
26
|
});
|
|
25
27
|
if (!answer.ok)
|
|
26
28
|
return answer;
|
|
@@ -25,7 +25,7 @@ export const providerChangesSchema = z
|
|
|
25
25
|
.optional(),
|
|
26
26
|
})
|
|
27
27
|
.strict()
|
|
28
|
-
.refine((value) => Object.keys(value).length > 0, "
|
|
28
|
+
.refine((value) => Object.keys(value).length > 0, "Change at least one provider before saving.");
|
|
29
29
|
export function validateLocalProviderChanges(changes, current) {
|
|
30
30
|
const fields = [];
|
|
31
31
|
const families = { llm: "llm", audio: "tts", images: "image" };
|
|
@@ -35,7 +35,10 @@ export function validateLocalProviderChanges(changes, current) {
|
|
|
35
35
|
continue;
|
|
36
36
|
const provider = current.providers.find((one) => one.id === picked.provider && one.family === families[key]);
|
|
37
37
|
if (provider === undefined) {
|
|
38
|
-
fields.push({
|
|
38
|
+
fields.push({
|
|
39
|
+
field: key,
|
|
40
|
+
message: "This provider cannot do this kind of work. Choose another provider.",
|
|
41
|
+
});
|
|
39
42
|
continue;
|
|
40
43
|
}
|
|
41
44
|
const usable = readinessIsUsable(provider.readiness);
|
|
@@ -43,14 +46,18 @@ export function validateLocalProviderChanges(changes, current) {
|
|
|
43
46
|
fields.push({
|
|
44
47
|
field: key,
|
|
45
48
|
message: provider.readiness.kind === "cli"
|
|
46
|
-
? (provider.readiness.issue ??
|
|
47
|
-
|
|
49
|
+
? (provider.readiness.issue ??
|
|
50
|
+
"Install this command-line tool on your computer and sign in to it, then try again.")
|
|
51
|
+
: "Add this provider's API key in Settings → Providers first.",
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
if (changes.audio !== undefined &&
|
|
52
56
|
!current.voices.some((voice) => voice.provider === changes.audio?.provider && voice.voiceId === changes.audio.voice)) {
|
|
53
|
-
fields.push({
|
|
57
|
+
fields.push({
|
|
58
|
+
field: "audio.voice",
|
|
59
|
+
message: "Choose one of your saved voices for this provider. Add voices in Settings → Voices.",
|
|
60
|
+
});
|
|
54
61
|
}
|
|
55
62
|
return fields;
|
|
56
63
|
}
|
|
@@ -67,10 +74,16 @@ export async function validateProviderChanges(changes, current) {
|
|
|
67
74
|
continue;
|
|
68
75
|
const result = await checkRuntimeModel(current.modelsFor, picked.provider, families[key], picked.model, picked.thinking);
|
|
69
76
|
if (result === "missing") {
|
|
70
|
-
fields.push({
|
|
77
|
+
fields.push({
|
|
78
|
+
field: `${key}.model`,
|
|
79
|
+
message: "This provider does not offer that model. Choose another model.",
|
|
80
|
+
});
|
|
71
81
|
}
|
|
72
82
|
else if (result === "thinking") {
|
|
73
|
-
fields.push({
|
|
83
|
+
fields.push({
|
|
84
|
+
field: `${key}.thinking`,
|
|
85
|
+
message: "This model does not support that thinking setting. Choose another.",
|
|
86
|
+
});
|
|
74
87
|
}
|
|
75
88
|
}
|
|
76
89
|
return fields;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
const plainStyle = { strong: false, emphasis: false, href: null };
|
|
4
|
+
export function markdownBlocks(markdown) {
|
|
5
|
+
const tree = remark().use(remarkGfm).parse(markdown);
|
|
6
|
+
return tree.children.flatMap((node) => blocksOf(node, 0));
|
|
7
|
+
}
|
|
8
|
+
export function runsText(runs) {
|
|
9
|
+
return runs.map((run) => run.text).join("");
|
|
10
|
+
}
|
|
11
|
+
// Every link in the markdown, bare URLs included (remark-gfm makes those links too).
|
|
12
|
+
export function markdownLinks(markdown) {
|
|
13
|
+
const links = [];
|
|
14
|
+
const visit = (node) => {
|
|
15
|
+
if (node.type === "link") {
|
|
16
|
+
const text = collapse(inlineRuns(node.children, plainStyle)
|
|
17
|
+
.map((run) => run.text)
|
|
18
|
+
.join(""));
|
|
19
|
+
links.push({ text: text === "" ? node.url : text, href: node.url });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if ("children" in node)
|
|
23
|
+
for (const child of node.children)
|
|
24
|
+
visit(child);
|
|
25
|
+
};
|
|
26
|
+
visit(remark().use(remarkGfm).parse(markdown));
|
|
27
|
+
return links;
|
|
28
|
+
}
|
|
29
|
+
function blocksOf(node, depth) {
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case "heading": {
|
|
32
|
+
const text = collapse(runsText(inlineRuns(node.children, plainStyle)));
|
|
33
|
+
return text === ""
|
|
34
|
+
? []
|
|
35
|
+
: [{ kind: "heading", level: node.depth === 1 ? 1 : node.depth === 2 ? 2 : 3, text }];
|
|
36
|
+
}
|
|
37
|
+
case "paragraph":
|
|
38
|
+
return paragraph(inlineRuns(node.children, plainStyle));
|
|
39
|
+
case "list":
|
|
40
|
+
return node.children.flatMap((item, index) => {
|
|
41
|
+
const marker = node.ordered ? `${(node.start ?? 1) + index}.` : "•";
|
|
42
|
+
let first = true;
|
|
43
|
+
return item.children.flatMap((child) => {
|
|
44
|
+
if (child.type === "list")
|
|
45
|
+
return blocksOf(child, depth + 1);
|
|
46
|
+
const runs = child.type === "paragraph"
|
|
47
|
+
? inlineRuns(child.children, plainStyle)
|
|
48
|
+
: blocksOf(child, depth).flatMap((block) => ("runs" in block ? block.runs : []));
|
|
49
|
+
if (tidy(runs).length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const block = {
|
|
52
|
+
kind: "item",
|
|
53
|
+
depth,
|
|
54
|
+
marker: first ? marker : "",
|
|
55
|
+
runs: tidy(runs),
|
|
56
|
+
};
|
|
57
|
+
first = false;
|
|
58
|
+
return [block];
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
case "blockquote":
|
|
62
|
+
return node.children.flatMap((child) => blocksOf(child, depth).flatMap((block) => "runs" in block ? [{ kind: "quote", runs: block.runs }] : [block]));
|
|
63
|
+
case "thematicBreak":
|
|
64
|
+
return [{ kind: "rule" }];
|
|
65
|
+
case "code":
|
|
66
|
+
return paragraph([{ ...plainStyle, text: node.value }]);
|
|
67
|
+
case "table":
|
|
68
|
+
return node.children.flatMap((row) => paragraph(row.children.flatMap((cell, index) => [
|
|
69
|
+
...(index === 0 ? [] : [{ ...plainStyle, text: " | " }]),
|
|
70
|
+
...inlineRuns(cell.children, plainStyle),
|
|
71
|
+
])));
|
|
72
|
+
// Raw HTML, footnote definitions, front matter and images have no printed form here.
|
|
73
|
+
default:
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function paragraph(runs) {
|
|
78
|
+
const tidied = tidy(runs);
|
|
79
|
+
return tidied.length === 0 ? [] : [{ kind: "paragraph", runs: tidied }];
|
|
80
|
+
}
|
|
81
|
+
function inlineRuns(nodes, style) {
|
|
82
|
+
return nodes.flatMap((node) => {
|
|
83
|
+
switch (node.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
case "inlineCode":
|
|
86
|
+
return [{ ...style, text: node.value }];
|
|
87
|
+
case "strong":
|
|
88
|
+
return inlineRuns(node.children, { ...style, strong: true });
|
|
89
|
+
case "emphasis":
|
|
90
|
+
return inlineRuns(node.children, { ...style, emphasis: true });
|
|
91
|
+
case "delete":
|
|
92
|
+
return inlineRuns(node.children, style);
|
|
93
|
+
case "link":
|
|
94
|
+
return inlineRuns(node.children, { ...style, href: node.url });
|
|
95
|
+
case "break":
|
|
96
|
+
return [{ ...style, text: "\n" }];
|
|
97
|
+
case "footnoteReference":
|
|
98
|
+
return [{ ...style, text: `[${node.label ?? node.identifier}]` }];
|
|
99
|
+
case "image":
|
|
100
|
+
return node.alt ? [{ ...style, text: node.alt }] : [];
|
|
101
|
+
default:
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Soft line breaks and runs of spaces become one space; a hard break stays a line break.
|
|
107
|
+
function tidy(runs) {
|
|
108
|
+
const tidied = runs
|
|
109
|
+
.map((run) => (run.text === "\n" ? run : { ...run, text: run.text.replace(/\s+/g, " ") }))
|
|
110
|
+
.filter((run) => run.text !== "");
|
|
111
|
+
const first = tidied[0];
|
|
112
|
+
const last = tidied.at(-1);
|
|
113
|
+
if (first === undefined || last === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
const trimmed = tidied.map((run, index) => ({
|
|
116
|
+
...run,
|
|
117
|
+
text: index === 0 && index === tidied.length - 1
|
|
118
|
+
? run.text.trim()
|
|
119
|
+
: index === 0
|
|
120
|
+
? run.text.trimStart()
|
|
121
|
+
: index === tidied.length - 1
|
|
122
|
+
? run.text.trimEnd()
|
|
123
|
+
: run.text,
|
|
124
|
+
}));
|
|
125
|
+
return trimmed.filter((run) => run.text !== "");
|
|
126
|
+
}
|
|
127
|
+
function collapse(text) {
|
|
128
|
+
return text.replace(/\s+/g, " ").trim();
|
|
129
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { faceWidth, useFace } from "./fonts.js";
|
|
2
|
+
// `room(index)` answers where line `index` starts, relative to the paragraph's left edge,
|
|
3
|
+
// and how wide it may be: a drop cap narrows the first few.
|
|
4
|
+
export function wrapRuns(doc, runs, style, room) {
|
|
5
|
+
const spaceWidth = faceWidth(doc, style.body, style.size, " ");
|
|
6
|
+
const lines = [];
|
|
7
|
+
let line = [];
|
|
8
|
+
let x = 0;
|
|
9
|
+
const limit = () => room(lines.length);
|
|
10
|
+
const finish = () => {
|
|
11
|
+
lines.push(line);
|
|
12
|
+
line = [];
|
|
13
|
+
x = 0;
|
|
14
|
+
};
|
|
15
|
+
const place = (atom) => {
|
|
16
|
+
const { indent } = limit();
|
|
17
|
+
line.push({
|
|
18
|
+
text: atom.text,
|
|
19
|
+
face: faceOf(atom.run, style),
|
|
20
|
+
color: atom.run.href === null ? style.color : style.linkColor,
|
|
21
|
+
href: atom.run.href,
|
|
22
|
+
x: indent + x,
|
|
23
|
+
width: atom.width,
|
|
24
|
+
});
|
|
25
|
+
x += atom.width;
|
|
26
|
+
};
|
|
27
|
+
for (const token of tokens(doc, runs, style)) {
|
|
28
|
+
if (token.kind === "break") {
|
|
29
|
+
finish();
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const width = token.atoms.reduce((sum, atom) => sum + atom.width, 0);
|
|
33
|
+
const gap = line.length > 0 && token.space ? spaceWidth : 0;
|
|
34
|
+
if (line.length > 0 && x + gap + width > limit().width)
|
|
35
|
+
finish();
|
|
36
|
+
else
|
|
37
|
+
x += gap;
|
|
38
|
+
if (width <= limit().width) {
|
|
39
|
+
for (const atom of token.atoms)
|
|
40
|
+
place(atom);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const atom of token.atoms)
|
|
44
|
+
for (const character of [...atom.text]) {
|
|
45
|
+
const piece = { ...atom, text: character, width: measure(doc, atom.run, style, character) };
|
|
46
|
+
if (line.length > 0 && x + piece.width > limit().width)
|
|
47
|
+
finish();
|
|
48
|
+
place(piece);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (line.length > 0)
|
|
52
|
+
finish();
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
export function drawLine(doc, line, left, y, size) {
|
|
56
|
+
for (const piece of line) {
|
|
57
|
+
useFace(doc, piece.face, size);
|
|
58
|
+
doc.setTextColor(piece.color.r, piece.color.g, piece.color.b);
|
|
59
|
+
doc.text(piece.text, left + piece.x, y);
|
|
60
|
+
}
|
|
61
|
+
// One clickable box per run of pieces sharing a link, rather than one per word.
|
|
62
|
+
let start;
|
|
63
|
+
let end;
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (start?.href != null && end !== undefined)
|
|
66
|
+
doc.link(left + start.x, y - size * 0.3528, end.x + end.width - start.x, size * 0.45, {
|
|
67
|
+
url: start.href,
|
|
68
|
+
});
|
|
69
|
+
start = undefined;
|
|
70
|
+
end = undefined;
|
|
71
|
+
};
|
|
72
|
+
for (const piece of line) {
|
|
73
|
+
if (piece.href !== start?.href)
|
|
74
|
+
flush();
|
|
75
|
+
if (piece.href !== null) {
|
|
76
|
+
start ??= piece;
|
|
77
|
+
end = piece;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
flush();
|
|
81
|
+
doc.setCharSpace(0);
|
|
82
|
+
}
|
|
83
|
+
function tokens(doc, runs, style) {
|
|
84
|
+
const result = [];
|
|
85
|
+
let space = false;
|
|
86
|
+
for (const run of runs) {
|
|
87
|
+
for (const part of run.text.split(/( +|\n)/)) {
|
|
88
|
+
if (part === "")
|
|
89
|
+
continue;
|
|
90
|
+
if (part === "\n") {
|
|
91
|
+
result.push({ kind: "break" });
|
|
92
|
+
space = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (/^ +$/.test(part)) {
|
|
96
|
+
space = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const atom = { text: part, run, width: measure(doc, run, style, part) };
|
|
100
|
+
const last = result.at(-1);
|
|
101
|
+
// No space before this part: it continues the previous word, as the comma after a
|
|
102
|
+
// bold word does, and must not be wrapped away from it.
|
|
103
|
+
if (!space && last?.kind === "word")
|
|
104
|
+
result[result.length - 1] = { ...last, atoms: [...last.atoms, atom] };
|
|
105
|
+
else
|
|
106
|
+
result.push({ kind: "word", atoms: [atom], space });
|
|
107
|
+
space = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
function measure(doc, run, style, text) {
|
|
113
|
+
return faceWidth(doc, faceOf(run, style), style.size, text);
|
|
114
|
+
}
|
|
115
|
+
function faceOf(run, style) {
|
|
116
|
+
return run.strong ? style.strong : run.emphasis ? style.emphasis : style.body;
|
|
117
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// The bundled Cinzel weights (SIL OFL 1.1, assets/document/fonts/OFL.txt) and the page
|
|
3
|
+
// texture. Read on every render rather than kept in memory: four fonts and one image are
|
|
4
|
+
// under a megabyte, and a document is made once per article.
|
|
5
|
+
const assets = new URL("../../assets/document/", import.meta.url);
|
|
6
|
+
const cinzelFiles = {
|
|
7
|
+
normal: "Cinzel-Regular.ttf",
|
|
8
|
+
medium: "Cinzel-Medium.ttf",
|
|
9
|
+
bold: "Cinzel-Bold.ttf",
|
|
10
|
+
black: "Cinzel-Black.ttf",
|
|
11
|
+
};
|
|
12
|
+
export function readDocumentAssets() {
|
|
13
|
+
try {
|
|
14
|
+
const cinzel = Object.fromEntries(Object.entries(cinzelFiles).map(([style, file]) => [
|
|
15
|
+
style,
|
|
16
|
+
readFileSync(new URL(`fonts/${file}`, assets)).toString("base64"),
|
|
17
|
+
]));
|
|
18
|
+
return { cinzel, parchment: new Uint8Array(readFileSync(new URL("background.jpg", assets))) };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new Error(`Slopify couldn't read the fonts and page texture it makes documents with (${error instanceof Error ? error.message : String(error)}). Its installation is incomplete: reinstall or update Slopify, then use Retry stage on Document.`, { cause: error });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function registerFonts(doc, fonts) {
|
|
25
|
+
for (const [style, file] of Object.entries(cinzelFiles)) {
|
|
26
|
+
doc.addFileToVFS(file, fonts[style]);
|
|
27
|
+
doc.addFont(file, "Cinzel", style);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Cinzel exists only in the four bundled weights; the standard fonts only in normal, bold,
|
|
31
|
+
// italic and bold italic. A face asking for anything else gets the nearest one.
|
|
32
|
+
export function useFace(doc, face, size) {
|
|
33
|
+
doc.setFont(face.family, styleFor(face));
|
|
34
|
+
doc.setFontSize(size);
|
|
35
|
+
doc.setCharSpace(face.letterSpacing);
|
|
36
|
+
}
|
|
37
|
+
export function faceWidth(doc, face, size, text) {
|
|
38
|
+
useFace(doc, face, size);
|
|
39
|
+
return doc.getTextWidth(text) + face.letterSpacing * [...text].length;
|
|
40
|
+
}
|
|
41
|
+
function styleFor(face) {
|
|
42
|
+
if (face.family === "Cinzel") {
|
|
43
|
+
if (face.style === "italic")
|
|
44
|
+
return "normal";
|
|
45
|
+
if (face.style === "bolditalic")
|
|
46
|
+
return "bold";
|
|
47
|
+
return face.style;
|
|
48
|
+
}
|
|
49
|
+
if (face.style === "medium")
|
|
50
|
+
return "normal";
|
|
51
|
+
if (face.style === "black")
|
|
52
|
+
return "bold";
|
|
53
|
+
return face.style;
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The built-in looks a project's PDF can take. Browser-safe: Play, Edit project and the
|
|
2
|
+
// server all offer and accept the same names from here.
|
|
3
|
+
export const documentThemes = ["dicemaster", "plain"];
|
|
4
|
+
export const defaultDocumentTheme = "dicemaster";
|
|
5
|
+
export const documentThemeLabels = {
|
|
6
|
+
dicemaster: "DiceMaster",
|
|
7
|
+
plain: "Plain",
|
|
8
|
+
};
|
|
9
|
+
export function documentThemeOf(settings) {
|
|
10
|
+
return settings?.theme ?? defaultDocumentTheme;
|
|
11
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useFace } from "./fonts.js";
|
|
2
|
+
const dateFormat = new Intl.DateTimeFormat("en-US", {
|
|
3
|
+
year: "numeric",
|
|
4
|
+
month: "long",
|
|
5
|
+
day: "numeric",
|
|
6
|
+
});
|
|
7
|
+
export function writtenOn(date) {
|
|
8
|
+
return dateFormat.format(date);
|
|
9
|
+
}
|
|
10
|
+
// Brand, tagline, title, the cover when there is one, then the date, word count and link.
|
|
11
|
+
export function titlePage(w, title, words, date, cover) {
|
|
12
|
+
const { theme, doc } = w;
|
|
13
|
+
const { fonts, sizes, titlePage: layout, brand } = theme;
|
|
14
|
+
const centre = w.width / 2;
|
|
15
|
+
if (brand.name !== null) {
|
|
16
|
+
w.write(brand.name, centre, layout.brandY, fonts.dramatic, sizes.brand, "heading", "center");
|
|
17
|
+
if (brand.url !== null) {
|
|
18
|
+
const width = doc.getTextWidth(brand.name);
|
|
19
|
+
doc.link(centre - width / 2, layout.brandY - 8, width, 10, { url: brand.url });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (brand.tagline !== null)
|
|
23
|
+
w.write(brand.tagline, centre, layout.brandY + layout.taglineOffset, fonts.decorative, sizes.body, "heading", "center");
|
|
24
|
+
useFace(doc, fonts.heading, sizes.title);
|
|
25
|
+
let y = layout.titleY;
|
|
26
|
+
for (const line of doc.splitTextToSize(title, w.contentWidth)) {
|
|
27
|
+
w.write(line, centre, y, fonts.heading, sizes.title, "text", "center");
|
|
28
|
+
y += layout.titleLine;
|
|
29
|
+
}
|
|
30
|
+
// Without a cover the details sit where lore2script2 put them; with one, just under it.
|
|
31
|
+
let meta = y + layout.metaOffset;
|
|
32
|
+
if (cover !== null && layout.cover.enabled) {
|
|
33
|
+
const top = y - layout.titleLine + layout.cover.gap;
|
|
34
|
+
const below = layout.cover.gap + layout.metaLine * 3;
|
|
35
|
+
const room = Math.min(layout.cover.maxHeight, w.bottom - below - top);
|
|
36
|
+
const { width, height } = doc.getImageProperties(cover.data);
|
|
37
|
+
if (room > 0 && width > 0 && height > 0) {
|
|
38
|
+
const scale = Math.min(w.contentWidth / width, room / height);
|
|
39
|
+
doc.addImage(cover.data, cover.format, centre - (width * scale) / 2, top, width * scale, height * scale);
|
|
40
|
+
meta = top + height * scale + layout.cover.gap + layout.metaLine;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const line = (text, color, decorative = false) => {
|
|
44
|
+
w.write(text, centre, meta, decorative ? fonts.decorative : fonts.body, sizes.meta, color, "center");
|
|
45
|
+
meta += layout.metaLine;
|
|
46
|
+
};
|
|
47
|
+
if (layout.showDate)
|
|
48
|
+
line(`Written on ${writtenOn(date)}`, "muted");
|
|
49
|
+
if (layout.showWordCount)
|
|
50
|
+
line(`Word Count: ${words.toLocaleString("en-US")}`, "muted");
|
|
51
|
+
if (brand.linkLabel !== null) {
|
|
52
|
+
const at = meta;
|
|
53
|
+
line(brand.linkLabel, "heading", true);
|
|
54
|
+
if (brand.url !== null) {
|
|
55
|
+
const width = doc.getTextWidth(brand.linkLabel);
|
|
56
|
+
doc.link(centre - width / 2, at - 8, width, 10, { url: brand.url });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// How many pages the contents need for `entries` one-line entries, laid out exactly as
|
|
61
|
+
// fillContents lays them out, so they can be kept free before the body is written.
|
|
62
|
+
export function contentsPages(w, entries) {
|
|
63
|
+
const { contents } = w.theme;
|
|
64
|
+
if (!contents.enabled || entries === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
let pages = 1;
|
|
67
|
+
let y = w.left + contents.firstEntryOffset;
|
|
68
|
+
for (let entry = 0; entry < entries; entry += 1) {
|
|
69
|
+
if (y + contents.line > w.bottom) {
|
|
70
|
+
pages += 1;
|
|
71
|
+
y = w.theme.header.top + contents.titleOffset;
|
|
72
|
+
}
|
|
73
|
+
y += contents.line;
|
|
74
|
+
}
|
|
75
|
+
return pages;
|
|
76
|
+
}
|
|
77
|
+
export function fillContents(w, first, entries) {
|
|
78
|
+
const { doc, theme } = w;
|
|
79
|
+
const { contents, fonts, sizes } = theme;
|
|
80
|
+
if (!contents.enabled || entries.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
let page = first;
|
|
83
|
+
doc.setPage(page);
|
|
84
|
+
w.write(contents.title, w.width / 2, w.left + contents.titleOffset, fonts.heading, sizes.section, "heading", "center");
|
|
85
|
+
const top = Math.min(...entries.map((entry) => entry.level));
|
|
86
|
+
let y = w.left + contents.firstEntryOffset;
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (y + contents.line > w.bottom) {
|
|
89
|
+
page += 1;
|
|
90
|
+
doc.setPage(page);
|
|
91
|
+
y = theme.header.top + contents.titleOffset;
|
|
92
|
+
}
|
|
93
|
+
const indent = (entry.level - top) * contents.indent;
|
|
94
|
+
const number = String(entry.page);
|
|
95
|
+
useFace(doc, fonts.body, sizes.body);
|
|
96
|
+
const numberX = w.width - w.left - doc.getTextWidth(number);
|
|
97
|
+
const room = numberX - (w.left + indent) - 10;
|
|
98
|
+
let label = entry.title;
|
|
99
|
+
if (doc.getTextWidth(label) > room) {
|
|
100
|
+
while (label.length > 1 && doc.getTextWidth(`${label}...`) > room)
|
|
101
|
+
label = label.slice(0, -1);
|
|
102
|
+
label = `${label.trimEnd()}...`;
|
|
103
|
+
}
|
|
104
|
+
w.write(label, w.left + indent, y, fonts.body, sizes.body, "text");
|
|
105
|
+
const labelEnd = w.left + indent + doc.getTextWidth(label) + 3;
|
|
106
|
+
w.write(number, numberX, y, fonts.body, sizes.body, "text");
|
|
107
|
+
doc.link(w.left, y - 6, w.contentWidth, 8, { pageNumber: entry.page });
|
|
108
|
+
const dot = doc.getTextWidth(".") * 1.5;
|
|
109
|
+
for (let x = labelEnd; x + dot < numberX - 3; x += dot)
|
|
110
|
+
w.write(".", x, y, fonts.body, sizes.body, "muted");
|
|
111
|
+
y += contents.line;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function sourcesPage(w, items) {
|
|
115
|
+
const { doc, theme } = w;
|
|
116
|
+
const { fonts, sizes, sources } = theme;
|
|
117
|
+
if (!sources.enabled || items.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
w.addPage();
|
|
120
|
+
w.write(sources.title, w.left, w.left + sources.titleOffset, fonts.heading, sizes.section, "heading");
|
|
121
|
+
const entry = { title: sources.title, level: 1, page: w.page() };
|
|
122
|
+
w.y = w.left + sources.bodyOffset;
|
|
123
|
+
useFace(doc, fonts.body, sizes.body);
|
|
124
|
+
const bullet = doc.getTextWidth("• ");
|
|
125
|
+
for (const item of items) {
|
|
126
|
+
useFace(doc, fonts.body, sizes.body);
|
|
127
|
+
const lines = doc.splitTextToSize(item.text, w.contentWidth - bullet - 5);
|
|
128
|
+
w.ensure(sources.line);
|
|
129
|
+
w.write("•", w.left, w.y, fonts.body, sizes.body, "muted");
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
w.ensure(sources.line);
|
|
132
|
+
w.write(line, w.left + bullet, w.y, fonts.body, sizes.body, item.href === null ? "text" : "heading");
|
|
133
|
+
if (item.href !== null)
|
|
134
|
+
doc.link(w.left + bullet, w.y - 5, doc.getTextWidth(line), 7, { url: item.href });
|
|
135
|
+
w.y += sources.line;
|
|
136
|
+
}
|
|
137
|
+
w.y += sources.gap;
|
|
138
|
+
}
|
|
139
|
+
return entry;
|
|
140
|
+
}
|
|
141
|
+
// The closing page: the theme's lines, then the document's details and the call to action.
|
|
142
|
+
export function endPage(w, words, date) {
|
|
143
|
+
const { doc, theme } = w;
|
|
144
|
+
const { fonts, sizes, endPage: end } = theme;
|
|
145
|
+
if (!end.enabled)
|
|
146
|
+
return null;
|
|
147
|
+
w.addPage();
|
|
148
|
+
w.write(end.title, w.left, w.left + end.titleOffset, fonts.heading, sizes.section, "heading");
|
|
149
|
+
const entry = { title: end.title, level: 1, page: w.page() };
|
|
150
|
+
const lines = [...end.lines];
|
|
151
|
+
if (end.showDocumentDetails) {
|
|
152
|
+
if (lines.length > 0)
|
|
153
|
+
lines.push("");
|
|
154
|
+
lines.push("Document Details:", `• Written On: ${writtenOn(date)}`, `• Total Words: ${words.toLocaleString("en-US")}`, `• Total Pages: ${doc.getNumberOfPages()}`);
|
|
155
|
+
}
|
|
156
|
+
const line = theme.spacing.bodyLine;
|
|
157
|
+
let y = w.left + end.bodyOffset;
|
|
158
|
+
for (const text of lines) {
|
|
159
|
+
if (text.startsWith("•"))
|
|
160
|
+
w.write(text, w.left + 5, y, fonts.body, sizes.body, "muted");
|
|
161
|
+
else if (text.endsWith(":"))
|
|
162
|
+
w.write(text, w.left, y, fonts.strong, sizes.body, "text");
|
|
163
|
+
else if (text !== "")
|
|
164
|
+
w.write(text, w.left, y, fonts.body, sizes.body, "text");
|
|
165
|
+
y += line;
|
|
166
|
+
}
|
|
167
|
+
const calls = [end.link?.text, end.closing].filter((text) => Boolean(text));
|
|
168
|
+
if (calls.length > 0)
|
|
169
|
+
y += line;
|
|
170
|
+
for (const text of calls) {
|
|
171
|
+
w.write(text, w.left, y, fonts.decorative, sizes.body, "heading");
|
|
172
|
+
if (text === end.link?.text && end.link.url !== null)
|
|
173
|
+
doc.link(w.left, y - 6, doc.getTextWidth(text), 8, { url: end.link.url });
|
|
174
|
+
y += line;
|
|
175
|
+
}
|
|
176
|
+
return entry;
|
|
177
|
+
}
|
|
178
|
+
// The running header ("Brand | Title") and page number on every page after the title page.
|
|
179
|
+
export function headsAndFeet(w, title) {
|
|
180
|
+
const { doc, theme } = w;
|
|
181
|
+
const { fonts, sizes, header, footer, brand } = theme;
|
|
182
|
+
for (let page = 2; page <= doc.getNumberOfPages(); page += 1) {
|
|
183
|
+
doc.setPage(page);
|
|
184
|
+
if (header.enabled) {
|
|
185
|
+
useFace(doc, fonts.body, sizes.footer);
|
|
186
|
+
const shown = doc.getTextWidth(title) > w.contentWidth - 40
|
|
187
|
+
? `${title.slice(0, header.maxTitleCharacters)}...`
|
|
188
|
+
: title;
|
|
189
|
+
const text = [brand.name, shown].filter(Boolean).join(" | ");
|
|
190
|
+
if (text !== "")
|
|
191
|
+
w.write(text, w.left, header.top, fonts.body, sizes.footer, "faint");
|
|
192
|
+
if (brand.name !== null && brand.url !== null)
|
|
193
|
+
doc.link(w.left, header.top - 6, doc.getTextWidth(brand.name), 8, { url: brand.url });
|
|
194
|
+
}
|
|
195
|
+
if (footer.enabled)
|
|
196
|
+
w.write(footer.text.replaceAll("{page}", String(page)), w.width / 2, w.height - footer.bottom, fonts.footer, sizes.footer, "faint", "center");
|
|
197
|
+
}
|
|
198
|
+
}
|