@segnavia/render 0.4.0 → 0.6.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/dist/book-destinations.js +101 -0
- package/dist/book-margins.js +20 -0
- package/dist/book-pdf.js +233 -0
- package/dist/book-render.js +48 -0
- package/dist/browser-jpeg.js +42 -0
- package/dist/color/black-point.js +11 -8
- package/dist/color/engine.js +9 -2
- package/dist/color/interpolation.js +11 -1
- package/dist/color/profile.js +17 -7
- package/dist/color/tables.js +47 -13
- package/dist/color/types.js +4 -1
- package/dist/index.d.ts +5 -38
- package/dist/index.js +29 -5
- package/dist/options.d.ts +39 -0
- package/dist/press/adapter.js +117 -50
- package/dist/press/color-policy.js +27 -8
- package/dist/press/content.js +28 -20
- package/dist/press/image.js +327 -0
- package/dist/press/policy.js +27 -10
- package/dist/press/report.js +26 -14
- package/dist/press/sample-shading.js +9 -19
- package/dist/press/types.d.ts +9 -2
- package/dist/press/visitor.js +32 -30
- package/package.json +4 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { RenderRefusedError } from "./errors.js";
|
|
2
|
+
import { PDFArray, PDFDict, PDFHexString, PDFName, PDFNumber, PDFRef, PDFString } from "pdf-lib";
|
|
3
|
+
//#region src/book-destinations.ts
|
|
4
|
+
const key = (value) => PDFName.of(value);
|
|
5
|
+
const resolve = (document, value) => value instanceof PDFRef ? document.context.lookup(value) : value;
|
|
6
|
+
function decode(value) {
|
|
7
|
+
if (value instanceof PDFString || value instanceof PDFHexString) return value.decodeText();
|
|
8
|
+
if (value instanceof PDFName) return value.toString().slice(1);
|
|
9
|
+
}
|
|
10
|
+
function destination(document, value) {
|
|
11
|
+
let found = resolve(document, value);
|
|
12
|
+
if (found instanceof PDFDict) found = resolve(document, found.get(key("D")));
|
|
13
|
+
if (!(found instanceof PDFArray)) return void 0;
|
|
14
|
+
const reference = found.get(0);
|
|
15
|
+
if (!(reference instanceof PDFRef)) return void 0;
|
|
16
|
+
const page = document.getPages().findIndex((candidate) => candidate.ref.toString() === reference.toString());
|
|
17
|
+
if (page < 0) return void 0;
|
|
18
|
+
const x = resolve(document, found.get(2));
|
|
19
|
+
const y = resolve(document, found.get(3));
|
|
20
|
+
return {
|
|
21
|
+
page: page + 1,
|
|
22
|
+
x: x instanceof PDFNumber ? x.asNumber() : 0,
|
|
23
|
+
y: y instanceof PDFNumber ? y.asNumber() : document.getPage(page).getHeight()
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function nameTree(document, value, output) {
|
|
27
|
+
const dictionary = resolve(document, value);
|
|
28
|
+
if (!(dictionary instanceof PDFDict)) return;
|
|
29
|
+
const names = resolve(document, dictionary.get(key("Names")));
|
|
30
|
+
if (names instanceof PDFArray) for (let index = 0; index + 1 < names.size(); index += 2) {
|
|
31
|
+
const name = decode(resolve(document, names.get(index)));
|
|
32
|
+
const target = destination(document, names.get(index + 1));
|
|
33
|
+
if (name && target) output.set(name, target);
|
|
34
|
+
}
|
|
35
|
+
const kids = resolve(document, dictionary.get(key("Kids")));
|
|
36
|
+
if (kids instanceof PDFArray) for (let index = 0; index < kids.size(); index += 1) nameTree(document, kids.get(index), output);
|
|
37
|
+
}
|
|
38
|
+
function namedDestinations(document) {
|
|
39
|
+
const output = /* @__PURE__ */ new Map();
|
|
40
|
+
const names = resolve(document, document.catalog.get(key("Names")));
|
|
41
|
+
if (names instanceof PDFDict) nameTree(document, names.get(key("Dests")), output);
|
|
42
|
+
const direct = resolve(document, document.catalog.get(key("Dests")));
|
|
43
|
+
if (direct instanceof PDFDict) for (const [name, value] of direct.entries()) {
|
|
44
|
+
const target = destination(document, value);
|
|
45
|
+
if (target) output.set(name.toString().slice(1), target);
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
function hiddenAnnotation(document, annotation) {
|
|
50
|
+
const rectangle = resolve(document, annotation.get(key("Rect")));
|
|
51
|
+
if (!(rectangle instanceof PDFArray) || rectangle.size() !== 4) return false;
|
|
52
|
+
const values = Array.from({ length: 4 }, (_, index) => resolve(document, rectangle.get(index)));
|
|
53
|
+
if (!values.every((value) => value instanceof PDFNumber)) return false;
|
|
54
|
+
const [left = 0, bottom = 0, right = 0, top = 0] = values.map((value) => value.asNumber());
|
|
55
|
+
return Math.abs(right - left) <= 1 && Math.abs(top - bottom) <= 1;
|
|
56
|
+
}
|
|
57
|
+
function explicit(document, target, offset) {
|
|
58
|
+
return document.context.obj([
|
|
59
|
+
document.getPage(offset + target.page - 1).ref,
|
|
60
|
+
"XYZ",
|
|
61
|
+
target.x,
|
|
62
|
+
target.y,
|
|
63
|
+
null
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
function customTarget(uri) {
|
|
67
|
+
if (!uri?.startsWith("segnavia:target/")) return void 0;
|
|
68
|
+
try {
|
|
69
|
+
return decodeURIComponent(uri.slice(16));
|
|
70
|
+
} catch {
|
|
71
|
+
return uri.slice(16);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function rewriteBookLinks(document, frontPages, front, body) {
|
|
75
|
+
for (const [pageIndex, page] of document.getPages().entries()) {
|
|
76
|
+
const annotations = resolve(document, page.node.get(key("Annots")));
|
|
77
|
+
if (!(annotations instanceof PDFArray)) continue;
|
|
78
|
+
const kept = Array.from({ length: annotations.size() }, (_, index) => annotations.get(index)).filter((object) => rewriteAnnotation(document, object, pageIndex, frontPages, front, body));
|
|
79
|
+
if (kept.length > 0) page.node.set(key("Annots"), document.context.obj(kept));
|
|
80
|
+
else page.node.delete(key("Annots"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function rewriteAnnotation(document, object, pageIndex, frontPages, front, body) {
|
|
84
|
+
const annotation = resolve(document, object);
|
|
85
|
+
if (!(annotation instanceof PDFDict)) return true;
|
|
86
|
+
if (hiddenAnnotation(document, annotation)) return false;
|
|
87
|
+
const named = decode(resolve(document, annotation.get(key("Dest"))));
|
|
88
|
+
const action = resolve(document, annotation.get(key("A")));
|
|
89
|
+
const uri = action instanceof PDFDict ? decode(resolve(document, action.get(key("URI")))) : void 0;
|
|
90
|
+
const cross = customTarget(uri);
|
|
91
|
+
const target = cross ? front.get(cross) ?? body.get(cross) : named ? pageIndex < frontPages ? front.get(named) : body.get(named) : void 0;
|
|
92
|
+
if (cross && !target) throw new RenderRefusedError(`The segnavia link to ${cross} has no printed destination.`);
|
|
93
|
+
if (target) {
|
|
94
|
+
const offset = front.has(cross ?? "") || !cross && pageIndex < frontPages ? 0 : frontPages;
|
|
95
|
+
annotation.set(key("Dest"), explicit(document, target, offset));
|
|
96
|
+
annotation.delete(key("A"));
|
|
97
|
+
} else if (uri?.startsWith("segnavia:")) throw new RenderRefusedError(`The PDF still contains the segnavia link ${uri}.`);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { namedDestinations, rewriteBookLinks };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveDirection, sheetMm } from "@segnavia/format";
|
|
2
|
+
//#region src/book-margins.ts
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
5
|
+
}
|
|
6
|
+
function marginCss(book) {
|
|
7
|
+
if (!book.page) return "";
|
|
8
|
+
const size = sheetMm(book.page);
|
|
9
|
+
const edge = book.page.margin;
|
|
10
|
+
return `@page{size:${size.width}mm ${size.height}mm;margin:0}:where(html,body){margin:0}:where(.segnavia-margin-page){position:relative;inline-size:${size.width}mm;block-size:${size.height}mm;break-after:page;direction:ltr}:where(.segnavia-running-head,.segnavia-folio){position:absolute;display:flex;align-items:center}:where(.segnavia-running-head){inset-block-start:0;block-size:${edge.top}mm}:where(.segnavia-folio){inset-block-end:0;block-size:${edge.bottom}mm}:where([data-side="left"] .segnavia-running-head,[data-side="left"] .segnavia-folio){inset-inline-start:${edge.outer}mm;inset-inline-end:${edge.inner}mm;justify-content:flex-start}:where([data-side="right"] .segnavia-running-head,[data-side="right"] .segnavia-folio){inset-inline-start:${edge.inner}mm;inset-inline-end:${edge.outer}mm;justify-content:flex-end}`;
|
|
11
|
+
}
|
|
12
|
+
function marginDocument(book, pages) {
|
|
13
|
+
return pages.map((page) => {
|
|
14
|
+
const head = page.runningHead === void 0 ? "" : `<div class="segnavia-running-head"><bdi dir="${resolveDirection(book.dir, book.lang, page.runningHead)}">${escapeHtml(page.runningHead)}</bdi></div>`;
|
|
15
|
+
const folio = page.folio === void 0 ? "" : `<div class="segnavia-folio"><bdi dir="${resolveDirection(book.dir, book.lang, page.folio)}">${escapeHtml(page.folio)}</bdi></div>`;
|
|
16
|
+
return `<div class="segnavia-margin-page" data-side="${page.side}">${head}${folio}</div>`;
|
|
17
|
+
}).join("");
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { marginCss, marginDocument };
|
package/dist/book-pdf.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { marginDocument } from "./book-margins.js";
|
|
2
|
+
import { RenderRefusedError } from "./errors.js";
|
|
3
|
+
import { namedDestinations, rewriteBookLinks } from "./book-destinations.js";
|
|
4
|
+
import { Book, formatNumber, resolveDirection, sheetMm } from "@segnavia/format";
|
|
5
|
+
import { PDFDocument } from "pdf-lib";
|
|
6
|
+
import { bookDocument, bookPrintPart } from "@segnavia/html";
|
|
7
|
+
//#region src/book-pdf.ts
|
|
8
|
+
async function inspect(bytes) {
|
|
9
|
+
const document = await PDFDocument.load(bytes);
|
|
10
|
+
return {
|
|
11
|
+
bytes,
|
|
12
|
+
pages: document.getPageCount(),
|
|
13
|
+
destinations: namedDestinations(document)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function hashTargets(document) {
|
|
17
|
+
return [...new Set([...document.html.matchAll(/href="#([^"]+)"/g)].map((match) => match[1]))];
|
|
18
|
+
}
|
|
19
|
+
function requireTargets(part, expected) {
|
|
20
|
+
const missing = expected.filter((id) => !part.destinations.has(id));
|
|
21
|
+
if (missing.length > 0) throw new RenderRefusedError(`Chromium wrote ${expected.length - missing.length} of ${expected.length} named destinations for hidden targets; missing ${missing.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
const sectionsFor = (sections, part) => sections.filter((section) => bookPrintPart(section.kind) === part);
|
|
24
|
+
function planBlanks(sections, destinations) {
|
|
25
|
+
const ids = [];
|
|
26
|
+
const predicted = /* @__PURE__ */ new Map();
|
|
27
|
+
let blanks = 0;
|
|
28
|
+
for (const section of sectionsFor(sections, "body")) {
|
|
29
|
+
const original = destinations.get(section.id)?.page;
|
|
30
|
+
if (original === void 0) throw new RenderRefusedError(`The first body print has no destination for ${section.id}.`);
|
|
31
|
+
let page = original + blanks;
|
|
32
|
+
if (page % 2 === 0) {
|
|
33
|
+
ids.push(section.id);
|
|
34
|
+
blanks += 1;
|
|
35
|
+
page += 1;
|
|
36
|
+
}
|
|
37
|
+
predicted.set(section.id, page);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
ids,
|
|
41
|
+
predicted
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function verifyStarts(label, sections, expected, actual) {
|
|
45
|
+
for (const section of sections) {
|
|
46
|
+
const predicted = expected.get(section.id);
|
|
47
|
+
const found = actual.get(section.id)?.page;
|
|
48
|
+
if (predicted !== found) throw new RenderRefusedError(`${section.id} was predicted to start on ${label} page ${predicted}, but started on page ${found}.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function appendBlank(part) {
|
|
52
|
+
if (!part.bytes) return part;
|
|
53
|
+
const document = await PDFDocument.load(part.bytes);
|
|
54
|
+
const last = document.getPage(document.getPageCount() - 1);
|
|
55
|
+
document.addPage([last.getWidth(), last.getHeight()]);
|
|
56
|
+
return {
|
|
57
|
+
...part,
|
|
58
|
+
bytes: await document.save(),
|
|
59
|
+
pages: part.pages + 1
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function pageSide(book, physicalPage) {
|
|
63
|
+
const odd = resolveDirection(book.dir, book.lang, book.title) === "rtl" ? "left" : "right";
|
|
64
|
+
return physicalPage % 2 === 1 ? odd : odd === "left" ? "right" : "left";
|
|
65
|
+
}
|
|
66
|
+
function marginsFor(book, sections, destinations, pages, offset, blanks, system) {
|
|
67
|
+
const starts = sections.flatMap((section) => {
|
|
68
|
+
const page = destinations.get(section.id)?.page;
|
|
69
|
+
return page === void 0 ? [] : [{
|
|
70
|
+
page,
|
|
71
|
+
section
|
|
72
|
+
}];
|
|
73
|
+
}).sort((left, right) => left.page - right.page);
|
|
74
|
+
return Array.from({ length: pages }, (_, index) => {
|
|
75
|
+
const page = index + 1;
|
|
76
|
+
if (blanks.has(page)) return {
|
|
77
|
+
kind: "blank",
|
|
78
|
+
side: pageSide(book, offset + page)
|
|
79
|
+
};
|
|
80
|
+
const owner = starts.filter((start) => start.page <= page).at(-1);
|
|
81
|
+
return {
|
|
82
|
+
kind: owner?.section.kind ?? "blank",
|
|
83
|
+
side: pageSide(book, offset + page),
|
|
84
|
+
folio: formatNumber(page, system),
|
|
85
|
+
runningHead: owner?.page === page ? void 0 : owner?.section.runningHead
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function destinationOwner(id, destination, sections, destinations) {
|
|
90
|
+
const owner = sections.flatMap((section) => {
|
|
91
|
+
const start = destinations.get(section.id);
|
|
92
|
+
return start && start.page <= destination.page ? [{
|
|
93
|
+
section,
|
|
94
|
+
start
|
|
95
|
+
}] : [];
|
|
96
|
+
}).sort((left, right) => left.start.page - right.start.page).at(-1)?.section;
|
|
97
|
+
return owner ? `${owner.title ?? owner.id} (${owner.id})` : id;
|
|
98
|
+
}
|
|
99
|
+
function frontPrintStatus(prints, ids, sections) {
|
|
100
|
+
if (prints.length < 2) return { status: "more" };
|
|
101
|
+
const expected = prints.at(-2);
|
|
102
|
+
const actual = prints.at(-1);
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
const before = expected.get(id);
|
|
105
|
+
const after = actual.get(id);
|
|
106
|
+
if (!before || !after || before.page === after.page) continue;
|
|
107
|
+
if (prints.length < 3) return { status: "more" };
|
|
108
|
+
return {
|
|
109
|
+
status: "refused",
|
|
110
|
+
message: `Front destination ${id} in ${destinationOwner(id, before, sections, expected)} moved from page ${before.page} to page ${after.page} after three prints.`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { status: "stable" };
|
|
114
|
+
}
|
|
115
|
+
function finalNumbers(book, headings, front, body) {
|
|
116
|
+
return Object.fromEntries(headings.map((heading) => {
|
|
117
|
+
const frontPage = front.destinations.get(heading.id)?.page;
|
|
118
|
+
if (frontPage !== void 0) return [heading.id, formatNumber(frontPage, book.numbering.frontMatter)];
|
|
119
|
+
const bodyPage = body.destinations.get(heading.id)?.page;
|
|
120
|
+
if (bodyPage === void 0) throw new RenderRefusedError(`The heading ${heading.id} has no printed destination.`);
|
|
121
|
+
return [heading.id, formatNumber(bodyPage, book.numbering.body)];
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
async function printBody(book, options, adapter) {
|
|
125
|
+
const firstDocument = bookDocument(book, {
|
|
126
|
+
part: "body",
|
|
127
|
+
targets: true
|
|
128
|
+
});
|
|
129
|
+
if (firstDocument.issues.length > 0) throw new RenderRefusedError(`The book cannot be printed: ${firstDocument.issues.join("; ")}.`);
|
|
130
|
+
let part = await inspect(await adapter.print(firstDocument.html, book, options));
|
|
131
|
+
requireTargets(part, hashTargets(firstDocument));
|
|
132
|
+
const plan = book.rectoOpenings ? planBlanks(firstDocument.sections, part.destinations) : {
|
|
133
|
+
ids: [],
|
|
134
|
+
predicted: /* @__PURE__ */ new Map()
|
|
135
|
+
};
|
|
136
|
+
if (book.rectoOpenings) {
|
|
137
|
+
const finalDocument = bookDocument(book, {
|
|
138
|
+
part: "body",
|
|
139
|
+
targets: true,
|
|
140
|
+
blankBefore: plan.ids
|
|
141
|
+
});
|
|
142
|
+
part = await inspect(await adapter.print(finalDocument.html, book, options));
|
|
143
|
+
requireTargets(part, hashTargets(finalDocument));
|
|
144
|
+
verifyStarts("body", sectionsFor(firstDocument.sections, "body"), plan.predicted, part.destinations);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
document: firstDocument,
|
|
148
|
+
part,
|
|
149
|
+
blankBefore: plan.ids
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function printFront(book, source, body, options, adapter) {
|
|
153
|
+
const sections = sectionsFor(source.sections, "front");
|
|
154
|
+
if (sections.length === 0) return {
|
|
155
|
+
part: {
|
|
156
|
+
pages: 0,
|
|
157
|
+
destinations: /* @__PURE__ */ new Map()
|
|
158
|
+
},
|
|
159
|
+
trailingBlank: false
|
|
160
|
+
};
|
|
161
|
+
const ids = [];
|
|
162
|
+
const prints = [];
|
|
163
|
+
let numbers;
|
|
164
|
+
let final;
|
|
165
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
166
|
+
const document = bookDocument(book, {
|
|
167
|
+
part: "front",
|
|
168
|
+
targets: true,
|
|
169
|
+
crossLinks: true,
|
|
170
|
+
pageNumbers: numbers
|
|
171
|
+
});
|
|
172
|
+
const printed = await inspect(await adapter.print(document.html, book, options));
|
|
173
|
+
if (ids.length === 0) ids.push(...hashTargets(document));
|
|
174
|
+
requireTargets(printed, ids);
|
|
175
|
+
prints.push(printed);
|
|
176
|
+
const status = frontPrintStatus(prints.map((part) => part.destinations), ids, sections);
|
|
177
|
+
if (status.status === "stable") {
|
|
178
|
+
final = printed;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
if (status.status === "refused") throw new RenderRefusedError(status.message);
|
|
182
|
+
numbers = finalNumbers(book, source.headings, printed, body);
|
|
183
|
+
}
|
|
184
|
+
if (!final) throw new RenderRefusedError("The front print did not converge after three prints.");
|
|
185
|
+
const trailingBlank = book.rectoOpenings && final.pages % 2 === 1;
|
|
186
|
+
return {
|
|
187
|
+
part: trailingBlank ? await appendBlank(final) : final,
|
|
188
|
+
trailingBlank
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function join(front, body, marginBytes) {
|
|
192
|
+
const output = await PDFDocument.create();
|
|
193
|
+
for (const part of [front, body]) {
|
|
194
|
+
if (!part.bytes) continue;
|
|
195
|
+
const source = await PDFDocument.load(part.bytes);
|
|
196
|
+
for (const page of await output.copyPages(source, source.getPageIndices())) output.addPage(page);
|
|
197
|
+
}
|
|
198
|
+
const layer = await PDFDocument.load(marginBytes);
|
|
199
|
+
if (layer.getPageCount() !== output.getPageCount()) throw new RenderRefusedError(`The margin layer has ${layer.getPageCount()} pages, but the book has ${output.getPageCount()}.`);
|
|
200
|
+
for (const [index, page] of layer.getPages().entries()) {
|
|
201
|
+
const [embedded] = await output.embedPdf(layer, [index]);
|
|
202
|
+
if (embedded) output.getPage(index).drawPage(embedded, {
|
|
203
|
+
x: 0,
|
|
204
|
+
y: 0,
|
|
205
|
+
width: page.getWidth(),
|
|
206
|
+
height: page.getHeight()
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
rewriteBookLinks(output, front.pages, front.destinations, body.destinations);
|
|
210
|
+
return output.save();
|
|
211
|
+
}
|
|
212
|
+
async function renderBookPdf(input, options, adapter) {
|
|
213
|
+
const book = Book.parse(input);
|
|
214
|
+
if (book.mode === "paginated") throw new RenderRefusedError("bookPdf prints continuous books; printing a paginated book is not built yet.");
|
|
215
|
+
if (!book.page) throw new RenderRefusedError("Printing a continuous book needs page; set page before calling bookPdf.");
|
|
216
|
+
const printedBody = await printBody(book, options, adapter);
|
|
217
|
+
const printedFront = await printFront(book, printedBody.document, printedBody.part, options, adapter);
|
|
218
|
+
const front = printedFront.part;
|
|
219
|
+
const bodyBlankPages = new Set(printedBody.blankBefore.map((id) => (printedBody.part.destinations.get(id)?.page ?? 1) - 1));
|
|
220
|
+
const frontBlankPages = new Set(printedFront.trailingBlank ? [front.pages] : []);
|
|
221
|
+
const marginPages = [...marginsFor(book, sectionsFor(printedBody.document.sections, "front"), front.destinations, front.pages, 0, frontBlankPages, book.numbering.frontMatter), ...marginsFor(book, sectionsFor(printedBody.document.sections, "body"), printedBody.part.destinations, printedBody.part.pages, front.pages, bodyBlankPages, book.numbering.body)];
|
|
222
|
+
const marginBytes = await adapter.print(marginDocument(book, marginPages), book, options, true);
|
|
223
|
+
return adapter.finish(await join(front, printedBody.part, marginBytes), options);
|
|
224
|
+
}
|
|
225
|
+
function bookPageCss(book, marginLayer = false) {
|
|
226
|
+
if (!book.page) return "";
|
|
227
|
+
const size = sheetMm(book.page);
|
|
228
|
+
if (marginLayer) return `@page{size:${size.width}mm ${size.height}mm;margin:0}`;
|
|
229
|
+
const edge = book.page.margin;
|
|
230
|
+
return `@page{size:${size.width}mm ${size.height}mm;margin-top:${edge.top}mm;margin-bottom:${edge.bottom}mm}@page :right{margin-left:${edge.inner}mm;margin-right:${edge.outer}mm}@page :left{margin-left:${edge.outer}mm;margin-right:${edge.inner}mm}`;
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
export { bookPageCss, frontPrintStatus, renderBookPdf };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { marginCss } from "./book-margins.js";
|
|
2
|
+
import { RenderRefusedError } from "./errors.js";
|
|
3
|
+
import { bookPageCss, renderBookPdf } from "./book-pdf.js";
|
|
4
|
+
import { PX_PER_MM, resolveDirection, sheetMm } from "@segnavia/format";
|
|
5
|
+
import { frameDocument, newNonce } from "@segnavia/frame/bridge";
|
|
6
|
+
//#region src/book-render.ts
|
|
7
|
+
function renderBrowserBookPdf(input, options, dependencies) {
|
|
8
|
+
return renderBookPdf(input, options, {
|
|
9
|
+
print: (html, book, renderOptions, marginLayer = false) => {
|
|
10
|
+
if (!book.page) throw new RenderRefusedError("Printing a continuous book needs page; set page before calling bookPdf.");
|
|
11
|
+
const size = sheetMm(book.page);
|
|
12
|
+
const css = `${bookPageCss(book, marginLayer)}${marginLayer ? marginCss(book) : ""}${dependencies.fontFaces(renderOptions)}${renderOptions.css ?? ""}`;
|
|
13
|
+
return dependencies.render({
|
|
14
|
+
viewport: {
|
|
15
|
+
width: Math.round(size.width * PX_PER_MM),
|
|
16
|
+
height: Math.round(size.height * PX_PER_MM)
|
|
17
|
+
},
|
|
18
|
+
scripts: !marginLayer && book.scripts === "sandboxed",
|
|
19
|
+
html: frameDocument(html, {
|
|
20
|
+
scripts: marginLayer ? "refuse" : book.scripts,
|
|
21
|
+
nonce: newNonce(),
|
|
22
|
+
dir: marginLayer ? "ltr" : resolveDirection(book.dir, book.lang, book.title),
|
|
23
|
+
lang: book.lang,
|
|
24
|
+
css,
|
|
25
|
+
relayKeys: false,
|
|
26
|
+
reportHeight: false
|
|
27
|
+
}),
|
|
28
|
+
waitForReady: !marginLayer && book.scripts === "sandboxed" && renderOptions.waitForPageReady !== false,
|
|
29
|
+
boundMs: renderOptions.boundMs,
|
|
30
|
+
beforeOutput: renderOptions.beforeOutput
|
|
31
|
+
}, {
|
|
32
|
+
width: `${size.width}mm`,
|
|
33
|
+
height: `${size.height}mm`,
|
|
34
|
+
printBackground: true,
|
|
35
|
+
preferCSSPageSize: true,
|
|
36
|
+
margin: {
|
|
37
|
+
top: "0mm",
|
|
38
|
+
right: "0mm",
|
|
39
|
+
bottom: "0mm",
|
|
40
|
+
left: "0mm"
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
finish: dependencies.finish
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { renderBrowserBookPdf };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/browser-jpeg.ts
|
|
2
|
+
function browserJpegDecoder(tab) {
|
|
3
|
+
return async (bytes) => {
|
|
4
|
+
const encoded = Buffer.from(bytes).toString("base64");
|
|
5
|
+
const decoded = await tab.evaluate(async (base64) => {
|
|
6
|
+
const binary = atob(base64);
|
|
7
|
+
const source = Uint8Array.from(binary, (value) => value.charCodeAt(0));
|
|
8
|
+
const bitmap = await createImageBitmap(new Blob([source], { type: "image/jpeg" }), {
|
|
9
|
+
colorSpaceConversion: "none",
|
|
10
|
+
premultiplyAlpha: "none"
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
const context = new OffscreenCanvas(bitmap.width, bitmap.height).getContext("2d");
|
|
14
|
+
if (!context) throw new Error("Chromium could not create a 2D canvas for JPEG decoding");
|
|
15
|
+
context.drawImage(bitmap, 0, 0);
|
|
16
|
+
const rgba = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
|
17
|
+
const rgb = new Uint8Array(bitmap.width * bitmap.height * 3);
|
|
18
|
+
for (let pixel = 0; pixel < bitmap.width * bitmap.height; pixel++) {
|
|
19
|
+
rgb[pixel * 3] = rgba[pixel * 4] ?? 0;
|
|
20
|
+
rgb[pixel * 3 + 1] = rgba[pixel * 4 + 1] ?? 0;
|
|
21
|
+
rgb[pixel * 3 + 2] = rgba[pixel * 4 + 2] ?? 0;
|
|
22
|
+
}
|
|
23
|
+
let value = "";
|
|
24
|
+
for (let offset = 0; offset < rgb.length; offset += 32768) value += String.fromCharCode(...rgb.subarray(offset, Math.min(rgb.length, offset + 32768)));
|
|
25
|
+
return {
|
|
26
|
+
width: bitmap.width,
|
|
27
|
+
height: bitmap.height,
|
|
28
|
+
rgb: btoa(value)
|
|
29
|
+
};
|
|
30
|
+
} finally {
|
|
31
|
+
bitmap.close();
|
|
32
|
+
}
|
|
33
|
+
}, encoded);
|
|
34
|
+
return {
|
|
35
|
+
width: decoded.width,
|
|
36
|
+
height: decoded.height,
|
|
37
|
+
rgb: new Uint8Array(Buffer.from(decoded.rgb, "base64"))
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { browserJpegDecoder };
|
|
@@ -2,7 +2,7 @@ import { checked } from "./types.js";
|
|
|
2
2
|
import { D50, labToXyz, matrix3, tableDecode, tableEncode, tryInvert3, xyzToLab } from "./spaces.js";
|
|
3
3
|
//#region src/color/black-point.ts
|
|
4
4
|
function detectSourceBlackPoint(colorSpace, pcs, major, relativeA2b, perceptualB2a) {
|
|
5
|
-
if (colorSpace !== "CMYK") return [
|
|
5
|
+
if (colorSpace !== "CMYK" && colorSpace !== "GRAY") return [
|
|
6
6
|
0,
|
|
7
7
|
0,
|
|
8
8
|
0
|
|
@@ -19,11 +19,12 @@ function detectSourceBlackPoint(colorSpace, pcs, major, relativeA2b, perceptualB
|
|
|
19
19
|
checked(result, 2, "black-point A2B result")
|
|
20
20
|
]);
|
|
21
21
|
const lab = pcs === "Lab " ? returned : xyzToLab(returned);
|
|
22
|
-
|
|
22
|
+
const black = [
|
|
23
23
|
Math.min(50, lab[0]),
|
|
24
24
|
0,
|
|
25
25
|
0
|
|
26
26
|
];
|
|
27
|
+
return pcs === "Lab " ? black : labToXyz(black);
|
|
27
28
|
}
|
|
28
29
|
function quadraticRoot(x, y) {
|
|
29
30
|
if (x.length < 4) return 0;
|
|
@@ -61,14 +62,15 @@ function quadraticRoot(x, y) {
|
|
|
61
62
|
return discriminant <= 0 ? 0 : Math.max(0, Math.min(50, (-b + Math.sqrt(discriminant)) / (2 * a)));
|
|
62
63
|
}
|
|
63
64
|
function destinationBlackPoint(colorSpace, pcs, major, relativeA2b, relativeB2a, sourceBlack) {
|
|
64
|
-
if (colorSpace !== "CMYK") return [...sourceBlack];
|
|
65
|
+
if (colorSpace !== "CMYK" && colorSpace !== "GRAY") return [...sourceBlack];
|
|
66
|
+
const sourceLab = pcs === "Lab " ? sourceBlack : xyzToLab(sourceBlack);
|
|
65
67
|
const inputRamp = [], outputRamp = [];
|
|
66
68
|
for (let index = 0; index < 256; index++) {
|
|
67
69
|
const lightness = index * 100 / 255;
|
|
68
70
|
const lab = [
|
|
69
71
|
lightness,
|
|
70
|
-
Math.max(-50, Math.min(50,
|
|
71
|
-
Math.max(-50, Math.min(50,
|
|
72
|
+
Math.max(-50, Math.min(50, sourceLab[1])),
|
|
73
|
+
Math.max(-50, Math.min(50, sourceLab[2]))
|
|
72
74
|
];
|
|
73
75
|
const value = pcs === "Lab " ? lab : labToXyz(lab);
|
|
74
76
|
const device = relativeB2a.run(tableEncode(pcs, relativeB2a.encoding, major, value, false));
|
|
@@ -97,11 +99,12 @@ function destinationBlackPoint(colorSpace, pcs, major, relativeA2b, relativeB2a,
|
|
|
97
99
|
y.push(normalized);
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
|
-
|
|
102
|
+
const result = [
|
|
101
103
|
x.length < 3 ? 0 : quadraticRoot(x, y),
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
sourceLab[1],
|
|
105
|
+
sourceLab[2]
|
|
104
106
|
];
|
|
107
|
+
return pcs === "Lab " ? result : labToXyz(result);
|
|
105
108
|
}
|
|
106
109
|
function compensateBlackPoint(profile, lab, reverse) {
|
|
107
110
|
const point = reverse ? profile.sourceBlackPoint : profile.destinationBlackPoint;
|
package/dist/color/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clamp, color4, refuse } from "./types.js";
|
|
1
|
+
import { clamp, color1, color4, refuse } from "./types.js";
|
|
2
2
|
import { labToXyz, srgbToLab, tableEncode } from "./spaces.js";
|
|
3
3
|
import { compensateBlackPoint } from "./black-point.js";
|
|
4
4
|
//#region src/color/engine.ts
|
|
@@ -14,5 +14,12 @@ function toPress(profile, rgb) {
|
|
|
14
14
|
clamp(black)
|
|
15
15
|
];
|
|
16
16
|
}
|
|
17
|
+
function toGray(profile, rgb) {
|
|
18
|
+
if (profile.colorSpace !== "GRAY") refuse(`destination color space ${profile.colorSpace}`, "GRAY");
|
|
19
|
+
const lab = compensateBlackPoint(profile, srgbToLab(rgb), false);
|
|
20
|
+
const pcs = profile.pcs === "Lab " ? lab : labToXyz(lab);
|
|
21
|
+
const [gray] = color1(profile.b2a.run(tableEncode(profile.pcs, profile.b2a.encoding, profile.majorVersion, pcs, true)), "B2A result");
|
|
22
|
+
return [clamp(gray)];
|
|
23
|
+
}
|
|
17
24
|
//#endregion
|
|
18
|
-
export { toPress };
|
|
25
|
+
export { toGray, toPress };
|
|
@@ -34,6 +34,16 @@ function multilinear(grid, points, outputs, input) {
|
|
|
34
34
|
}
|
|
35
35
|
return result;
|
|
36
36
|
}
|
|
37
|
+
function linearGrid(grid, points, outputs, input) {
|
|
38
|
+
const count = checked(points, 0, "CLUT dimensions");
|
|
39
|
+
const position = clamp(checked(input, 0, "CLUT input")) * (count - 1);
|
|
40
|
+
const low = Math.min(count - 2, Math.floor(position));
|
|
41
|
+
const fraction = position - low;
|
|
42
|
+
return Array.from({ length: outputs }, (_, channel) => {
|
|
43
|
+
const lower = grid.get(low * outputs + channel);
|
|
44
|
+
return lower + (grid.get((low + 1) * outputs + channel) - lower) * fraction;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
function tetrahedral4(grid, points, outputs, input) {
|
|
38
48
|
const { base, fractions, stride } = layout(points, outputs, input);
|
|
39
49
|
const order = [
|
|
@@ -71,4 +81,4 @@ function matrixInput(values) {
|
|
|
71
81
|
];
|
|
72
82
|
}
|
|
73
83
|
//#endregion
|
|
74
|
-
export { interpolate, matrixInput, multilinear, tetrahedral4 };
|
|
84
|
+
export { interpolate, linearGrid, matrixInput, multilinear, tetrahedral4 };
|
package/dist/color/profile.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { REVERSE_LUT, checked, refuse } from "./types.js";
|
|
2
2
|
import { destinationBlackPoint, detectSourceBlackPoint } from "./black-point.js";
|
|
3
3
|
import { fixed, need, signature, u32 } from "./binary.js";
|
|
4
|
-
import { matrixTables, parseMab, parseMft } from "./tables.js";
|
|
4
|
+
import { grayTables, matrixTables, parseMab, parseMft } from "./tables.js";
|
|
5
5
|
//#region src/color/profile.ts
|
|
6
6
|
const MAX_PROFILE_BYTES = 33554432;
|
|
7
7
|
function header(bytes, view, bounds) {
|
|
@@ -12,7 +12,7 @@ function header(bytes, view, bounds) {
|
|
|
12
12
|
const profileClass = signature(bytes, 12, bounds);
|
|
13
13
|
if (profileClass !== "prtr" && profileClass !== "mntr") refuse(`profile class ${JSON.stringify(profileClass)}`, "prtr or mntr");
|
|
14
14
|
const colorSpace = signature(bytes, 16, bounds);
|
|
15
|
-
if (colorSpace !== "CMYK" && colorSpace !== "RGB ") refuse(`color space ${JSON.stringify(colorSpace)}`, "CMYK or RGB");
|
|
15
|
+
if (colorSpace !== "CMYK" && colorSpace !== "GRAY" && colorSpace !== "RGB ") refuse(`color space ${JSON.stringify(colorSpace)}`, "CMYK, GRAY, or RGB");
|
|
16
16
|
const pcs = signature(bytes, 20, bounds);
|
|
17
17
|
if (pcs !== "Lab " && pcs !== "XYZ ") refuse(`PCS ${JSON.stringify(pcs)}`, "Lab or XYZ");
|
|
18
18
|
u32(view, 0, bounds);
|
|
@@ -55,6 +55,13 @@ function table(bytes, view, all, names, inputs, outputs) {
|
|
|
55
55
|
if (!record) throw new Error(`ICC profile is missing ${name} after its tag set was validated`);
|
|
56
56
|
return recordTable(bytes, view, record, inputs, outputs);
|
|
57
57
|
}
|
|
58
|
+
function optionalTable(bytes, view, all, names, inputs, outputs) {
|
|
59
|
+
const name = names.find((candidate) => all[candidate]);
|
|
60
|
+
if (!name) return void 0;
|
|
61
|
+
const record = all[name];
|
|
62
|
+
if (!record) throw new Error(`ICC profile is missing ${name} after its tag set was validated`);
|
|
63
|
+
return recordTable(bytes, view, record, inputs, outputs);
|
|
64
|
+
}
|
|
58
65
|
function readProfile(bytes) {
|
|
59
66
|
if (bytes.length > 33554432) refuse(`profile length ${bytes.length}`, `at most ${MAX_PROFILE_BYTES} bytes`);
|
|
60
67
|
const bounds = {
|
|
@@ -63,12 +70,15 @@ function readProfile(bytes) {
|
|
|
63
70
|
}, view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
64
71
|
const parsed = header(bytes, view, bounds), all = records(bytes, view, bounds);
|
|
65
72
|
if (all.wtpt?.type !== "XYZ ") refuse(`white point tag type ${JSON.stringify(all.wtpt?.type ?? "missing")}`, "XYZ ");
|
|
66
|
-
const channels = parsed.colorSpace === "CMYK" ? 4 : 3;
|
|
73
|
+
const channels = parsed.colorSpace === "CMYK" ? 4 : parsed.colorSpace === "GRAY" ? 1 : 3;
|
|
67
74
|
const matrix = parsed.colorSpace === "RGB " ? matrixTables(bytes, view, all) : void 0;
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
75
|
+
const trcRecord = all.kTRC;
|
|
76
|
+
const gray = parsed.colorSpace === "GRAY" && trcRecord ? grayTables(bytes, view, trcRecord, parsed.pcs) : void 0;
|
|
77
|
+
const select = (names, inputs, outputs, fallback) => optionalTable(bytes, view, all, names, inputs, outputs) ?? fallback ?? table(bytes, view, all, names, inputs, outputs);
|
|
78
|
+
const a2b = matrix?.a2b ?? select(["A2B1", "A2B0"], channels, 3, gray?.a2b);
|
|
79
|
+
const b2a = matrix?.b2a ?? select(["B2A1", "B2A0"], 3, channels, gray?.b2a);
|
|
80
|
+
const perceptualA2b = matrix?.a2b ?? select(["A2B0", "A2B1"], channels, 3, gray?.a2b);
|
|
81
|
+
const perceptualB2a = matrix?.b2a ?? select(["B2A0", "B2A1"], 3, channels, gray?.b2a);
|
|
72
82
|
const gamutRecord = all.gamt, gamut = gamutRecord ? recordTable(bytes, view, gamutRecord, 3, 1) : void 0;
|
|
73
83
|
const white = all.wtpt;
|
|
74
84
|
if (!white) throw new Error("ICC profile is missing wtpt after its tag set was validated");
|