0 directories, 46 files

util

Home / tinai / util
/**
 * PDF export for documents and scratchpads: renders typed content blocks
 * (text, header, list-step, link, code, quote, table, equation, color swatch,
 * diagram image) into a paginated US Letter PDF with 1-inch margins using
 * pdf-lib, loaded on demand from a CDN. Inline formatting (b, i, u, strong,
 * em, code, sup, sub, a) is preserved; external links become clickable URI
 * annotations and "#name" anchor links jump to the named header.
 * Tables are drawn as bordered grids and diagrams as embedded PNG images.
 * Headers are sized by their level (index 1-6, default 1). Characters outside
 * the PDF standard fonts' WinAnsi encoding are mapped to ASCII equivalents
 * where possible and replaced with "?" otherwise. Equations are exported as
 * their TeX source in monospace.
 */

import { headerLevel } from './format-utils.js';
import { download_file } from './dom-utils.js';
import { sanitize_filename } from './export-utils.js';

const PDF_LIB_URL = 'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/+esm';
const PAGE_WIDTH = 612;
const PAGE_HEIGHT = 792;
const MARGIN = 72;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
const BODY_SIZE = 11;
const BODY_LINE = 15.5;
const HEADER_SIZES = { 1: 20, 2: 17, 3: 15, 4: 13, 5: 12, 6: 11 };
const LIST_INDENT = 20;
const QUOTE_INDENT = 20;
const CODE_SIZE = 10;
const CODE_LINE = 14;
const TABLE_SIZE = 9.5;
const TABLE_LINE = 13;
const TABLE_PAD = 4;
const CAPTION_SIZE = 9.5;
const CAPTION_LINE = 13;

let pdf_lib_promise = null;

/**
 * Character substitutions applied before stripping unencodable characters:
 * arrows and math symbols with no WinAnsi glyph get an ASCII equivalent.
 */
const CHAR_MAP = {
	'\u2190': '<-', '\u2192': '->', '\u2194': '<->', '\u21D0': '<=', '\u21D2': '=>', '\u21D4': '<=>',
	'\u2248': '~', '\u2260': '!=', '\u2264': '<=', '\u2265': '>=', '\u00B1': '+/-', '\u2212': '-', '\u221E': 'oo'
};
// WinAnsi (the standard fonts' encoding) allowlist: ASCII, Latin-1, and the
// punctuation pdf-lib maps into WinAnsi.
const WINANSI_RE = /[^\u0020-\u007E\u00A0-\u00FF\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC]/g;

/**
 * Sanitizes text for the standard PDF fonts.
 * @param {string} text - Raw text from a document value.
 * @returns {string} Text encodable as WinAnsi.
 */
function sanitize(text) {
	let mapped = '';
	for (const ch of text) {
		mapped += CHAR_MAP[ch] ?? ch;
	}
	return mapped.replace(WINANSI_RE, '?');
}

/**
 * Parses a block's inline HTML into a flat list of styled text runs.
 * @param {string} html - The block value (inline markup).
 * @param {string|null} href - Forced link target for link blocks.
 * @returns {Array<Object>} Runs of { text, style: { bold, italic, underline, mono, sup, sub, href } }.
 */
function parseRuns(html, href = null) {
	const tags = {
		B: { bold: true }, STRONG: { bold: true },
		I: { italic: true }, EM: { italic: true },
		U: { underline: true }, CODE: { mono: true },
		SUP: { sup: true }, SUB: { sub: true },
		A: { underline: true, href: null }
	};
	const runs = [];
	const parsed = new DOMParser().parseFromString(`<div>${html || ''}</div>`, 'text/html');
	const walk = (node, style) => {
		for (const child of node.childNodes) {
			if (child.nodeType === Node.TEXT_NODE) {
				if (child.textContent) runs.push({ text: child.textContent, style: { ...style } });
			} else if (child.nodeType === Node.ELEMENT_NODE) {
				const extra = tags[child.tagName];
				const next = extra ? { ...style, ...extra, href: child.tagName === 'A' ? (child.getAttribute('href') || style.href) : style.href } : style;
				walk(child, next);
			}
		}
	};
	walk(parsed.body.firstChild, { bold: false, italic: false, underline: false, mono: false, sup: false, sub: false, href });
	return runs;
}

