src/pdf.js
Rama main · réplica de lectura del repositorio original.
import { readFileSync } from 'node:fs';
import { deflateSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { parseBlocks } from './markdown.js';
const HELV = [278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584];
const HELV_BOLD = [278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584];
const FONTS = {
F1: { base: 'Helvetica', widths: HELV },
F2: { base: 'Helvetica-Bold', widths: HELV_BOLD },
F3: { base: 'Helvetica-Oblique', widths: HELV },
F4: { base: 'Courier', widths: null },
};
const TTF_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'vendor', 'fonts', 'DejaVuSans.ttf');
const MISSING_GLYPH_CP = 0x25a1;
function parseTtf(buf) {
const tables = {};
const numTables = buf.readUInt16BE(4);
for (let i = 0; i < numTables; i++) {
const o = 12 + i * 16;
tables[buf.toString('ascii', o, o + 4)] = { off: buf.readUInt32BE(o + 8), len: buf.readUInt32BE(o + 12) };
}
for (const t of ['head', 'hhea', 'maxp', 'cmap', 'hmtx']) {
if (!tables[t]) throw new Error(`font missing required table: ${t}`);
}
const head = tables.head.off;
const unitsPerEm = buf.readUInt16BE(head + 18);
const scale = (v) => Math.round(v * 1000 / unitsPerEm);
const bbox = [buf.readInt16BE(head + 36), buf.readInt16BE(head + 38), buf.readInt16BE(head + 40), buf.readInt16BE(head + 42)].map(scale);
const hhea = tables.hhea.off;
const ascent = scale(buf.readInt16BE(hhea + 4));
const descent = scale(buf.readInt16BE(hhea + 6));
const numHMetrics = buf.readUInt16BE(hhea + 34);
const numGlyphs = buf.readUInt16BE(tables.maxp.off + 4);
let capHeight = ascent;
if (tables['OS/2'] && buf.readUInt16BE(tables['OS/2'].off) >= 2) {
capHeight = scale(buf.readInt16BE(tables['OS/2'].off + 88));
}
const cmap = tables.cmap.off;
let f4 = 0, f12 = 0;
const subCount = buf.readUInt16BE(cmap + 2);
for (let i = 0; i < subCount; i++) {
const o = cmap + 4 + i * 8;
const pid = buf.readUInt16BE(o), eid = buf.readUInt16BE(o + 2);
const sub = cmap + buf.readUInt32BE(o + 4);
const fmt = buf.readUInt16BE(sub);
if (fmt === 4 && (pid === 3 && eid === 1 || pid === 0)) f4 = sub;
if (fmt === 12 && (pid === 3 && eid === 10 || pid === 0)) f12 = sub;
}
if (!f4 && !f12) throw new Error('font has no usable Unicode cmap subtable');
const gidCache = new Map();
const gidFor = (cp) => {
if (gidCache.has(cp)) return gidCache.get(cp);
let gid = 0;
if (f12) {
let lo = 0, hi = buf.readUInt32BE(f12 + 12) - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const g = f12 + 16 + mid * 12;
const start = buf.readUInt32BE(g), end = buf.readUInt32BE(g + 4);
if (cp < start) hi = mid - 1;
else if (cp > end) lo = mid + 1;
else { gid = buf.readUInt32BE(g + 8) + (cp - start); break; }
}
}
if (!gid && f4 && cp <= 0xffff) {
const segCount = buf.readUInt16BE(f4 + 6) / 2;
const ends = f4 + 14, starts = ends + segCount * 2 + 2;
const deltas = starts + segCount * 2, ranges = deltas + segCount * 2;
for (let s = 0; s < segCount; s++) {
if (cp > buf.readUInt16BE(ends + s * 2)) continue;
const start = buf.readUInt16BE(starts + s * 2);
if (cp < start) break;
const rangeOff = buf.readUInt16BE(ranges + s * 2);
if (rangeOff === 0) {
gid = (cp + buf.readInt16BE(deltas + s * 2)) & 0xffff;
} else {
const g = buf.readUInt16BE(ranges + s * 2 + rangeOff + (cp - start) * 2);
gid = g === 0 ? 0 : (g + buf.readInt16BE(deltas + s * 2)) & 0xffff;
}
break;
}
}
gidCache.set(cp, gid);
return gid;
};
const hmtx = tables.hmtx.off;
const widthFor = (gid) => {
const i = Math.min(gid, numHMetrics - 1);
return scale(buf.readUInt16BE(hmtx + i * 4));
};
return { data: buf, unitsPerEm, bbox, ascent, descent, capHeight, numGlyphs, gidFor, widthFor };
}
let UNI = null;
let UNI_FAILED = false;
export function unicodeFont() {
if (UNI) return UNI;
if (UNI_FAILED) return null;
try {
UNI = parseTtf(readFileSync(TTF_PATH));
} catch {
UNI_FAILED = true;
return null;
}
return UNI;
}
function needsUnicode(text) {
for (const ch of String(text)) {
const cp = ch.codePointAt(0);
if (cp > 255 && !WINANSI_EXTRA[cp]) return !!unicodeFont();
}
return false;
}
function encodeUnicodeHex(text, used) {
const uni = unicodeFont();
let hex = '';
for (const ch of String(text)) {
let cp = ch.codePointAt(0);
let gid = uni.gidFor(cp);
if (!gid) { cp = MISSING_GLYPH_CP; gid = uni.gidFor(cp); }
if (!used.has(gid)) used.set(gid, cp);
hex += gid.toString(16).padStart(4, '0');
}
return hex;
}
const PAGE_W = 612;
const PAGE_H = 792;
const MARGIN = 72;
const CONTENT_W = PAGE_W - MARGIN * 2;
function charWidth(font, code, size) {
const f = FONTS[font];
if (!f.widths) return 600 * size / 1000;
const w = code >= 32 && code <= 126 ? f.widths[code - 32] : 556;
return w * size / 1000;
}
function textWidth(font, text, size) {
if (needsUnicode(text)) {
const uni = unicodeFont();
let w = 0;
for (const ch of String(text)) {
const gid = uni.gidFor(ch.codePointAt(0)) || uni.gidFor(MISSING_GLYPH_CP);
w += uni.widthFor(gid) * size / 1000;
}
return w;
}
let w = 0;
for (let i = 0; i < text.length; i++) w += charWidth(font, text.charCodeAt(i), size);
return w;
}
export function stripInline(text) {
return String(text)
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\(([^)]*)\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/~~([^~]+)~~/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/_([^_]+)_/g, '$1');
}
function wrap(font, text, size, maxWidth) {
const words = String(text).split(/\s+/).filter((w) => w.length);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (textWidth(font, candidate, size) <= maxWidth || !line) {
line = candidate;
while (textWidth(font, line, size) > maxWidth && line.length > 1) {
let cut = line.length - 1;
while (cut > 1 && textWidth(font, line.slice(0, cut), size) > maxWidth) cut--;
lines.push(line.slice(0, cut));
line = line.slice(cut);
}
} else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines.length ? lines : [''];
}
const WINANSI_EXTRA = {
0x20ac: 128, 0x2026: 133, 0x2018: 145, 0x2019: 146, 0x201c: 147, 0x201d: 148,
0x2022: 149, 0x2013: 150, 0x2014: 151, 0x2122: 153,
};
function escapePdfText(text) {
let out = '';
for (const ch of String(text)) {
const code = ch.codePointAt(0);
if (ch === '\\' || ch === '(' || ch === ')') out += '\\' + ch;
else if (code >= 32 && code <= 126) out += ch;
else if (WINANSI_EXTRA[code]) out += '\\' + WINANSI_EXTRA[code].toString(8).padStart(3, '0');
else if (code <= 255) out += '\\' + code.toString(8).padStart(3, '0');
else out += '?';
}
return out;
}
class PdfBuilder {
constructor() {
this.pages = [];
this.used = new Map();
this.newPage();
}
tj(str, font, size, x, y, rg) {
const pos = `1 0 0 1 ${x.toFixed(1)} ${y.toFixed(1)} Tm`;
if (needsUnicode(str)) {
return `BT ${rg}/FU ${size} Tf ${pos} <${encodeUnicodeHex(str, this.used)}> Tj ET`;
}
return `BT ${rg}/${font} ${size} Tf ${pos} (${escapePdfText(str)}) Tj ET`;
}
newPage() {
this.ops = [];
this.pages.push(this.ops);
this.y = PAGE_H - MARGIN;
}
ensure(height) {
if (this.y - height < MARGIN) this.newPage();
}
text(str, { font = 'F1', size = 11, x = MARGIN, color = null, lineGap = 1.35 } = {}) {
const lh = size * lineGap;
this.ensure(lh);
this.y -= lh;
const rg = color ? `${color.join(' ')} rg ` : '0 0 0 rg ';
this.ops.push(this.tj(str, font, size, x, this.y, rg));
}
paragraph(str, { font = 'F1', size = 11, indent = 0, color = null, gapAfter = 6 } = {}) {
const lines = wrap(font, str, size, CONTENT_W - indent);
for (const line of lines) this.text(line, { font, size, x: MARGIN + indent, color });
this.y -= gapAfter;
}
rule() {
this.ensure(14);
this.y -= 10;
this.ops.push(`0.8 0.8 0.8 RG 0.7 w ${MARGIN} ${this.y.toFixed(1)} m ${(PAGE_W - MARGIN).toFixed(1)} ${this.y.toFixed(1)} l S`);
this.y -= 6;
}
space(h) {
this.y -= h;
}
}
export function markdownToPdf(markdown, { title = 'Document', subtitle = '' } = {}) {
const b = new PdfBuilder();
b.paragraph(title, { font: 'F2', size: 20, gapAfter: 2 });
if (subtitle) b.paragraph(subtitle, { size: 9.5, color: [0.45, 0.45, 0.5], gapAfter: 2 });
b.rule();
b.space(6);
const blocks = parseBlocks(markdown);
for (const block of blocks) {
switch (block.type) {
case 'heading': {
const sizes = { 1: 17, 2: 14.5, 3: 12.5 };
b.space(8);
b.paragraph(stripInline(block.text), { font: 'F2', size: sizes[block.level] ?? 11.5, gapAfter: 3 });
break;
}
case 'paragraph':
b.paragraph(stripInline(block.text.replace(/\n/g, ' ')), {});
break;
case 'code': {
b.space(3);
for (const line of block.text.split('\n')) {
for (const wrapped of wrap('F4', line || ' ', 9, CONTENT_W - 12)) {
b.text(wrapped, { font: 'F4', size: 9, x: MARGIN + 12, color: [0.2, 0.2, 0.3], lineGap: 1.3 });
}
}
b.space(8);
break;
}
case 'quote':
for (const line of block.text.split('\n')) {
b.paragraph(stripInline(line), { font: 'F3', size: 11, indent: 16, color: [0.35, 0.35, 0.4], gapAfter: 2 });
}
b.space(5);
break;
case 'hr':
b.rule();
break;
case 'html':
if (/class="pagebreak"/.test(block.text)) b.newPage();
break;
case 'mermaid': {
b.space(3);
for (const line of block.text.split('\n')) {
for (const wrapped of wrap('F4', line || ' ', 9, CONTENT_W - 12)) {
b.text(wrapped, { font: 'F4', size: 9, x: MARGIN + 12, color: [0.2, 0.2, 0.3], lineGap: 1.3 });
}
}
b.space(8);
break;
}
case 'list': {
let counter = 0;
for (const item of block.items) {
counter = item.depth === 0 && item.ordered ? counter + 1 : counter;
const bullet = item.checked != null ? (item.checked ? '[x]' : '[ ]') : item.ordered ? `${counter}.` : '•';
b.paragraph(`${bullet} ${stripInline(item.text)}`, { indent: 14 + item.depth * 16, gapAfter: 2 });
}
b.space(5);
break;
}
case 'table': {
const all = [block.header, ...block.rows];
const colCount = Math.max(...all.map((r) => r.length));
const widths = [];
for (let c = 0; c < colCount; c++) {
widths[c] = Math.max(...all.map((r) => textWidth('F1', stripInline(r[c] ?? ''), 9.5))) + 12;
}
const scale = Math.min(1, CONTENT_W / widths.reduce((a, x) => a + x, 0));
all.forEach((row, ri) => {
b.ensure(14);
b.y -= 13;
let x = MARGIN;
row.forEach((cell, c) => {
const font = ri === 0 ? 'F2' : 'F1';
let text = stripInline(cell ?? '');
const maxW = widths[c] * scale - 6;
while (text.length > 1 && textWidth(font, text, 9.5) > maxW) text = text.slice(0, -1);
b.ops.push(b.tj(text, font, 9.5, x, b.y, '0 0 0 rg '));
x += widths[c] * scale;
});
if (ri === 0) {
b.y -= 4;
b.ops.push(`0.75 0.75 0.75 RG 0.7 w ${MARGIN} ${b.y.toFixed(1)} m ${(PAGE_W - MARGIN).toFixed(1)} ${b.y.toFixed(1)} l S`);
}
});
b.space(10);
break;
}
}
}
return assemble(b.pages, b.used);
}
function toUnicodeCMap(used) {
const pairs = [...used.entries()].sort((a, b) => a[0] - b[0]);
const chunks = [];
for (let i = 0; i < pairs.length; i += 100) {
const slice = pairs.slice(i, i + 100);
const rows = slice.map(([gid, cp]) => {
const s = String.fromCodePoint(cp);
let units = '';
for (let j = 0; j < s.length; j++) units += s.charCodeAt(j).toString(16).padStart(4, '0');
return `<${gid.toString(16).padStart(4, '0')}> <${units}>`;
});
chunks.push(`${slice.length} beginbfchar\n${rows.join('\n')}\nendbfchar`);
}
return [
'/CIDInit /ProcSet findresource begin',
'12 dict begin',
'begincmap',
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
'/CMapName /Adobe-Identity-UCS def',
'/CMapType 2 def',
'1 begincodespacerange',
'<0000> <ffff>',
'endcodespacerange',
chunks.join('\n'),
'endcmap',
'CMapName currentdict /CMap defineresource pop',
'end',
'end',
].join('\n');
}
function assemble(pages, used = new Map()) {
const objects = [];
const addObj = (body) => objects.push(body) && objects.length;
const fontIds = {};
for (const [key, font] of Object.entries(FONTS)) {
fontIds[key] = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${font.base} /Encoding /WinAnsiEncoding >>`);
}
let fontRes = Object.entries(fontIds).map(([k, id]) => `/${k} ${id} 0 R`).join(' ');
if (used.size) {
const uni = unicodeFont();
const fontData = deflateSync(uni.data).toString('latin1');
const fileId = addObj(`<< /Length ${fontData.length} /Filter /FlateDecode /Length1 ${uni.data.length} >>\nstream\n${fontData}\nendstream`);
const descId = addObj(`<< /Type /FontDescriptor /FontName /DejaVuSans /Flags 32 /FontBBox [${uni.bbox.join(' ')}] /ItalicAngle 0 /Ascent ${uni.ascent} /Descent ${uni.descent} /CapHeight ${uni.capHeight} /StemV 80 /FontFile2 ${fileId} 0 R >>`);
const wArr = [...used.keys()].sort((a, b) => a - b).map((gid) => `${gid} [${uni.widthFor(gid)}]`).join(' ');
const cidId = addObj(`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /DejaVuSans /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ${descId} 0 R /DW 600 /W [${wArr}] /CIDToGIDMap /Identity >>`);
const cmapStream = toUnicodeCMap(used);
const tuId = addObj(`<< /Length ${Buffer.byteLength(cmapStream, 'latin1')} >>\nstream\n${cmapStream}\nendstream`);
const type0Id = addObj(`<< /Type /Font /Subtype /Type0 /BaseFont /DejaVuSans /Encoding /Identity-H /DescendantFonts [${cidId} 0 R] /ToUnicode ${tuId} 0 R >>`);
fontRes += ` /FU ${type0Id} 0 R`;
}
const pageIds = [];
const pagesId = objects.length + pages.length * 2 + 1;
for (const ops of pages) {
const stream = ops.join('\n');
const contentId = addObj(`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`);
const pageId = addObj(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << ${fontRes} >> >> /Contents ${contentId} 0 R >>`);
pageIds.push(pageId);
}
const actualPagesId = addObj(`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`);
if (actualPagesId !== pagesId) throw new Error('PDF object id accounting error');
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
let out = '%PDF-1.4\n%âãÏÓ\n';
const offsets = [0];
objects.forEach((body, idx) => {
offsets.push(Buffer.byteLength(out, 'latin1'));
out += `${idx + 1} 0 obj\n${body}\nendobj\n`;
});
const xrefStart = Buffer.byteLength(out, 'latin1');
out += `xref\n0 ${objects.length + 1}\n`;
out += '0000000000 65535 f \n';
for (let i = 1; i <= objects.length; i++) {
out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
}
out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`;
return Buffer.from(out, 'latin1');
}