/* Mixed-format PDF writer. PDFKit creates new searchable pages; pdf-lib copies
existing PDF pages into their exact position in the final queue. */
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.PdfBuilder = factory();
})(typeof self !== "undefined" ? self : globalThis, function () {
"use strict";
const FONT_FILES = {
NotoSans: "vendor/fonts/noto-sans-regular.ttf",
NotoSansBold: "vendor/fonts/noto-sans-bold.ttf",
NotoSansItalic: "vendor/fonts/noto-sans-italic.ttf",
NotoSansBoldItalic: "vendor/fonts/noto-sans-bolditalic.ttf",
NotoArabic: "vendor/fonts/noto-arabic-regular.ttf",
NotoArabicBold: "vendor/fonts/noto-arabic-bold.ttf",
NotoHebrew: "vendor/fonts/noto-hebrew-regular.ttf",
NotoHebrewBold: "vendor/fonts/noto-hebrew-bold.ttf",
NotoDevanagari: "vendor/fonts/noto-devanagari-regular.ttf",
NotoDevanagariBold: "vendor/fonts/noto-devanagari-bold.ttf",
NotoCJK: "vendor/fonts/noto-cjk-regular.ttc",
};
const FONT_CACHE = new Map();
const INK = "#101113";
const MUTED = "#666b72";
const LINE = "#dfe2e6";
const SURFACE = "#f5f6f7";
const ACCENT = "#f4402d";
const RTL_RE = /[\u0590-\u08ff\ufb1d-\ufefc]/;
async function loadFont(name) {
if (!FONT_CACHE.has(name)) {
FONT_CACHE.set(
name,
fetch(FONT_FILES[name]).then((response) => {
if (!response.ok) throw new Error(`Could not load the offline font ${name}.`);
return response.arrayBuffer();
})
);
}
return FONT_CACHE.get(name);
}
function scriptsIn(text) {
const value = String(text || "");
return {
arabic: /[\u0600-\u08ff\ufb50-\ufefc]/.test(value),
hebrew: /[\u0590-\u05ff\ufb1d-\ufb4f]/.test(value),
devanagari: /[\u0900-\u097f]/.test(value),
japanese: /[\u3040-\u30ff]/.test(value),
korean: /[\uac00-\ud7af\u1100-\u11ff]/.test(value),
han: /[\u3400-\u9fff\uf900-\ufaff]/.test(value),
};
}
function collectText(items) {
const parts = [];
for (const item of items) {
parts.push(item.file?.name || "");
for (const block of item.blocks || []) {
parts.push(block.text || "");
parts.push((block.spans || []).map((span) => span.text).join(""));
parts.push((block.items || []).join(" "));
for (const row of block.rows || []) parts.push(row.map((cell) => cell.text || cell).join(" "));
}
for (const sheet of item.sheets || []) {
parts.push(sheet.name);
for (const row of sheet.rows || []) parts.push(row.join(" "));
}
}
return parts.join("\n");
}
async function registerFonts(doc, items) {
const text = collectText(items);
const scripts = scriptsIn(text);
const names = ["NotoSans", "NotoSansBold", "NotoSansItalic", "NotoSansBoldItalic"];
if (scripts.arabic) names.push("NotoArabic", "NotoArabicBold");
if (scripts.hebrew) names.push("NotoHebrew", "NotoHebrewBold");
if (scripts.devanagari) names.push("NotoDevanagari", "NotoDevanagariBold");
if (scripts.japanese || scripts.korean || scripts.han) names.push("NotoCJK");
const loaded = await Promise.all(names.map(async (name) => [name, await loadFont(name)]));
for (const [name, buffer] of loaded) {
const bytes = new Uint8Array(buffer);
if (name === "NotoCJK") {
doc.registerFont("NotoCJKSC", bytes, "NotoSansCJKsc-Regular");
doc.registerFont("NotoCJKTC", bytes, "NotoSansCJKtc-Regular");
doc.registerFont("NotoCJKJP", bytes, "NotoSansCJKjp-Regular");
doc.registerFont("NotoCJKKR", bytes, "NotoSansCJKkr-Regular");
} else {
doc.registerFont(name, bytes);
}
}
}
function fontFor(text, bold, italic) {
const scripts = scriptsIn(text);
if (scripts.arabic) return bold ? "NotoArabicBold" : "NotoArabic";
if (scripts.hebrew) return bold ? "NotoHebrewBold" : "NotoHebrew";
if (scripts.devanagari) return bold ? "NotoDevanagariBold" : "NotoDevanagari";
if (scripts.japanese) return "NotoCJKJP";
if (scripts.korean) return "NotoCJKKR";
if (scripts.han) return "NotoCJKSC";
if (bold && italic) return "NotoSansBoldItalic";
if (bold) return "NotoSansBold";
if (italic) return "NotoSansItalic";
return "NotoSans";
}
function visualRtl(text) {
if (!RTL_RE.test(text) || typeof bidi_js !== "function") return text;
const bidi = bidi_js();
const levels = bidi.getEmbeddingLevels(text);
const chars = Array.from(text);
for (const [start, end] of bidi.getReorderSegments(text, levels)) {
const reversed = chars.slice(start, end + 1).reverse();
chars.splice(start, reversed.length, ...reversed);
}
const mirrored = bidi.getMirroredCharactersMap(text, levels);
mirrored.forEach((replacement, index) => {
if (index < chars.length) chars[index] = replacement;
});
return chars.join("");
}
function addLogicalText(doc, logical, options) {
const rtl = RTL_RE.test(logical);
if (rtl) doc.markContent("Span", { actual: logical });
doc.text(rtl ? visualRtl(logical) : logical, { ...(options || {}), align: rtl ? "right" : options?.align });
if (rtl) doc.endMarkedContent();
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(`${file.name} could not be read.`));
reader.readAsDataURL(file);
});
}
function canvasDataUrl(bitmap, orientation) {
const turn = orientation >= 5 && orientation <= 8;
const canvas = document.createElement("canvas");
canvas.width = turn ? bitmap.height : bitmap.width;
canvas.height = turn ? bitmap.width : bitmap.height;
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
const transforms = {
2: [-1, 0, 0, 1, bitmap.width, 0],
3: [-1, 0, 0, -1, bitmap.width, bitmap.height],
4: [1, 0, 0, -1, 0, bitmap.height],
5: [0, 1, 1, 0, 0, 0],
6: [0, 1, -1, 0, bitmap.height, 0],
7: [0, -1, -1, 0, bitmap.height, bitmap.width],
8: [0, -1, 1, 0, 0, bitmap.width],
};
if (transforms[orientation]) context.setTransform(...transforms[orientation]);
context.drawImage(bitmap, 0, 0);
return canvas.toDataURL("image/png");
}
async function decodeTiff(buffer, name) {
try {
const ifds = UTIF.decode(buffer);
if (!ifds.length) throw new Error("no pages");
UTIF.decodeImage(buffer, ifds[0], ifds);
const rgba = UTIF.toRGBA8(ifds[0]);
const canvas = document.createElement("canvas");
canvas.width = ifds[0].width;
canvas.height = ifds[0].height;
canvas.getContext("2d").putImageData(
new ImageData(new Uint8ClampedArray(rgba), ifds[0].width, ifds[0].height),
0,
0
);
return createImageBitmap(canvas);
} catch {
throw new Error(`${name} is a TIFF this browser couldn't read.`);
}
}
async function prepareImage(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const metadata = Pages.parseRasterMetadata(bytes);
const tiff = Pages.isTiff(bytes);
let bitmap;
try {
bitmap = tiff
? await decodeTiff(buffer, file.name)
: await createImageBitmap(file, { imageOrientation: "from-image" });
} catch {
throw new Error(`${file.name} isn't an image this browser can read.`);
}
const page = Pages.imagePage(
metadata.width || bitmap.width,
metadata.height || bitmap.height,
metadata.dpiX,
metadata.dpiY,
metadata.orientation || 1
);
const simpleJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && (metadata.orientation || 1) === 1;
const simplePng = bytes.length > 8 && String.fromCharCode(...bytes.slice(1, 4)) === "PNG";
const src = simpleJpeg || simplePng
? await fileToDataUrl(file)
: canvasDataUrl(bitmap, tiff ? metadata.orientation || 1 : 1);
bitmap.close();
return { src, page };
}
function addImagePage(doc, image) {
doc.addPage({ size: [image.page.width, image.page.height], margin: 0 });
doc.image(image.src, 0, 0, {
width: image.page.width,
height: image.page.height,
ignoreOrientation: true,
});
}
function addTextPage(doc) {
doc.addPage({ size: "A4", layout: "portrait", margin: 54 });
doc.font("NotoSans").fontSize(11).fillColor(INK);
}
function renderSpans(doc, spans, options) {
const logical = spans.map((span) => span.text).join("");
if (RTL_RE.test(logical)) {
doc.font(fontFor(logical, spans.some((span) => span.bold), false));
addLogicalText(doc, logical, options);
return;
}
const usable = spans.filter((span) => span.text);
usable.forEach((span, index) => {
doc.font(span.code ? "NotoSans" : fontFor(span.text, span.bold, span.italic));
doc.fillColor(span.link ? ACCENT : options?.color || INK);
doc.text(span.text, {
continued: index !== usable.length - 1,
link: span.link || undefined,
underline: Boolean(span.link),
strike: Boolean(span.strike),
lineGap: options?.lineGap,
});
});
doc.fillColor(INK);
}
function renderTable(doc, rows, widths) {
if (!rows.length) return;
const data = rows.map((row, rowIndex) =>
row.map((raw) => {
const cell = typeof raw === "object" && raw !== null ? raw : { text: String(raw ?? "") };
const header = rowIndex === 0 || cell.header;
return {
text: cell.text || "",
colSpan: cell.colSpan || 1,
rowSpan: cell.rowSpan || 1,
type: header ? "TH" : "TD",
scope: header ? "Column" : undefined,
backgroundColor: header ? "#eceef1" : undefined,
textColor: INK,
borderColor: LINE,
padding: 4,
font: { src: fontFor(cell.text || "", header, false), size: 8 },
textOptions: { lineGap: 1 },
};
})
);
doc.table({
maxWidth: doc.page.contentWidth,
columnStyles: widths || Array.from({ length: Math.max(...rows.map((row) => row.length)) }, () => "*"),
defaultStyle: { border: 0.5, borderColor: LINE, padding: 4 },
data,
});
doc.moveDown(0.75);
}
function renderBlocks(doc, blocks) {
for (const block of blocks) {
if (block.type === "heading") {
const sizes = [0, 25, 20, 16, 14, 12, 11];
const text = block.spans.map((span) => span.text).join("");
doc.moveDown(block.level === 1 ? 0.3 : 0.55);
doc.font(fontFor(text, true, false)).fontSize(sizes[block.level] || 12).fillColor(INK);
addLogicalText(doc, text, { lineGap: 2 });
doc.moveDown(0.25);
} else if (block.type === "paragraph") {
doc.fontSize(11).fillColor(INK);
renderSpans(doc, block.spans, { lineGap: 3 });
doc.moveDown(0.55);
} else if (block.type === "list") {
const joined = block.items.join(" ");
doc.font(fontFor(joined, false, false)).fontSize(11).fillColor(INK);
doc.list(block.items, {
listType: block.ordered ? "numbered" : "bullet",
indent: 18,
textIndent: 10,
bulletIndent: 2,
lineGap: 3,
});
doc.moveDown(0.55);
} else if (block.type === "quote") {
const x = doc.x;
doc.save().strokeColor(ACCENT).lineWidth(2).moveTo(x, doc.y).lineTo(x, doc.y + 34).stroke().restore();
doc.x += 14;
doc.font(fontFor(block.text, false, true)).fontSize(10.5).fillColor(MUTED);
addLogicalText(doc, block.text, { lineGap: 3 });
doc.x = x;
doc.fillColor(INK).moveDown(0.65);
} else if (block.type === "code") {
const available = doc.page.contentWidth;
doc.font(/[^ -\u007f]/.test(block.text) ? "NotoSans" : "Courier").fontSize(9);
const height = Math.min(doc.heightOfString(block.text, { width: available - 20 }) + 16, 500);
if (doc.y + height > doc.page.height - doc.page.margins.bottom) doc.addPage();
const top = doc.y;
doc.save().fillColor(SURFACE).roundedRect(doc.x, top, available, height, 5).fill().restore();
doc.fillColor(INK).text(block.text, doc.x + 10, top + 8, { width: available - 20, lineGap: 2 });
doc.y = top + height + 8;
} else if (block.type === "rule") {
doc.moveDown(0.4);
doc.save().strokeColor(LINE).lineWidth(1).moveTo(doc.x, doc.y).lineTo(doc.x + doc.page.contentWidth, doc.y).stroke().restore();
doc.moveDown(0.7);
} else if (block.type === "table") {
renderTable(doc, block.rows);
} else if (block.type === "image") {
try {
doc.image(block.src, { fit: [doc.page.contentWidth, 430], align: "center" });
doc.moveDown(0.65);
} catch {
doc.font("NotoSansItalic").fontSize(10).fillColor(MUTED).text(block.alt || "Image");
doc.moveDown(0.5);
}
}
}
}
function columnGroups(sheet, availableWidth) {
const columns = sheet.rows.reduce((max, row) => Math.max(max, row.length), 0);
const widths = Array.from({ length: columns }, (_, column) => {
if (sheet.widths?.[column]) return Math.max(42, Math.min(160, sheet.widths[column]));
const longest = sheet.rows.slice(0, 250).reduce(
(max, row) => Math.max(max, String(row[column] || "").length),
0
);
return Math.max(42, Math.min(160, longest * 4.5 + 12));
});
const groups = [];
let group = [];
let used = 0;
widths.forEach((width, index) => {
if (group.length && used + width > availableWidth) {
groups.push(group);
group = [];
used = 0;
}
group.push({ index, width: Math.min(width, availableWidth) });
used += width;
});
if (group.length) groups.push(group);
return groups;
}
function columnName(index) {
let value = index + 1;
let result = "";
while (value) {
value -= 1;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
function sheetRows(sheet, group) {
const mergeAt = new Map();
const covered = new Set();
for (const merge of sheet.merges || []) {
const groupStart = group[0].index;
const groupEnd = group[group.length - 1].index;
const mergeEnd = merge.startColumn + merge.colSpan - 1;
if (merge.startColumn < groupStart || mergeEnd > groupEnd) continue;
mergeAt.set(`${merge.startRow},${merge.startColumn}`, merge);
for (let row = merge.startRow; row < merge.startRow + merge.rowSpan; row += 1) {
for (let column = merge.startColumn; column <= mergeEnd; column += 1) {
if (row !== merge.startRow || column !== merge.startColumn) covered.add(`${row},${column}`);
}
}
}
return sheet.rows.map((row, rowIndex) => {
const output = [];
for (const { index } of group) {
if (covered.has(`${rowIndex},${index}`)) continue;
const merge = mergeAt.get(`${rowIndex},${index}`);
output.push({
text: String(row[index] || ""),
header: rowIndex === 0,
colSpan: merge?.colSpan || 1,
rowSpan: merge?.rowSpan || 1,
});
}
return output;
});
}
function sheetTitle(doc, title, continued) {
doc.font(fontFor(title, true, false)).fontSize(16).fillColor(INK)
.text(title + (continued ? " · continued" : ""));
doc.moveDown(0.55).fontSize(8).font("NotoSans");
}
function estimatedRowHeight(doc, row, widths) {
let height = 0;
row.forEach((cell, index) => {
const text = typeof cell === "object" ? cell.text : String(cell || "");
doc.font(fontFor(text, Boolean(cell?.header), false)).fontSize(8);
height = Math.max(height, doc.heightOfString(text || " ", {
width: Math.max(8, (widths[index] || 42) - 8),
lineGap: 1,
}) + 8);
});
return Math.min(500, Math.max(17, height));
}
function renderPaginatedSheet(doc, title, rows, widths) {
const header = rows[0] || [];
let pageRows = [header];
let continued = false;
function newPage() {
doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
sheetTitle(doc, title, continued);
continued = true;
pageRows = [header];
}
newPage();
let used = estimatedRowHeight(doc, header, widths);
for (const row of rows.slice(1)) {
const height = estimatedRowHeight(doc, row, widths);
const available = doc.page.height - doc.page.margins.bottom - doc.y;
if (pageRows.length > 1 && used + height > available) {
renderTable(doc, pageRows, widths);
newPage();
used = estimatedRowHeight(doc, header, widths);
}
pageRows.push(row);
used += height;
}
renderTable(doc, pageRows, widths);
}
function renderSheets(doc, sheets) {
for (const sheet of sheets) {
const available = Pages.A4_PORTRAIT[1] - 60;
const groups = columnGroups(sheet, available);
if (!groups.length) {
doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
doc.font("NotoSansBold").fontSize(17).fillColor(INK).text(sheet.name);
doc.moveDown(0.6).font("NotoSans").fontSize(10).fillColor(MUTED).text("This sheet is empty.");
continue;
}
groups.forEach((group, groupIndex) => {
const suffix = groups.length > 1
? ` · columns ${columnName(group[0].index)}–${columnName(group[group.length - 1].index)}`
: "";
renderPaginatedSheet(
doc,
sheet.name + suffix,
sheetRows(sheet, group),
group.map((column) => column.width)
);
if (groupIndex < groups.length - 1) doc.moveDown(0.1);
});
}
}
async function buildConverted(items, onProgress) {
if (typeof PDFDocument !== "function" || typeof blobStream !== "function") {
throw new Error("The offline PDF engine did not load. Refresh and try again.");
}
const doc = new PDFDocument({
autoFirstPage: false,
bufferPages: true,
compress: true,
tagged: true,
pdfVersion: "1.5",
info: { Title: "MakeItPDF conversion", Creator: "MakeItPDF" },
});
const stream = doc.pipe(blobStream());
await registerFonts(doc, items);
const warnings = [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
onProgress?.(index, items.length, `Converting ${item.file.name}`);
if (item.kind === "image") addImagePage(doc, await prepareImage(item.file));
else if (item.kind === "xlsx" || item.kind === "csv") renderSheets(doc, item.sheets);
else {
addTextPage(doc);
renderBlocks(doc, item.blocks);
}
warnings.push(...(item.warnings || []).map((warning) => `${item.file.name}: ${warning}`));
await new Promise((resolve) => setTimeout(resolve, 0));
}
const pageCount = doc.bufferedPageRange().count;
onProgress?.(items.length, items.length, "Finishing PDF");
doc.end();
const blob = await new Promise((resolve, reject) => {
stream.on("finish", () => resolve(stream.toBlob("application/pdf")));
stream.on("error", reject);
});
return { blob, pageCount, warnings };
}
function pdfLoadError(name, error) {
if (/encrypt|password/i.test(String(error?.message || error))) {
return new Error(`${name} is password-protected. Remove its password and try again.`);
}
return new Error(`${name} isn't a valid PDF or uses PDF features this browser couldn't read.`);
}
async function appendPdf(merged, engine, bytes, name) {
let source;
try {
source = await engine.PDFDocument.load(bytes, { updateMetadata: false });
if (!source.getPageCount()) throw new Error("PDF has no pages");
const pages = await merged.copyPages(source, source.getPageIndices());
pages.forEach((page) => merged.addPage(page));
} catch (error) {
throw pdfLoadError(name, error);
}
}
async function build(items, onProgress) {
if (!items.some((item) => item.kind === "pdf")) return buildConverted(items, onProgress);
const engine = globalThis.PDFLib;
if (!engine?.PDFDocument) {
throw new Error("The offline PDF combining engine did not load. Refresh and try again.");
}
const merged = await engine.PDFDocument.create();
const warnings = [];
let index = 0;
while (index < items.length) {
const item = items[index];
if (item.kind === "pdf") {
onProgress?.(index, items.length, `Adding ${item.file.name}`);
let bytes;
try {
bytes = await item.file.arrayBuffer();
} catch {
throw new Error(`${item.file.name} could not be read.`);
}
await appendPdf(merged, engine, bytes, item.file.name);
index += 1;
await new Promise((resolve) => setTimeout(resolve, 0));
continue;
}
const start = index;
const run = [];
while (index < items.length && items[index].kind !== "pdf") run.push(items[index++]);
const converted = await buildConverted(run, (current, total, label) => {
onProgress?.(start + Math.min(current, total), items.length, label);
});
warnings.push(...converted.warnings);
await appendPdf(
merged,
engine,
await converted.blob.arrayBuffer(),
"The newly converted section"
);
}
onProgress?.(items.length, items.length, "Finishing PDF");
merged.setTitle("MakeItPDF combined document");
merged.setCreator("MakeItPDF");
merged.setProducer("MakeItPDF");
const bytes = await merged.save();
return {
blob: new Blob([bytes], { type: "application/pdf" }),
pageCount: merged.getPageCount(),
warnings,
};
}
return { build, scriptsIn, visualRtl, columnGroups };
});