/**
 * Builds a single styled run for plain (non-HTML) text such as code or TeX.
 * @param {string} text - The raw text.
 * @param {Object} [flags={}] - Style flags to set beyond the defaults.
 * @returns {Array<Object>} Runs in parseRuns' shape.
 */
function plainRuns(text, flags = {}) {
	return [{
		text: text.length ? text : ' ',
		style: { bold: false, italic: false, underline: false, mono: false, sup: false, sub: false, href: null, ...flags }
	}];
}

/**
 * Splits runs into whitespace and word pieces, so adjacency across inline
 * style boundaries (e.g. "</b>,") is preserved on the line.
 * @param {Array<Object>} runs - Styled runs from parseRuns.
 * @returns {Array<Object>} Pieces with their style; whitespace pieces flagged.
 */
function tokenize(runs) {
	const tokens = [];
	for (const run of runs) {
		for (const piece of run.text.split(/(\s+)/)) {
			if (piece) tokens.push({ text: sanitize(piece), ws: /^\s+$/.test(piece), style: run.style });
		}
	}
	return tokens;
}

/**
 * Picks the embedded font for a style combination.
 * @param {Object} style - Run style flags.
 * @param {Object} fonts - Embedded standard fonts.
 * @returns {PDFFont} The font to measure and draw with.
 */
function fontFor(style, fonts) {
	if (style.mono) return fonts.mono;
	if (style.bold && style.italic) return fonts.boldItalic;
	if (style.bold) return fonts.bold;
	if (style.italic) return fonts.italic;
	return fonts.regular;
}

/**
 * Resolves tokens into concrete drawables: font, size, baseline offset, color.
 * @param {Array<Object>} tokens - Word tokens.
 * @param {Object} fonts - Embedded standard fonts.
 * @param {number} baseSize - Block's base font size.
 * @param {Object} colors - { text, link } PDF colors.
 * @returns {Array<Object>} Tokens with font, size, dy, and color assigned.
 */
function materialize(tokens, fonts, baseSize, colors) {
	return tokens.map(token => {
		const style = token.style;
		const small = style.sup || style.sub;
		return {
			...token,
			font: fontFor(style, fonts),
			size: small ? baseSize * 0.65 : baseSize,
			dy: style.sup ? baseSize * 0.33 : (style.sub ? -baseSize * 0.18 : 0),
			color: style.href ? colors.link : colors.text
		};
	});
}

/**
 * Greedy word wrap over whitespace/word pieces: spaces between words are
 * carried by the whitespace pieces themselves and dropped at line breaks;
 * single words wider than the line are hard-broken.
 * @param {Array<Object>} tokens - Materialized pieces.
 * @param {number} maxWidth - Available line width.
 * @returns {Array<Array<Object>>} Lines of pieces, each carrying its width.
 */
function wrapTokens(tokens, maxWidth) {
	const lines = [];
	let line = [];
	let width = 0;
	let pending = null;
	const flush = () => {
		if (line.length) lines.push(line);
		line = [];
		width = 0;
		pending = null;
	};
	for (const token of tokens) {
		if (token.ws) {
			pending = token;
			continue;
		}
		const piece = { ...token, width: token.font.widthOfTextAtSize(token.text, token.size) };
		if (piece.width > maxWidth) {
			flush();
			let text = token.text;
			while (text.length) {
				let take = text.length;
				while (take > 1 && token.font.widthOfTextAtSize(text.slice(0, take), token.size) > maxWidth) take--;
				const part = text.slice(0, take);
				line.push({ ...token, text: part, width: token.font.widthOfTextAtSize(part, token.size) });
				text = text.slice(take);
				if (text.length) flush();
			}
			continue;
		}
		if (line.length && pending) {
			const spaceWidth = pending.font.widthOfTextAtSize(' ', pending.size);
			if (width + spaceWidth + piece.width > maxWidth) {
				flush();
			} else {
				line.push({ ...pending, text: ' ', width: spaceWidth });
				width += spaceWidth;
			}
			pending = null;
		}
		if (line.length && width + piece.width > maxWidth) flush();
		line.push(piece);
		width += piece.width;
	}
	flush();
	return lines;
}

