-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
/**
* 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
Type: Web, text/x-java
23.59 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File