gutterpress 0.10.0-alpha.4 → 0.10.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/README.md +2 -0
- package/dist/api/index.js +2 -2
- package/dist/{audit-k1vnfwvc.js → audit-cvarpa72.js} +4 -4
- package/dist/{build-hqmwgvdw.js → build-6r502chw.js} +6 -4
- package/dist/{cli-0r0tq16s.js → cli-149edp6b.js} +1 -1
- package/dist/{cli-ra0ed2xt.js → cli-cqtggsng.js} +1 -1
- package/dist/{cli-n25qycwz.js → cli-eq5naw4m.js} +222 -141
- package/dist/{cli-revgt4pr.js → cli-yzf38679.js} +1 -0
- package/dist/{cli-hp9r2pzt.js → cli-zfcryxg8.js} +327 -24
- package/dist/cli.js +16 -16
- package/dist/{doctor-dxms7ehm.js → doctor-akvxbtjb.js} +2 -2
- package/dist/engine/compiler/build.d.ts +1 -1
- package/dist/engine/shared/flush.d.ts +59 -0
- package/dist/engine/shared/margin-box-support.d.ts +24 -0
- package/dist/{engine-z4p9sr4h.js → engine-b159tbns.js} +2 -2
- package/dist/{engine-wa7y9av9.js → engine-ft4cr3ep.js} +1 -1
- package/dist/{gutterpress-agent-1ctgfz92.js → gutterpress-agent-cazqstr1.js} +49 -1
- package/dist/{gutterpress-viewer-cem7dmr5.js → gutterpress-viewer-te8g5grx.js} +191 -71
- package/dist/{index-05y3dnxq.js → index-ge7q9xj3.js} +330 -249
- package/dist/{index-xxg4zfrg.js → index-wq3r5pj7.js} +287 -23
- package/dist/{index-9tyq9kks.js → index-ycpvr0am.js} +47 -4
- package/dist/index.js +3 -3
- package/dist/lib/build-runner.d.ts +7 -0
- package/dist/lib/build-staging.d.ts +51 -0
- package/dist/lib/engine.d.ts +9 -0
- package/dist/lib/markdown/gutterpress-css.d.ts +9 -1
- package/dist/lib/markdown/markers.d.ts +51 -1
- package/dist/{lint-xjwm5ep8.js → lint-p2sw53d9.js} +4 -4
- package/dist/{new-kwdwpf0j.js → new-hvq0x91q.js} +4 -4
- package/dist/{plugin-rg4tnn96.js → plugin-pssmk0dx.js} +4 -4
- package/dist/{preflight-3127y25z.js → preflight-4j00yd0g.js} +4 -4
- package/dist/{preview-ncgfhqmw.js → preview-d4s6gk6p.js} +6 -5
- package/dist/{preview-interface-435cczt5.js → preview-interface-0ssk8bmm.js} +153 -6
- package/dist/{publish-pr0rwh6p.js → publish-tkc26en7.js} +4 -4
- package/dist/render.js +83 -7
- package/dist/{repair-8270smfw.js → repair-2va4w12t.js} +4 -4
- package/dist/{validate-54e17rae.js → validate-w9vfefrw.js} +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,6 +190,7 @@ gutterpress preview [input-dir] [options]
|
|
|
190
190
|
--skip-lint Skip CSS linting (pdf|pdfx only)
|
|
191
191
|
--skip-pre-validate Skip pre-build validation (pdf|pdfx only)
|
|
192
192
|
--skip-post-validate Skip post-build PDF/X validation (pdfx only)
|
|
193
|
+
--allow-shrink Build anyway when content is wider than the page content box (pdf|pdfx only)
|
|
193
194
|
--engine <name> native (default) | paged (deprecated) [overrides the manifest's engine: field; applies to the live preview AND --format pdf|pdfx]
|
|
194
195
|
```
|
|
195
196
|
|
|
@@ -210,6 +211,7 @@ gutterpress build [input-dir] [options]
|
|
|
210
211
|
--skip-lint Skip the CSS print-safety pass (default: lint runs for pdf/pdfx)
|
|
211
212
|
--skip-pre-validate Skip pre-build validation
|
|
212
213
|
--skip-post-validate Skip post-build PDF/X validation
|
|
214
|
+
--allow-shrink Build anyway when content is wider than the page content box. Chromium then scales the WHOLE book down to fit it — the build reports that whole-document scale (e.g. "about 0.72x its declared size") plus every offender, as warnings.
|
|
213
215
|
--engine <name> native (default) | paged (deprecated) [overrides the manifest's engine: field; native = the Gutterpress engine, native Chromium pagination]
|
|
214
216
|
```
|
|
215
217
|
|
package/dist/api/index.js
CHANGED
|
@@ -122,12 +122,12 @@ import {
|
|
|
122
122
|
validateProjectPlugins,
|
|
123
123
|
verifyRepoReadable,
|
|
124
124
|
writeAppHeartbeat
|
|
125
|
-
} from "../index-
|
|
125
|
+
} from "../index-ge7q9xj3.js";
|
|
126
126
|
import {
|
|
127
127
|
BuildError,
|
|
128
128
|
DEFAULT_PRINT_OPTS,
|
|
129
129
|
readyProbeExpr
|
|
130
|
-
} from "../index-
|
|
130
|
+
} from "../index-ycpvr0am.js";
|
|
131
131
|
import {
|
|
132
132
|
AUTO_SNAPSHOT_MESSAGE,
|
|
133
133
|
HISTORY_PAGE_LIMIT,
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
executeAndReport
|
|
3
|
-
} from "./cli-
|
|
3
|
+
} from "./cli-eq5naw4m.js";
|
|
4
4
|
import {
|
|
5
5
|
log
|
|
6
|
-
} from "./cli-
|
|
6
|
+
} from "./cli-cqtggsng.js";
|
|
7
7
|
import {
|
|
8
8
|
UsageError,
|
|
9
9
|
rejectExtraPositionals,
|
|
10
10
|
rejectUnknownFlags
|
|
11
|
-
} from "./cli-
|
|
12
|
-
import"./cli-
|
|
11
|
+
} from "./cli-149edp6b.js";
|
|
12
|
+
import"./cli-zfcryxg8.js";
|
|
13
13
|
import"./cli-c41yr7he.js";
|
|
14
14
|
import {
|
|
15
15
|
EXIT_CODES
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
runBuild,
|
|
3
3
|
splitOutPath
|
|
4
|
-
} from "./cli-
|
|
4
|
+
} from "./cli-eq5naw4m.js";
|
|
5
5
|
import {
|
|
6
6
|
log
|
|
7
|
-
} from "./cli-
|
|
7
|
+
} from "./cli-cqtggsng.js";
|
|
8
8
|
import {
|
|
9
9
|
UsageError,
|
|
10
10
|
parseEngine,
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
parsePdfxFlavor,
|
|
13
13
|
rejectExtraPositionals,
|
|
14
14
|
rejectUnknownFlags
|
|
15
|
-
} from "./cli-
|
|
16
|
-
import"./cli-
|
|
15
|
+
} from "./cli-149edp6b.js";
|
|
16
|
+
import"./cli-zfcryxg8.js";
|
|
17
17
|
import"./cli-c41yr7he.js";
|
|
18
18
|
import {
|
|
19
19
|
BuildError
|
|
@@ -37,6 +37,7 @@ var commandArgs = {
|
|
|
37
37
|
"skip-lint": { type: "boolean", description: "Skip CSS linting (default: lint runs for pdf/pdfx)" },
|
|
38
38
|
"skip-pre-validate": { type: "boolean", description: "Skip pre-build validation" },
|
|
39
39
|
"skip-post-validate": { type: "boolean", description: "Skip post-build PDF/X validation" },
|
|
40
|
+
"allow-shrink": { type: "boolean", description: "Build anyway when content is wider than the page content box, instead of failing. Chromium then scales the WHOLE book down to fit; each offender is reported as a warning." },
|
|
40
41
|
engine: { type: "string", description: "Pagination engine. Paged.js has been removed; native is the only engine. This flag is accepted but ignored (a warning fires for --engine paged)." }
|
|
41
42
|
};
|
|
42
43
|
var build_default = defineCommand({
|
|
@@ -66,6 +67,7 @@ var build_default = defineCommand({
|
|
|
66
67
|
skipLint: !!args["skip-lint"],
|
|
67
68
|
skipPreValidate: !!args["skip-pre-validate"],
|
|
68
69
|
skipPostValidate: !!args["skip-post-validate"],
|
|
70
|
+
allowShrink: !!args["allow-shrink"],
|
|
69
71
|
engine: parseEngine(args.engine),
|
|
70
72
|
rawArgs: args
|
|
71
73
|
});
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
// package.json
|
|
5
5
|
var package_default = {
|
|
6
6
|
name: "gutterpress",
|
|
7
|
-
version: "0.10.0
|
|
7
|
+
version: "0.10.0",
|
|
8
8
|
description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
|
|
9
9
|
author: "itlackey",
|
|
10
10
|
license: "MPL-2.0",
|
|
@@ -15,20 +15,21 @@ import {
|
|
|
15
15
|
stampCreator,
|
|
16
16
|
stripAnnotations,
|
|
17
17
|
warn
|
|
18
|
-
} from "./cli-
|
|
18
|
+
} from "./cli-cqtggsng.js";
|
|
19
19
|
import {
|
|
20
20
|
DEBOUNCE,
|
|
21
21
|
UsageError,
|
|
22
22
|
resolvePort
|
|
23
|
-
} from "./cli-
|
|
23
|
+
} from "./cli-149edp6b.js";
|
|
24
24
|
import {
|
|
25
|
+
MARGIN_BOX_IGNORED_PROPERTIES,
|
|
25
26
|
RENDER_TIMEOUT_MS,
|
|
26
27
|
REQUIRED_MILESTONE,
|
|
27
28
|
closeBrowser,
|
|
28
29
|
getAssetPath,
|
|
29
30
|
getBrowser,
|
|
30
31
|
prewarmBrowser
|
|
31
|
-
} from "./cli-
|
|
32
|
+
} from "./cli-zfcryxg8.js";
|
|
32
33
|
import {
|
|
33
34
|
INSTALL_HINTS,
|
|
34
35
|
execCapture,
|
|
@@ -1089,17 +1090,6 @@ import { readFile as readFile3 } from "node:fs/promises";
|
|
|
1089
1090
|
|
|
1090
1091
|
// src/lib/printsafe.ts
|
|
1091
1092
|
import postcss from "postcss";
|
|
1092
|
-
|
|
1093
|
-
// src/engine/shared/margin-box-support.ts
|
|
1094
|
-
var MARGIN_BOX_IGNORED_PROPERTIES = new Set([
|
|
1095
|
-
"transform",
|
|
1096
|
-
"rotate",
|
|
1097
|
-
"translate",
|
|
1098
|
-
"scale",
|
|
1099
|
-
"box-shadow"
|
|
1100
|
-
]);
|
|
1101
|
-
|
|
1102
|
-
// src/lib/printsafe.ts
|
|
1103
1093
|
var ruleRemoteUrls = "printsafe/no-remote-urls";
|
|
1104
1094
|
var ruleRiskyProps = "printsafe/no-risky-print-effects";
|
|
1105
1095
|
var ruleSyntax = "printsafe/syntax-error";
|
|
@@ -3291,6 +3281,9 @@ function warn2(env, line, type, message, marker) {
|
|
|
3291
3281
|
env.layoutWarnings = [];
|
|
3292
3282
|
env.layoutWarnings.push({ line, type, message, marker });
|
|
3293
3283
|
}
|
|
3284
|
+
function proseEscapeHint(kind) {
|
|
3285
|
+
return ` If this line is prose that happens to begin with "@${kind}" — a wrapped sentence, for` + ` instance — escape it as \\@${kind} or write it as \`@${kind}\` in backticks, and it stays text.`;
|
|
3286
|
+
}
|
|
3294
3287
|
function addClasses(token, baseClass, extraClass) {
|
|
3295
3288
|
const cls = [];
|
|
3296
3289
|
if (baseClass)
|
|
@@ -3352,14 +3345,14 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3352
3345
|
const unknown = parsed.__unknownTokens;
|
|
3353
3346
|
delete parsed.__unknownTokens;
|
|
3354
3347
|
for (const t of unknown) {
|
|
3355
|
-
warn2(state.env, startLine + 1, "unrecognized_marker_token", `@${parsed.kind}: "${t}" is not something a marker understands, so it was kept verbatim as a class name. Write a class as .my-class (or {.my-class}), an id as #my-id, and anything else as key=value
|
|
3348
|
+
warn2(state.env, startLine + 1, "unrecognized_marker_token", `@${parsed.kind}: "${t}" is not something a marker understands, so it was kept verbatim as a class name. Write a class as .my-class (or {.my-class}), an id as #my-id, and anything else as key=value.` + proseEscapeHint(parsed.kind), parsed);
|
|
3356
3349
|
}
|
|
3357
3350
|
}
|
|
3358
3351
|
if (parsed.__extraBareTokens) {
|
|
3359
3352
|
const { tokens, named } = parsed.__extraBareTokens;
|
|
3360
3353
|
delete parsed.__extraBareTokens;
|
|
3361
3354
|
const list = tokens.map((t) => `"${t}"`).join(", ");
|
|
3362
|
-
warn2(state.env, startLine + 1, "extra_bare_marker_token", named ? `@${parsed.kind}: only the first plain word (${list.split(", ")[0]}) is used as the name; the rest (${tokens.slice(1).map((t) => `"${t}"`).join(", ")}) became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.slice(1).join(" .")}.` : `@${parsed.kind}: ${list} are several plain words, but a marker has only one name slot — so NONE of them was used as the name and they all became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.join(" .")}
|
|
3355
|
+
warn2(state.env, startLine + 1, "extra_bare_marker_token", named ? `@${parsed.kind}: only the first plain word (${list.split(", ")[0]}) is used as the name; the rest (${tokens.slice(1).map((t) => `"${t}"`).join(", ")}) became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.slice(1).join(" .")}.` : `@${parsed.kind}: ${list} are several plain words, but a marker has only one name slot — so NONE of them was used as the name and they all became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.join(" .")}.` + proseEscapeHint(parsed.kind), parsed);
|
|
3363
3356
|
}
|
|
3364
3357
|
state.line = startLine + 1;
|
|
3365
3358
|
return true;
|
|
@@ -3451,7 +3444,12 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3451
3444
|
t.meta = { line: meta.__line };
|
|
3452
3445
|
addClasses(t, "spread", meta.attrs && meta.attrs.class ? meta.attrs.class : "");
|
|
3453
3446
|
attachDataAttrs(t, "spread", meta.name, meta.attrs || {});
|
|
3454
|
-
stack.open({
|
|
3447
|
+
stack.open({
|
|
3448
|
+
kind: "spread",
|
|
3449
|
+
classes: meta.attrs && meta.attrs.class || "",
|
|
3450
|
+
noPagesYet: true,
|
|
3451
|
+
sawAnyPage: false
|
|
3452
|
+
}, t);
|
|
3455
3453
|
}
|
|
3456
3454
|
function openPage(meta) {
|
|
3457
3455
|
const chapter = stack.get("chapter");
|
|
@@ -3466,7 +3464,7 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3466
3464
|
const label = chapter ? chapter.label : "";
|
|
3467
3465
|
if (label)
|
|
3468
3466
|
t.attrSet("data-chapter-label", label);
|
|
3469
|
-
stack.open({ kind: "page" }, t);
|
|
3467
|
+
stack.open({ kind: "page", classes: explicit }, t);
|
|
3470
3468
|
if (label && !chapter.openerEmitted) {
|
|
3471
3469
|
const opener = new state.Token("html_block", "", 0);
|
|
3472
3470
|
opener.content = `<div class="chapter-opener" data-chapter-label="${escapeAttr(label)}">${escapeHtml(label)}</div>
|
|
@@ -3489,13 +3487,51 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3489
3487
|
t.meta = { hasColumnBreak: false, line: meta.__line };
|
|
3490
3488
|
stack.open({
|
|
3491
3489
|
kind: "section",
|
|
3490
|
+
classes: meta.attrs && meta.attrs.class || "",
|
|
3491
|
+
sawContent: false,
|
|
3492
3492
|
meta: { name: meta.name || null, attrs: { ...meta.attrs || {} } },
|
|
3493
3493
|
openToken: t
|
|
3494
3494
|
}, t);
|
|
3495
3495
|
}
|
|
3496
|
+
function breakHost() {
|
|
3497
|
+
return stack.get("section") || stack.get("page") || stack.get("spread");
|
|
3498
|
+
}
|
|
3499
|
+
function gridClassOf(frame) {
|
|
3500
|
+
if (!frame || !frame.classes)
|
|
3501
|
+
return null;
|
|
3502
|
+
return frame.classes.split(/\s+/).find((c) => c.startsWith("gp-grid-")) || null;
|
|
3503
|
+
}
|
|
3504
|
+
function warnBreakInsideGrid(kind, line, meta) {
|
|
3505
|
+
const host = breakHost();
|
|
3506
|
+
const gridClass = gridClassOf(host);
|
|
3507
|
+
if (!gridClass)
|
|
3508
|
+
return;
|
|
3509
|
+
warn2(state.env, line, "break_inside_grid", `@${kind} inside a grid container: the enclosing @${host.kind} carries .${gridClass}, so this break's <div> becomes a grid ITEM — it takes a cell of its own, corrupts the grid's placement, and print and the live preview then disagree about which page the content after it lands on. Move the break outside the grid @${host.kind}, or remove it — grid rows already flow and fragment across pages on their own.`, meta);
|
|
3510
|
+
}
|
|
3511
|
+
function warnIfEmptyDecoratedSection(closingKind, closingLine) {
|
|
3512
|
+
const sec = stack.get("section");
|
|
3513
|
+
if (!sec || sec.sawContent)
|
|
3514
|
+
return;
|
|
3515
|
+
const attrs = sec.meta && sec.meta.attrs || {};
|
|
3516
|
+
const decorations = [];
|
|
3517
|
+
for (const c of (attrs.class || "").split(/\s+/).filter(Boolean))
|
|
3518
|
+
decorations.push(`.${c}`);
|
|
3519
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
3520
|
+
if (k === "class")
|
|
3521
|
+
continue;
|
|
3522
|
+
decorations.push(k === "id" ? `#${v}` : `${k}=${v}`);
|
|
3523
|
+
}
|
|
3524
|
+
if (!decorations.length)
|
|
3525
|
+
return;
|
|
3526
|
+
const openLine = sec.openToken && sec.openToken.meta && Number.isFinite(sec.openToken.meta.line) ? sec.openToken.meta.line : closingLine;
|
|
3527
|
+
warn2(state.env, openLine, "empty_section", `This @section (${decorations.join(" ")}) was closed by the @${closingKind} on line ${closingLine} with no content between the two markers, so its styling applies to an empty element and nothing prints the layout it asked for. Delete one of the two markers, or move the content that belongs inside the section between them.`, null);
|
|
3528
|
+
}
|
|
3496
3529
|
for (let i = 0;i < state.tokens.length; i++) {
|
|
3497
3530
|
const tok = state.tokens[i];
|
|
3498
3531
|
if (tok.type !== "layout_marker") {
|
|
3532
|
+
const openSectionFrame = stack.get("section");
|
|
3533
|
+
if (openSectionFrame)
|
|
3534
|
+
openSectionFrame.sawContent = true;
|
|
3499
3535
|
out.push(tok);
|
|
3500
3536
|
continue;
|
|
3501
3537
|
}
|
|
@@ -3521,6 +3557,7 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3521
3557
|
continue;
|
|
3522
3558
|
}
|
|
3523
3559
|
if (kind === "section") {
|
|
3560
|
+
warnIfEmptyDecoratedSection("section", line);
|
|
3524
3561
|
stack.close("section");
|
|
3525
3562
|
const spread = stack.get("spread");
|
|
3526
3563
|
if (spread && spread.noPagesYet) {
|
|
@@ -3549,6 +3586,7 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3549
3586
|
continue;
|
|
3550
3587
|
}
|
|
3551
3588
|
if (kind === "page-break") {
|
|
3589
|
+
warnBreakInsideGrid("page-break", line, meta);
|
|
3552
3590
|
const t = new state.Token("layout_page_break", "div", 0);
|
|
3553
3591
|
t.meta = { line };
|
|
3554
3592
|
t.attrSet("class", "gp-page-break");
|
|
@@ -3557,6 +3595,7 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3557
3595
|
continue;
|
|
3558
3596
|
}
|
|
3559
3597
|
if (kind === "column-break") {
|
|
3598
|
+
warnBreakInsideGrid("column-break", line, meta);
|
|
3560
3599
|
const openSectionFrame = stack.get("section");
|
|
3561
3600
|
if (openSectionFrame && openSectionFrame.openToken && !openSectionFrame.openToken.meta.hasColumnBreak) {
|
|
3562
3601
|
const sectionCls = openSectionFrame.openToken.attrGet("class") || "";
|
|
@@ -3572,6 +3611,7 @@ function plugin(md, pluginOptions = {}) {
|
|
|
3572
3611
|
continue;
|
|
3573
3612
|
}
|
|
3574
3613
|
if (kind === "end-section") {
|
|
3614
|
+
warnIfEmptyDecoratedSection("end-section", line);
|
|
3575
3615
|
stack.close("section");
|
|
3576
3616
|
continue;
|
|
3577
3617
|
}
|
|
@@ -3659,7 +3699,7 @@ body { margin: 0; }
|
|
|
3659
3699
|
.gp-page-break { break-before: page; }
|
|
3660
3700
|
.page { break-before: page; }
|
|
3661
3701
|
.spread { break-before: page; }
|
|
3662
|
-
:where(.page, .spread) { position: relative; }
|
|
3702
|
+
:where(.page, .spread) { position: relative; display: flow-root; box-sizing: border-box; min-height: calc(var(--gp-content-h, 1px) - 1px); }
|
|
3663
3703
|
.gp-column-break { break-after: column; height: 0; font-size: 0; line-height: 0; visibility: hidden; }
|
|
3664
3704
|
|
|
3665
3705
|
:where(h1,h2,h3,h4,h5,h6) { break-after: avoid; }
|
|
@@ -6006,6 +6046,26 @@ var GUTTERPRESS_CSS = `
|
|
|
6006
6046
|
.gp-columns-2 { columns: 2; column-gap: var(--gp-column-gap, 1.5em); }
|
|
6007
6047
|
.gp-columns-3 { columns: 3; column-gap: var(--gp-column-gap, 1.5em); }
|
|
6008
6048
|
|
|
6049
|
+
/* grid runs — the SLOTTED counterpart to the column runs above. Grid places
|
|
6050
|
+
each child into the next cell, across then down (deterministic slots: card
|
|
6051
|
+
layouts, stat blocks, image-plus-caption pairs); columns FLOW one text run
|
|
6052
|
+
down then across. Same neutral-primitive rationale as .gp-columns-*, and
|
|
6053
|
+
permanent vocabulary for the same reason: standard CSS Grid verbatim, no
|
|
6054
|
+
spec gap to remove later. MEASURED (Chromium 151, gp-grid evidence pack):
|
|
6055
|
+
grid rows fragment across sheets with EXACT print/viewer parity — 2- and
|
|
6056
|
+
3-col, unequal item heights, mid-row cuts, multi-sheet overflow,
|
|
6057
|
+
break-inside:avoid, gap geometry — so a grid taller than the page is safe,
|
|
6058
|
+
no fit-one-page constraint. Two things to know, not fix:
|
|
6059
|
+
- on a min-height page root (MARKER_CSS), default align-content
|
|
6060
|
+
stretches rows apart to fill the page — identically in both engines.
|
|
6061
|
+
Authors wanting packed rows set align-content: start.
|
|
6062
|
+
- a @page-break / @column-break marker DIRECTLY inside a grid container
|
|
6063
|
+
becomes a grid item and corrupts placement (the one measured parity
|
|
6064
|
+
break); markers.js diagnoses it (break_inside_grid).
|
|
6065
|
+
--gp-grid-gap is author-settable. */
|
|
6066
|
+
.gp-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gp-grid-gap, 1.5em); }
|
|
6067
|
+
.gp-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gp-grid-gap, 1.5em); }
|
|
6068
|
+
|
|
6009
6069
|
/* shape wrap — text follows the image's alpha silhouette instead of its
|
|
6010
6070
|
rectangular box. shape-outside only applies to floats, so this is inert
|
|
6011
6071
|
without .gp-left/.gp-right (and under .gp-pin, which un-floats). The
|
|
@@ -6068,8 +6128,14 @@ img.gp-shape {
|
|
|
6068
6128
|
opacity, filter, transform on it traps the negative layer inside).
|
|
6069
6129
|
Core keeps .page/.spread at 'position: relative; z-index: auto'
|
|
6070
6130
|
precisely so they are not stacking contexts.
|
|
6071
|
-
- a clipping ancestor (overflow other than visible)
|
|
6072
|
-
|
|
6131
|
+
- a clipping ancestor (overflow other than visible) — but only where
|
|
6132
|
+
the art actually overhangs that ancestor's clip box on a clipped
|
|
6133
|
+
axis: the overhang is cut off, the same mechanism that clips a
|
|
6134
|
+
.gp-bleed plate back to the wrapper's width. Clipping never reorders
|
|
6135
|
+
layers — within-bounds art under a clipping .page prints whole and
|
|
6136
|
+
still behind (measured; see the build audit's comment in
|
|
6137
|
+
engine/compiler/build.ts), and a static wrapper's overflow never
|
|
6138
|
+
binds an abspos .gp-pin at all.
|
|
6073
6139
|
The build-time engine.layer.trapped audit reports both against the live
|
|
6074
6140
|
ancestor chain. printsafe/page-containment is only an early source hint for
|
|
6075
6141
|
declarations written directly on .page/.spread. */
|
|
@@ -6839,6 +6905,76 @@ async function writeBuildFingerprint(input) {
|
|
|
6839
6905
|
return outPath;
|
|
6840
6906
|
}
|
|
6841
6907
|
|
|
6908
|
+
// src/lib/build-preflight.ts
|
|
6909
|
+
var INSTALL_HINTS2 = {
|
|
6910
|
+
gs: INSTALL_HINTS.gs.body,
|
|
6911
|
+
qpdf: INSTALL_HINTS.qpdf.body
|
|
6912
|
+
};
|
|
6913
|
+
function rendersInPooledChromium(format, opts) {
|
|
6914
|
+
return format !== "html" && !opts.engineBrowser;
|
|
6915
|
+
}
|
|
6916
|
+
async function preflightBuildTools(format, opts, config) {
|
|
6917
|
+
const missing = [];
|
|
6918
|
+
if (rendersInPooledChromium(format, opts) && !await resolveChromiumExecutable()) {
|
|
6919
|
+
await requireChromiumExecutable();
|
|
6920
|
+
}
|
|
6921
|
+
if (format === "pdfx" && !await resolveGhostscript()) {
|
|
6922
|
+
missing.push({ name: "gs (Ghostscript)", installHint: INSTALL_HINTS2.gs });
|
|
6923
|
+
}
|
|
6924
|
+
if (format === "pdfx") {
|
|
6925
|
+
const stripAnnotations2 = opts.stripAnnotations ?? config.pdfx.stripAnnotations;
|
|
6926
|
+
if (stripAnnotations2 && !await isToolAvailable("qpdf")) {
|
|
6927
|
+
missing.push({ name: "qpdf", installHint: INSTALL_HINTS2.qpdf });
|
|
6928
|
+
}
|
|
6929
|
+
}
|
|
6930
|
+
if (missing.length === 0)
|
|
6931
|
+
return;
|
|
6932
|
+
const list = missing.map((m) => ` • ${m.name}
|
|
6933
|
+
${m.installHint}`).join(`
|
|
6934
|
+
|
|
6935
|
+
`);
|
|
6936
|
+
throw new BuildError(`Required system tools not found:
|
|
6937
|
+
|
|
6938
|
+
${list}
|
|
6939
|
+
|
|
6940
|
+
Install the missing tools and re-run, or set GHOSTSCRIPT_PATH, CHROMIUM_PATH, or system PATH so gutterpress can find them. See the User Guide Chapter 7 (System Setup) at examples/gutterpress-user-guide/07-system-setup.md for the full per-feature matrix.`, 2);
|
|
6941
|
+
}
|
|
6942
|
+
async function verifyNativeChromiumMilestone() {
|
|
6943
|
+
let version;
|
|
6944
|
+
try {
|
|
6945
|
+
const browser = await getBrowser(RENDER_TIMEOUT_MS);
|
|
6946
|
+
version = await browser.version();
|
|
6947
|
+
} catch (err) {
|
|
6948
|
+
throw new BuildError(`Could not launch a Chromium browser for --engine native: ` + `${err instanceof Error ? err.message : String(err)}`, 2);
|
|
6949
|
+
}
|
|
6950
|
+
const milestone = Number(/Chrome\/(\d+)/.exec(version)?.[1] ?? 0);
|
|
6951
|
+
if (milestone < REQUIRED_MILESTONE) {
|
|
6952
|
+
await closeBrowser();
|
|
6953
|
+
throw new BuildError(`The Gutterpress native engine requires Chromium ${REQUIRED_MILESTONE}+; found ${version}.
|
|
6954
|
+
|
|
6955
|
+
` + `Install a newer Chrome, Chromium, or Edge, or point CHROMIUM_PATH ` + `(or PUPPETEER_EXECUTABLE_PATH) at a ${REQUIRED_MILESTONE}+ binary.
|
|
6956
|
+
|
|
6957
|
+
` + `Note: this applies to the CLI's own Chromium only — the desktop app ` + `renders --engine native with its own bundled browser and needs no ` + `separate install.`, 2);
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
function computeGates(format, opts, config) {
|
|
6961
|
+
if (format === "html") {
|
|
6962
|
+
if (opts.skipLint || opts.skipPreValidate || opts.skipPostValidate) {
|
|
6963
|
+
log.info("Validation/lint flags ignored for --format html (no validation phases apply)");
|
|
6964
|
+
}
|
|
6965
|
+
return { lint: false, preValidate: false, postValidate: false };
|
|
6966
|
+
}
|
|
6967
|
+
const lint2 = !opts.skipLint && config.lint.enabled !== false;
|
|
6968
|
+
const preValidate = !opts.skipPreValidate && config.validate.enabled !== false;
|
|
6969
|
+
const postValidate = format === "pdfx" && !opts.skipPostValidate && config.validate.enabled !== false;
|
|
6970
|
+
return { lint: lint2, preValidate, postValidate };
|
|
6971
|
+
}
|
|
6972
|
+
|
|
6973
|
+
// src/lib/build-staging.ts
|
|
6974
|
+
import path6 from "node:path";
|
|
6975
|
+
import os from "node:os";
|
|
6976
|
+
import fsp from "node:fs/promises";
|
|
6977
|
+
|
|
6842
6978
|
// src/lib/missing-asset-placeholder.ts
|
|
6843
6979
|
import { createHash as createHash5 } from "node:crypto";
|
|
6844
6980
|
import { deflateSync } from "node:zlib";
|
|
@@ -7016,75 +7152,7 @@ function placeholderPng(width = 640, height = 480, cell = 32) {
|
|
|
7016
7152
|
return png;
|
|
7017
7153
|
}
|
|
7018
7154
|
|
|
7019
|
-
// src/lib/build-preflight.ts
|
|
7020
|
-
var INSTALL_HINTS2 = {
|
|
7021
|
-
gs: INSTALL_HINTS.gs.body,
|
|
7022
|
-
qpdf: INSTALL_HINTS.qpdf.body
|
|
7023
|
-
};
|
|
7024
|
-
function rendersInPooledChromium(format, opts) {
|
|
7025
|
-
return format !== "html" && !opts.engineBrowser;
|
|
7026
|
-
}
|
|
7027
|
-
async function preflightBuildTools(format, opts, config) {
|
|
7028
|
-
const missing = [];
|
|
7029
|
-
if (rendersInPooledChromium(format, opts) && !await resolveChromiumExecutable()) {
|
|
7030
|
-
await requireChromiumExecutable();
|
|
7031
|
-
}
|
|
7032
|
-
if (format === "pdfx" && !await resolveGhostscript()) {
|
|
7033
|
-
missing.push({ name: "gs (Ghostscript)", installHint: INSTALL_HINTS2.gs });
|
|
7034
|
-
}
|
|
7035
|
-
if (format === "pdfx") {
|
|
7036
|
-
const stripAnnotations2 = opts.stripAnnotations ?? config.pdfx.stripAnnotations;
|
|
7037
|
-
if (stripAnnotations2 && !await isToolAvailable("qpdf")) {
|
|
7038
|
-
missing.push({ name: "qpdf", installHint: INSTALL_HINTS2.qpdf });
|
|
7039
|
-
}
|
|
7040
|
-
}
|
|
7041
|
-
if (missing.length === 0)
|
|
7042
|
-
return;
|
|
7043
|
-
const list = missing.map((m) => ` • ${m.name}
|
|
7044
|
-
${m.installHint}`).join(`
|
|
7045
|
-
|
|
7046
|
-
`);
|
|
7047
|
-
throw new BuildError(`Required system tools not found:
|
|
7048
|
-
|
|
7049
|
-
${list}
|
|
7050
|
-
|
|
7051
|
-
Install the missing tools and re-run, or set GHOSTSCRIPT_PATH, CHROMIUM_PATH, or system PATH so gutterpress can find them. See the User Guide Chapter 7 (System Setup) at examples/gutterpress-user-guide/07-system-setup.md for the full per-feature matrix.`, 2);
|
|
7052
|
-
}
|
|
7053
|
-
async function verifyNativeChromiumMilestone() {
|
|
7054
|
-
let version;
|
|
7055
|
-
try {
|
|
7056
|
-
const browser = await getBrowser(RENDER_TIMEOUT_MS);
|
|
7057
|
-
version = await browser.version();
|
|
7058
|
-
} catch (err) {
|
|
7059
|
-
throw new BuildError(`Could not launch a Chromium browser for --engine native: ` + `${err instanceof Error ? err.message : String(err)}`, 2);
|
|
7060
|
-
}
|
|
7061
|
-
const milestone = Number(/Chrome\/(\d+)/.exec(version)?.[1] ?? 0);
|
|
7062
|
-
if (milestone < REQUIRED_MILESTONE) {
|
|
7063
|
-
await closeBrowser();
|
|
7064
|
-
throw new BuildError(`The Gutterpress native engine requires Chromium ${REQUIRED_MILESTONE}+; found ${version}.
|
|
7065
|
-
|
|
7066
|
-
` + `Install a newer Chrome, Chromium, or Edge, or point CHROMIUM_PATH ` + `(or PUPPETEER_EXECUTABLE_PATH) at a ${REQUIRED_MILESTONE}+ binary.
|
|
7067
|
-
|
|
7068
|
-
` + `Note: this applies to the CLI's own Chromium only — the desktop app ` + `renders --engine native with its own bundled browser and needs no ` + `separate install.`, 2);
|
|
7069
|
-
}
|
|
7070
|
-
}
|
|
7071
|
-
function computeGates(format, opts, config) {
|
|
7072
|
-
if (format === "html") {
|
|
7073
|
-
if (opts.skipLint || opts.skipPreValidate || opts.skipPostValidate) {
|
|
7074
|
-
log.info("Validation/lint flags ignored for --format html (no validation phases apply)");
|
|
7075
|
-
}
|
|
7076
|
-
return { lint: false, preValidate: false, postValidate: false };
|
|
7077
|
-
}
|
|
7078
|
-
const lint2 = !opts.skipLint && config.lint.enabled !== false;
|
|
7079
|
-
const preValidate = !opts.skipPreValidate && config.validate.enabled !== false;
|
|
7080
|
-
const postValidate = format === "pdfx" && !opts.skipPostValidate && config.validate.enabled !== false;
|
|
7081
|
-
return { lint: lint2, preValidate, postValidate };
|
|
7082
|
-
}
|
|
7083
|
-
|
|
7084
7155
|
// src/lib/build-staging.ts
|
|
7085
|
-
import path6 from "node:path";
|
|
7086
|
-
import os from "node:os";
|
|
7087
|
-
import fsp from "node:fs/promises";
|
|
7088
7156
|
async function shipViewerHtml(htmlFile, outDir) {
|
|
7089
7157
|
await fsp.mkdir(path6.join(outDir, "engine"), { recursive: true });
|
|
7090
7158
|
await fsp.copyFile(await getAssetPath("engine/gutterpress-viewer.js"), path6.join(outDir, "engine/gutterpress-viewer.js"));
|
|
@@ -7093,6 +7161,50 @@ async function shipViewerHtml(htmlFile, outDir) {
|
|
|
7093
7161
|
const html = await fsp.readFile(htmlFile, "utf-8");
|
|
7094
7162
|
await fsp.writeFile(htmlFile, /<\/head>/i.test(html) ? html.replace(/<\/head>/i, tag + "</head>") : tag + html, "utf-8");
|
|
7095
7163
|
}
|
|
7164
|
+
async function stageBookAssets(options) {
|
|
7165
|
+
const { renderDir, outDir, htmlFile, imageRefs, cssAssets, onPlan } = options;
|
|
7166
|
+
const { copies: imageCopies, errors, destinations } = await planImageCopies(renderDir, imageRefs);
|
|
7167
|
+
const copies = [...cssAssets, ...imageCopies];
|
|
7168
|
+
onPlan?.({ unresolved: errors, copyCount: copies.length });
|
|
7169
|
+
const missingPlaceholders = copies.length ? await copyReferencedAssets(copies, outDir) : new Map;
|
|
7170
|
+
let staged = await fsp.readFile(htmlFile, "utf8");
|
|
7171
|
+
if (missingPlaceholders.size > 0) {
|
|
7172
|
+
const rewrites = new Map(missingPlaceholders);
|
|
7173
|
+
for (const [ref, dest] of destinations) {
|
|
7174
|
+
const placeholder = missingPlaceholders.get(dest);
|
|
7175
|
+
if (placeholder)
|
|
7176
|
+
rewrites.set(ref, placeholder);
|
|
7177
|
+
}
|
|
7178
|
+
staged = rewriteMissingImageReferences(staged, rewrites);
|
|
7179
|
+
await fsp.writeFile(htmlFile, staged, "utf8");
|
|
7180
|
+
}
|
|
7181
|
+
if (staged.includes("--gp-shape:")) {
|
|
7182
|
+
await fsp.writeFile(htmlFile, await inlineShapeUrls(staged, outDir), "utf8");
|
|
7183
|
+
}
|
|
7184
|
+
return { missing: [...missingPlaceholders.keys()].sort() };
|
|
7185
|
+
}
|
|
7186
|
+
async function copyReferencedAssets(copies, outDir) {
|
|
7187
|
+
const dirs = new Set(copies.map((c) => path6.dirname(path6.resolve(outDir, c.to))));
|
|
7188
|
+
await Promise.all([...dirs].map((d) => fsp.mkdir(d, { recursive: true })));
|
|
7189
|
+
const missing = new Map;
|
|
7190
|
+
await Promise.all(copies.map(async (c) => {
|
|
7191
|
+
const dest = path6.resolve(outDir, c.to);
|
|
7192
|
+
try {
|
|
7193
|
+
await fsp.copyFile(c.from, dest);
|
|
7194
|
+
} catch (err) {
|
|
7195
|
+
if (err?.code === "ENOENT") {
|
|
7196
|
+
const placeholder = placeholderOutputPath(c.to);
|
|
7197
|
+
const placeholderDest = path6.resolve(outDir, placeholder);
|
|
7198
|
+
await fsp.mkdir(path6.dirname(placeholderDest), { recursive: true });
|
|
7199
|
+
await fsp.writeFile(placeholderDest, placeholderPng());
|
|
7200
|
+
missing.set(c.to, placeholder);
|
|
7201
|
+
return;
|
|
7202
|
+
}
|
|
7203
|
+
throw new BuildError(`Could not copy asset ${c.from} → ${c.to}: ` + (err instanceof Error ? err.message : String(err)), 1);
|
|
7204
|
+
}
|
|
7205
|
+
}));
|
|
7206
|
+
return missing;
|
|
7207
|
+
}
|
|
7096
7208
|
async function createStageRoot() {
|
|
7097
7209
|
return fsp.mkdtemp(path6.join(os.tmpdir(), "gutterpress-stage-"));
|
|
7098
7210
|
}
|
|
@@ -7205,67 +7317,35 @@ async function renderBook(ctx) {
|
|
|
7205
7317
|
}
|
|
7206
7318
|
},
|
|
7207
7319
|
onImageRefs: (refs) => imageRefs.push(...refs),
|
|
7208
|
-
onCssAssets: (
|
|
7320
|
+
onCssAssets: (copies) => cssAssets.push(...copies),
|
|
7209
7321
|
onStyleWarnings: (warnings) => {
|
|
7210
7322
|
for (const w of warnings)
|
|
7211
7323
|
log.warn(` ${w}`);
|
|
7212
7324
|
}
|
|
7213
7325
|
});
|
|
7214
7326
|
log.success(`Wrote ${htmlFile}`);
|
|
7215
|
-
const {
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7327
|
+
const { missing } = await stageBookAssets({
|
|
7328
|
+
renderDir,
|
|
7329
|
+
outDir: workDir,
|
|
7330
|
+
htmlFile,
|
|
7331
|
+
imageRefs,
|
|
7332
|
+
cssAssets,
|
|
7333
|
+
onPlan: ({ unresolved, copyCount }) => {
|
|
7334
|
+
if (unresolved.length > 0) {
|
|
7335
|
+
throw new BuildError(`Cannot resolve ${unresolved.length} image reference(s):
|
|
7336
|
+
` + unresolved.map((e) => ` - ${e}`).join(`
|
|
7219
7337
|
`), 1);
|
|
7220
|
-
}
|
|
7221
|
-
const copies = [...cssAssets, ...imageCopies];
|
|
7222
|
-
let missingPlaceholders = new Map;
|
|
7223
|
-
if (copies.length > 0) {
|
|
7224
|
-
log.info(`Copying ${copies.length} referenced asset(s)`);
|
|
7225
|
-
missingPlaceholders = await copyReferencedAssets(copies, workDir);
|
|
7226
|
-
}
|
|
7227
|
-
let staged = await fsp2.readFile(htmlFile, "utf8");
|
|
7228
|
-
if (missingPlaceholders.size > 0) {
|
|
7229
|
-
const rewrites = new Map(missingPlaceholders);
|
|
7230
|
-
for (const [ref, dest] of destinations) {
|
|
7231
|
-
const placeholder = missingPlaceholders.get(dest);
|
|
7232
|
-
if (placeholder)
|
|
7233
|
-
rewrites.set(ref, placeholder);
|
|
7234
|
-
}
|
|
7235
|
-
staged = rewriteMissingImageReferences(staged, rewrites);
|
|
7236
|
-
await fsp2.writeFile(htmlFile, staged, "utf8");
|
|
7237
|
-
}
|
|
7238
|
-
if (staged.includes("--gp-shape:")) {
|
|
7239
|
-
await fsp2.writeFile(htmlFile, await inlineShapeUrls(staged, workDir), "utf8");
|
|
7240
|
-
}
|
|
7241
|
-
return htmlFile;
|
|
7242
|
-
}
|
|
7243
|
-
async function copyReferencedAssets(copies, outDir) {
|
|
7244
|
-
const dirs = new Set(copies.map((c) => path7.dirname(path7.resolve(outDir, c.to))));
|
|
7245
|
-
await Promise.all([...dirs].map((d) => fsp2.mkdir(d, { recursive: true })));
|
|
7246
|
-
const missing = new Map;
|
|
7247
|
-
await Promise.all(copies.map(async (c) => {
|
|
7248
|
-
const dest = path7.resolve(outDir, c.to);
|
|
7249
|
-
try {
|
|
7250
|
-
await fsp2.copyFile(c.from, dest);
|
|
7251
|
-
} catch (err) {
|
|
7252
|
-
if (err?.code === "ENOENT") {
|
|
7253
|
-
const placeholder = placeholderOutputPath(c.to);
|
|
7254
|
-
const placeholderDest = path7.resolve(outDir, placeholder);
|
|
7255
|
-
await fsp2.mkdir(path7.dirname(placeholderDest), { recursive: true });
|
|
7256
|
-
await fsp2.writeFile(placeholderDest, placeholderPng());
|
|
7257
|
-
missing.set(c.to, placeholder);
|
|
7258
|
-
return;
|
|
7259
7338
|
}
|
|
7260
|
-
|
|
7339
|
+
if (copyCount > 0)
|
|
7340
|
+
log.info(`Copying ${copyCount} referenced asset(s)`);
|
|
7261
7341
|
}
|
|
7262
|
-
})
|
|
7263
|
-
if (missing.
|
|
7264
|
-
log.warn(`${missing.
|
|
7265
|
-
for (const m of
|
|
7342
|
+
});
|
|
7343
|
+
if (missing.length > 0) {
|
|
7344
|
+
log.warn(`${missing.length} referenced image(s) do not exist — a magenta placeholder ` + `was substituted so the build could finish. Each one is a visible hole ` + `in the PDF:`);
|
|
7345
|
+
for (const m of missing)
|
|
7266
7346
|
log.warn(` missing: ${m}`);
|
|
7267
7347
|
}
|
|
7268
|
-
return
|
|
7348
|
+
return htmlFile;
|
|
7269
7349
|
}
|
|
7270
7350
|
async function resolveIccProfile(icc, manifestDir, explicitIccPath) {
|
|
7271
7351
|
const iccCandidates = path7.isAbsolute(icc) ? [icc] : [path7.resolve(manifestDir, icc), path7.resolve(icc)];
|
|
@@ -7363,11 +7443,12 @@ class PdfOutput {
|
|
|
7363
7443
|
const rawPdf = pdfxMode ? path7.join(stage, "raw.pdf") : path7.resolve(pdfFile);
|
|
7364
7444
|
await fsp2.mkdir(path7.dirname(path7.resolve(pdfFile)), { recursive: true });
|
|
7365
7445
|
log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
|
|
7366
|
-
const { buildNativePdf } = await import("./engine-
|
|
7446
|
+
const { buildNativePdf } = await import("./engine-ft4cr3ep.js");
|
|
7367
7447
|
const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
|
|
7368
7448
|
title: config.title,
|
|
7369
7449
|
author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
|
|
7370
|
-
signature: config.print.signature
|
|
7450
|
+
signature: config.print.signature,
|
|
7451
|
+
allowShrink: opts.allowShrink
|
|
7371
7452
|
}, opts.engineBrowser);
|
|
7372
7453
|
for (const d of engineDiagnostics)
|
|
7373
7454
|
log.warn(d.message);
|
|
@@ -21,6 +21,7 @@ var previewArgs = {
|
|
|
21
21
|
"skip-lint": { type: "boolean", description: "Skip CSS linting (pdf|pdfx only)" },
|
|
22
22
|
"skip-pre-validate": { type: "boolean", description: "Skip pre-build validation (pdf|pdfx only)" },
|
|
23
23
|
"skip-post-validate": { type: "boolean", description: "Skip post-build PDF/X validation (pdfx only)" },
|
|
24
|
+
"allow-shrink": { type: "boolean", description: "Build anyway when content is wider than the page content box, instead of failing. Chromium then scales the WHOLE book down to fit; the scale and each offender are reported as warnings. (pdf|pdfx only)" },
|
|
24
25
|
engine: { type: "string", description: "Pagination engine. Paged.js has been removed; native is the only engine. This flag is accepted but ignored (a warning fires for --engine paged)." }
|
|
25
26
|
};
|
|
26
27
|
|