/**
 * Draws one token at a baseline, applying sup/sub offset, underline, and
 * recording link rectangles.
 * @param {PDFPage} page - Page to draw on.
 * @param {Object} token - Materialized token with width.
 * @param {number} x - Left edge.
 * @param {number} y - Baseline.
 * @param {Array<Object>} links - Link rectangle collector.
 * @returns {number} The token's right edge.
 */
function drawToken(page, token, x, y, links) {
	if (token.ws) return x + token.width;
	const ty = y + token.dy;
	page.drawText(token.text, { x, y: ty, size: token.size, font: token.font, color: token.color });
	if (token.style.underline || token.style.href) {
		page.drawLine({ start: { x, y: ty - 1.5 }, end: { x: x + token.width, y: ty - 1.5 }, thickness: 0.6, color: token.color });
	}
	if (token.style.href) {
		links.push({ page, rect: [x, ty - 2, x + token.width, ty + token.size * 0.85], url: token.style.href });
	}
	return x + token.width;
}

/**
 * Draws a wrapped line piece by piece (whitespace pieces are explicit tokens).
 * @param {PDFPage} page - Page to draw on.
 * @param {Array<Object>} line - Pieces of the line.
 * @param {number} x - Line start x.
 * @param {number} y - Baseline.
 * @param {Array<Object>} links - Link rectangle collector.
 * @returns {number} The line's right edge.
 */
function drawLine(page, line, x, y, links) {
	let cursor = x;
	for (const token of line) {
		cursor = drawToken(page, token, cursor, y, links);
	}
	return cursor;
}

/**
 * Attaches link annotations to their pages: URI actions for external URLs,
 * jump destinations for "#name" anchors pointing at the named header.
 * Anchor links whose header does not exist are dropped.
 * @param {PDFDocument} doc - The PDF document.
 * @param {Object} lib - The pdf-lib namespace.
 * @param {Array<Object>} links - Collected { page, rect, url } entries.
 * @param {Map<string, Object>} anchors - Header destinations keyed by name ({ page, top }).
 */
function attachLinks(doc, lib, links, anchors) {
	const { PDFName, PDFString } = lib;
	const byPage = new Map();
	for (const link of links) {
		if (!byPage.has(link.page)) byPage.set(link.page, []);
		byPage.get(link.page).push(link);
	}
	for (const [page, pageLinks] of byPage) {
		const annots = [];
		for (const link of pageLinks) {
			const target = link.url.startsWith('#') ? anchors.get(link.url.slice(1)) : false;
			if (target === undefined) continue;
			const spec = {
				Type: 'Annot',
				Subtype: 'Link',
				Rect: link.rect,
				Border: [0, 0, 0]
			};
			if (target) {
				spec.Dest = [target.page.ref, 'XYZ', null, target.top, null];
			} else {
				spec.A = { Type: 'Action', S: 'URI', URI: PDFString.of(link.url) };
			}
			annots.push(doc.context.register(doc.context.obj(spec)));
		}
		page.node.set(PDFName.of('Annots'), doc.context.obj(annots));
	}
}

/**
 * Adds a fresh page and returns it.
 * @param {Object} ctx - Shared draw context.
 * @returns {PDFPage} The new page.
 */
function addPage(ctx) {
	return ctx.doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
}

/**
 * Starts a new page when the remaining space cannot fit the given height.
 * @param {Object} ctx - Shared draw context.
 * @param {number} needed - Vertical space needed from the current position.
 */
function ensureSpace(ctx, needed) {
	if (ctx.y - needed < MARGIN) {
		ctx.page = addPage(ctx);
		ctx.y = PAGE_HEIGHT - MARGIN;
	}
}

/**
 * Converts a PNG data URI into bytes pdf-lib can embed.
 * @param {string} dataUri - The data URI.
 * @returns {Uint8Array} The decoded bytes.
 */
function dataUriToBytes(dataUri) {
	const base64 = String(dataUri || '').split(',')[1] || '';
	return Uint8Array.from(atob(base64), ch => ch.charCodeAt(0));
}

/**
 * Parses a hex color into a pdf-lib color.
 * @param {string} hex - The hex value (#rgb or #rrggbb).
 * @param {Function} rgb - The pdf-lib rgb factory.
 * @returns {Object|null} The color, or null when unparseable.
 */
function hexToColor(hex, rgb) {
	const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex || '').trim());
	if (!m) return null;
	let h = m[1];
	if (h.length === 3) h = [...h].map(ch => ch + ch).join('');
	return rgb(parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255);
}

/**
 * Wraps an item caption into small italic lines.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item with an optional caption.
 * @returns {Array<Array<Object>>} The wrapped caption lines.
 */
function captionLines(ctx, item) {
	if (!item.caption) return [];
	const runs = parseRuns(String(item.caption)).map(run => ({ ...run, style: { ...run.style, italic: true } }));
	return wrapTokens(materialize(tokenize(runs), ctx.fonts, CAPTION_SIZE, ctx.colors), CONTENT_WIDTH);
}

/**
 * Draws an item caption as a small italic line above its block.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item with an optional caption.
 */
function drawCaption(ctx, item) {
	const lines = captionLines(ctx, item);
	for (const line of lines) {
		ensureSpace(ctx, CAPTION_LINE);
		ctx.y -= CAPTION_LINE;
		drawLine(ctx.page, line, MARGIN, ctx.y, ctx.links);
	}
	if (lines.length) ctx.y -= 2;
}

/**
 * Draws plain text lines (code, TeX source) in monospace, preserving line
 * breaks and hard-wrapping long lines.
 * @param {Object} ctx - Shared draw context.
 * @param {string} text - The raw text.
 */
function drawMonoLines(ctx, text) {
	const source = String(text ?? '').replace(/\t/g, '    ');
	for (const src of source.split('\n')) {
		const tokens = tokenize(plainRuns(src, { mono: true }));
		const lines = wrapTokens(materialize(tokens, ctx.fonts, CODE_SIZE, ctx.colors), CONTENT_WIDTH);
		if (!lines.length) lines.push([]);
		for (const line of lines) {
			ensureSpace(ctx, CODE_LINE);
			ctx.y -= CODE_LINE;
			drawLine(ctx.page, line, MARGIN, ctx.y, ctx.links);
		}
	}
}

/**
 * Draws a text-family block (header, text, list-step, link) through the
 * inline-run pipeline, keeping headers with their first content line and
 * recording named headers as anchor-link destinations.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item.
 * @param {number} index - The item position (for list markers).
 */
function drawTextBlock(ctx, item, index) {
	const isHeader = item.type === 'header';
	const isList = item.type === 'list-step';
	const baseSize = isHeader ? HEADER_SIZES[headerLevel(item)] : BODY_SIZE;
	const lineHeight = isHeader ? baseSize * 1.4 : BODY_LINE;
	const marker = isList ? (item.ordered ? (Number.isInteger(ctx.markers[index]) ? `${ctx.markers[index]}.` : null) : '\u2022') : null;
	const indent = isList ? LIST_INDENT : 0;
	const isLink = item.type === 'link';
	// citation-style link items carry their target in source[0]; the app
	// renders their title linked to the source url, so the PDF matches that
	const src = isLink && Array.isArray(item.source) ? item.source[0] : null;
	const fromSource = src?.title && src?.url;
	const href = isLink ? (fromSource ? src.url : (item.url || (item.anchor ? '#' + item.anchor : ''))) : null;
	const runs = parseRuns(fromSource ? src.title : (item.value || ''), href);
	const lines = wrapTokens(materialize(tokenize(runs), ctx.fonts, baseSize, ctx.colors), CONTENT_WIDTH - indent);
	if (!lines.length) return;

	// keep a header with its first content line: no header stranded at a page bottom
	if (isHeader && ctx.y - lineHeight * 2 < MARGIN) {
		ctx.page = addPage(ctx);
		ctx.y = PAGE_HEIGHT - MARGIN;
	}

	lines.forEach((line, li) => {
		if (ctx.y - lineHeight < MARGIN) {
			ctx.page = addPage(ctx);
			ctx.y = PAGE_HEIGHT - MARGIN;
		}
		ctx.y -= lineHeight;
		if (marker && li === 0) {
			ctx.page.drawText(marker, {
				x: MARGIN + 2,
				y: ctx.y,
				size: BODY_SIZE,
				font: ctx.fonts.regular,
				color: ctx.colors.text
			});
		}
		drawLine(ctx.page, line, MARGIN + indent, ctx.y, ctx.links);
		if (isHeader && item.name && li === 0) {
			ctx.anchors.set(item.name, { page: ctx.page, top: ctx.y + baseSize });
		}
	});
}

/**
 * Draws a quote block: indented runs with a vertical bar along the left,
 * continuing over page breaks.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item.
 */
function drawQuoteBlock(ctx, item) {
	drawCaption(ctx, item);
	const runs = parseRuns(item.value || '');
	const lines = wrapTokens(materialize(tokenize(runs), ctx.fonts, BODY_SIZE, ctx.colors), CONTENT_WIDTH - QUOTE_INDENT);
	if (!lines.length) return;
	let barPage = ctx.page;
	let barTop = ctx.y + 3;
	let barBottom = ctx.y;
	const drawBar = () => {
		if (barBottom < barTop) {
			barPage.drawLine({ start: { x: MARGIN + 4, y: barBottom }, end: { x: MARGIN + 4, y: barTop }, thickness: 2, color: ctx.colors.border });
		}
	};
	for (const line of lines) {
		if (ctx.y - BODY_LINE < MARGIN) {
			drawBar();
			ctx.page = addPage(ctx);
			ctx.y = PAGE_HEIGHT - MARGIN;
			barPage = ctx.page;
			barTop = ctx.y + 3;
		}
		ctx.y -= BODY_LINE;
		drawLine(ctx.page, line, MARGIN + QUOTE_INDENT, ctx.y, ctx.links);
		barBottom = ctx.y - 3;
	}
	drawBar();
}

/**
 * Draws a table as a bordered grid: column widths proportional to their
 * longest cell, cell text wrapped, header row bold on a shaded band.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item with table-rows and has-header.
 */
function drawTableBlock(ctx, item) {
	const rows = (Array.isArray(item['table-rows']) ? item['table-rows'] : [])
		.map(row => Array.isArray(row) ? row : []);
	if (!rows.length) return;
	drawCaption(ctx, item);
	const hasHeader = !!item['has-header'];
	const colCount = Math.max(...rows.map(row => row.length), 1);

	const cellRuns = rows.map((row, r) => (
		Array.from({ length: colCount }, (_, c) => {
			const runs = parseRuns(String(row[c] ?? ''));
			if (r === 0 && hasHeader) {
				return runs.map(run => ({ ...run, style: { ...run.style, bold: true } }));
			}
			return runs;
		})
	));

	const weights = Array.from({ length: colCount }, () => 3);
	for (const row of cellRuns) {
		row.forEach((runs, c) => {
			const len = runs.map(run => run.text).join('').length;
			if (len > weights[c]) weights[c] = len;
		});
	}
	const widths = weights.map(w => Math.max(CONTENT_WIDTH * w / weights.reduce((a, b) => a + b, 0), 40));
	const rescale = CONTENT_WIDTH / widths.reduce((a, b) => a + b, 0);
	for (let c = 0; c < colCount; c++) widths[c] *= rescale;

	const wrapped = cellRuns.map(row => row.map((runs, c) => (
		wrapTokens(materialize(tokenize(runs), ctx.fonts, TABLE_SIZE, ctx.colors), widths[c] - TABLE_PAD * 2)
	)));

	for (let r = 0; r < rows.length; r++) {
		const lineCount = Math.max(...wrapped[r].map(lines => lines.length), 1);
		const rowHeight = lineCount * TABLE_LINE + TABLE_PAD * 2;
		ensureSpace(ctx, rowHeight);
		const top = ctx.y;
		if (r === 0 && hasHeader) {
			ctx.page.drawRectangle({ x: MARGIN, y: top - rowHeight, width: CONTENT_WIDTH, height: rowHeight, color: ctx.colors.shade });
		}
		for (let c = 0; c < colCount; c++) {
			const x = MARGIN + widths.slice(0, c).reduce((a, b) => a + b, 0) + TABLE_PAD;
			let baseline = top - TABLE_PAD - TABLE_SIZE * 0.8;
			for (const line of wrapped[r][c]) {
				drawLine(ctx.page, line, x, baseline, ctx.links);
				baseline -= TABLE_LINE;
			}
		}
		ctx.y = top - rowHeight;
		for (const edge of [top, ctx.y]) {
			ctx.page.drawLine({ start: { x: MARGIN, y: edge }, end: { x: MARGIN + CONTENT_WIDTH, y: edge }, thickness: 0.5, color: ctx.colors.border });
		}
		let vx = MARGIN;
		for (let c = 0; c <= colCount; c++) {
			ctx.page.drawLine({ start: { x: vx, y: ctx.y }, end: { x: vx, y: top }, thickness: 0.5, color: ctx.colors.border });
			if (c < colCount) vx += widths[c];
		}
	}
	ctx.y -= 4;
}

/**
 * Draws a mermaid diagram as an embedded PNG image (centered, scaled to the
 * content width), falling back to the raw mermaid source when the diagram
 * was never rendered. Callers pass rasterized images keyed by item guid.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item.
 */
function drawImageBlock(ctx, item) {
	const img = ctx.embedded.get(item.guid);
	let w = 0;
	let h = 0;
	if (img) {
		const scale = CONTENT_WIDTH / img.width;
		w = CONTENT_WIDTH;
		h = img.height * scale;
		const maxHeight = PAGE_HEIGHT - MARGIN * 2;
		if (h > maxHeight) {
			w *= maxHeight / h;
			h = maxHeight;
		}
	}
	// keep the caption with its image: measure both before drawing anything
	const capLines = captionLines(ctx, item);
	const capHeight = capLines.length * CAPTION_LINE + (capLines.length ? 2 : 0);
	ensureSpace(ctx, capHeight + (img ? h + 10 : 0));
	for (const line of capLines) {
		ctx.y -= CAPTION_LINE;
		drawLine(ctx.page, line, MARGIN, ctx.y, ctx.links);
	}
	if (capLines.length) ctx.y -= 2;
	if (!img) {
		drawMonoLines(ctx, item.value || '');
		return;
	}
	ctx.y -= h + 10;
	ctx.page.drawImage(img, { x: MARGIN + (CONTENT_WIDTH - w) / 2, y: ctx.y + 10, width: w, height: h });
}

/**
 * Draws a color swatch block: a filled rectangle next to the hex value.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item.
 */
function drawColorBlock(ctx, item) {
	drawCaption(ctx, item);
	const hex = String(item.value || '').trim();
	ensureSpace(ctx, 20);
	ctx.y -= 20;
	ctx.page.drawRectangle({
		x: MARGIN,
		y: ctx.y + 2,
		width: 14,
		height: 14,
		color: hexToColor(hex, ctx.lib.rgb) || ctx.colors.border,
		borderColor: ctx.colors.border,
		borderWidth: 0.5
	});
	ctx.page.drawText(sanitize(hex), { x: MARGIN + 20, y: ctx.y + 5, size: BODY_SIZE, font: ctx.fonts.regular, color: ctx.colors.text });
}

/**
 * Draws one item by dispatching to its type's renderer.
 * @param {Object} ctx - Shared draw context.
 * @param {Object} item - The item.
 * @param {number} index - The item position.
 */
function drawItem(ctx, item, index) {
	if (index > 0) ctx.y -= item.type === 'header' ? 14 : 6;
	switch (item.type) {
		case 'table':
			drawTableBlock(ctx, item);
			return;
		case 'quote':
			drawQuoteBlock(ctx, item);
			return;
		case 'diagram-mermaid':
			drawImageBlock(ctx, item);
			return;
		case 'color-hex':
			drawColorBlock(ctx, item);
			return;
		case 'code':
		case 'equation':
			drawCaption(ctx, item);
			drawMonoLines(ctx, item.value || '');
			return;
		default:
			drawTextBlock(ctx, item, index);
	}
}

/**
 * Renders typed content blocks into a US Letter PDF (1" margins) and
 * downloads it.
 * @param {Array<Object>} items - The content blocks.
 * @param {Array<number|null>} markers - One displayed list number per item.
 * @param {string} title - The document title, used for metadata and filename.
 * @param {Object|null} [images=null] - PNG data URIs keyed by item guid, for
 *   diagram items; items without one fall back to their raw source text.
 */
export async function exportDocumentPdf(items, markers, title, images = null) {
	const lib = await loadPdfLib();
	const { PDFDocument, StandardFonts, rgb } = lib;
	const doc = await PDFDocument.create();
	doc.setTitle(title || 'Document');
	const colors = {
		text: rgb(0, 0, 0),
		link: rgb(0.02, 0.39, 0.76),
		border: rgb(0.72, 0.72, 0.72),
		shade: rgb(0.93, 0.93, 0.93)
	};
	const fonts = {
		regular: await doc.embedFont(StandardFonts.Helvetica),
		bold: await doc.embedFont(StandardFonts.HelveticaBold),
		italic: await doc.embedFont(StandardFonts.HelveticaOblique),
		boldItalic: await doc.embedFont(StandardFonts.HelveticaBoldOblique),
		mono: await doc.embedFont(StandardFonts.Courier)
	};

	const embedded = new Map();
	for (const [guid, dataUri] of Object.entries(images || {})) {
		try {
			embedded.set(guid, await doc.embedPng(dataUriToBytes(dataUri)));
		} catch {
			// a bad image falls back to the item's raw source text
		}
	}

	const ctx = { doc, lib, fonts, colors, embedded, markers: markers || [], links: [], anchors: new Map() };
	ctx.page = addPage(ctx);
	ctx.y = PAGE_HEIGHT - MARGIN;

	items.forEach((item, i) => drawItem(ctx, item, i));

	attachLinks(doc, lib, ctx.links, ctx.anchors);

	const bytes = await doc.save();
	download_file((sanitize_filename(title) || 'document') + '.pdf', 'application/pdf', bytes);
}

/**
 * Loads the pdf-lib ESM bundle from the CDN once and caches the promise. A
 * failed load clears the cache so a later export can retry.
 * @returns {Promise<Object>} The pdf-lib namespace.
 */
function loadPdfLib() {
	pdf_lib_promise ??= import(PDF_LIB_URL)
		.then(mod => mod.default ?? mod)
		.catch(err => {
			pdf_lib_promise = null;
			throw err;
		});
	return pdf_lib_promise;
}
🌐
pdf-export.js ×
Type: Web, text/x-java
23.59 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File