diff --git a/src/index.html b/src/index.html index 4098024..9ac0e43 100644 --- a/src/index.html +++ b/src/index.html @@ -10086,14 +10086,60 @@ }; renderList(token, 0); } else if (token.type === 'table') { - // Tables get a placeholder row in v1 — proper column - // layout lands in I2.2. - const headerLabels = (token.header || []) - .map(c => (c?.text || c?.tokens?.[0]?.text || c?.raw || '')).filter(Boolean); - const rowCount = Array.isArray(token.rows) ? token.rows.length : 0; - const summary = `[Table — ${headerLabels.length} cols × ${rowCount} rows: ${headerLabels.join(' · ')}]`; - lines = wrapTextIntoLines(summary, availW); - result.warnings.push(`Tables render as a placeholder in v1 — full layout in I2.2.`); + // Real table layout (I2.2). Equal-width columns; each cell + // wraps independently; row height = max line count × line + // height. The returned block carries `tableLayout` so the + // renderer can iterate columns/rows + emit per-cell text. + const headerCells = (token.header || []).map(c => String(c?.text || '')); + const rowCellsAll = (token.rows || []).map(row => row.map(c => String(c?.text || ''))); + const colCount = Math.max(headerCells.length, ...rowCellsAll.map(r => r.length), 1); + const align = Array.isArray(token.align) ? token.align : []; + const cellPadIn = 0.06; // breathing room inside each cell + const colWidthIn = availW / colCount; + const cellWidthIn = Math.max(0.1, colWidthIn - cellPadIn * 2); + // Header style: bold body. + const headerStyle = { ...style, fontWeight: 700 }; + setFont(headerStyle); + const headerLineH = lineHeightIn(headerStyle); + const headerCellLines = []; + for (let c = 0; c < colCount; c++) { + headerCellLines.push(wrapTextIntoLines(headerCells[c] || '', cellWidthIn)); + } + const headerRowH = Math.max(1, ...headerCellLines.map(l => l.length || 1)) * headerLineH + cellPadIn * 2; + // Body rows. + setFont(style); + const rowLineH = lineHeightIn(style); + const rowsData = rowCellsAll.map(row => { + const cells = []; + for (let c = 0; c < colCount; c++) { + cells.push(wrapTextIntoLines(row[c] || '', cellWidthIn)); + } + const linesMax = Math.max(1, ...cells.map(l => l.length || 1)); + return { cells, rowH: linesMax * rowLineH + cellPadIn * 2 }; + }); + const tableHeight = headerRowH + rowsData.reduce((s, r) => s + r.rowH, 0); + const tableLayout = { + colCount, colWidthIn, cellPadIn, + align, + headerCellLines, headerLineH, headerRowH, + rows: rowsData, rowLineH, + style, headerStyle, + totalHeightIn: tableHeight, + }; + // Early-return: tables compute blockH directly and carry + // no `lines` array. + const blockH = marginBefore + tableHeight + marginAfter; + return { + kind: 'table', + tokenType: 'table', + lines: [], + style, + lineHeightIn: rowLineH, + marginBefore, marginAfter, marginLeft, marginRight, + blockH, + keepWithNext: false, + tableLayout, + }; } else if (token.type === 'blockquote') { // marked emits blockquote with inner tokens; concatenate // their plain text + wrap. @@ -10205,9 +10251,56 @@ // Each block becomes a positioned div with its style + lines. const scale = (parent.offsetWidth || 200) / (canvasW * 96); for (const block of page.blocks) { - const blk = document.createElement('div'); const style = block.style || {}; const fs = (Number(style.fontSize) || 11); + // Table blocks (I2.2): render a positioned with the + // computed cell-line layout instead of a flat text block. + if (block.tokenType === 'table' && block.tableLayout) { + const tl = block.tableLayout; + const tableWrap = document.createElement('div'); + tableWrap.style.cssText = [ + 'position:absolute', + `left:${((Number(style.marginLeft) || 0) / innerW) * 100}%`, + `right:${((Number(style.marginRight) || 0) / innerW) * 100}%`, + `top:${(block.y / (frame.h - 2 * padding)) * 100}%`, + `font-family:${style.fontFamily || 'Georgia, serif'}`, + `font-size:${fs * scale}px`, + `line-height:${style.lineHeight || 1.5}`, + `color:${style.color || '#111111'}`, + ].filter(Boolean).join(';'); + const tbl = document.createElement('table'); + tbl.style.cssText = `width:100%;border-collapse:collapse;table-layout:fixed`; + const thead = document.createElement('thead'); + const trH = document.createElement('tr'); + for (let c = 0; c < tl.colCount; c++) { + const th = document.createElement('th'); + const a = tl.align[c] || 'left'; + th.style.cssText = `text-align:${a};vertical-align:top;padding:${tl.cellPadIn * scale * 96}px;border-bottom:1px solid #999;font-weight:700`; + th.textContent = (tl.headerCellLines[c] || []).join('\n'); + th.style.whiteSpace = 'pre-wrap'; + trH.appendChild(th); + } + thead.appendChild(trH); + tbl.appendChild(thead); + const tbody = document.createElement('tbody'); + for (const row of tl.rows) { + const tr = document.createElement('tr'); + for (let c = 0; c < tl.colCount; c++) { + const td = document.createElement('td'); + const a = tl.align[c] || 'left'; + td.style.cssText = `text-align:${a};vertical-align:top;padding:${tl.cellPadIn * scale * 96}px;border-bottom:1px solid #ddd`; + td.textContent = (row.cells[c] || []).join('\n'); + td.style.whiteSpace = 'pre-wrap'; + tr.appendChild(td); + } + tbody.appendChild(tr); + } + tbl.appendChild(tbody); + tableWrap.appendChild(tbl); + overlay.appendChild(tableWrap); + continue; + } + const blk = document.createElement('div'); blk.style.cssText = [ 'position:absolute', `left:${((Number(style.marginLeft) || 0) / innerW) * 100}%`, @@ -25975,6 +26068,97 @@ const align = style.alignment || 'left'; const color = style.color || '#111'; const fontKey = collect ? _resolveStandardFontKey(family, isBold, isItalic) : null; + // Table blocks (I2.2): draw cells via fillText, draw thin + // row separators via stroke, and emit per-cell textOps when + // collecting for real PDF text. + if (block.tokenType === 'table' && block.tableLayout) { + const tl = block.tableLayout; + const tableTopIn = innerY + (Number(block.y) || 0); + const tableLeftIn = xStartIn; + // Header row geometry. + const headerFontPx = (Number(tl.headerStyle?.fontSize) || fontSizePt) * (dpi / 72); + const headerLineHPx = tl.headerLineH * dpi; + const rowLineHPx = tl.rowLineH * dpi; + const cellPadPx = tl.cellPadIn * dpi; + let yPx = tableTopIn * dpi; + const drawCell = (cellLines, xPx, yTopPx, widthPx, cellHPx, alignment, isHeader) => { + const fs = isHeader ? headerFontPx : fontPx; + const lh = isHeader ? headerLineHPx : rowLineHPx; + const fw = isHeader ? 700 : weight; + ctx.font = `${styleAttr} ${fw} ${fs}px ${family}`; + ctx.fillStyle = color; + ctx.textBaseline = 'top'; + const a = alignment === 'right' ? 'right' : alignment === 'center' ? 'center' : 'left'; + ctx.textAlign = a; + const contentLeftPx = xPx + cellPadPx; + const contentRightPx = xPx + widthPx - cellPadPx; + const contentWPx = Math.max(1, contentRightPx - contentLeftPx); + let drawX; + if (a === 'right') drawX = contentRightPx; + else if (a === 'center') drawX = contentLeftPx + contentWPx / 2; + else drawX = contentLeftPx; + const startY = yTopPx + cellPadPx; + for (let li = 0; li < cellLines.length; li++) { + ctx.fillText(cellLines[li], drawX, startY + li * lh); + } + // Real-PDF emission: emit one textOp per line per cell. + if (collect && fontKey) { + const headerFontKey = isHeader + ? _resolveStandardFontKey(family, true, isItalic) + : fontKey; + if (headerFontKey) { + const cellLeftIn = (xPx / dpi); + const cellRightIn = ((xPx + widthPx) / dpi); + const padLeftIn = cellLeftIn + tl.cellPadIn; + const widthInForOp = (cellRightIn - cellLeftIn) - 2 * tl.cellPadIn; + const fsPt = isHeader ? (Number(tl.headerStyle?.fontSize) || fontSizePt) : fontSizePt; + for (let li = 0; li < cellLines.length; li++) { + const lineTopIn = (yTopPx / dpi) + tl.cellPadIn + li * (isHeader ? tl.headerLineH : tl.rowLineH); + textOps.push({ + text: cellLines[li], + fontKey: headerFontKey, + sizePt: fsPt, + color, + align: a, + leftIn: padLeftIn, + widthIn: widthInForOp, + topInchesFromTrim: lineTopIn, + }); + } + } + } + }; + // Header. + const colWidthPx = tl.colWidthIn * dpi; + for (let c = 0; c < tl.colCount; c++) { + const colX = tableLeftIn * dpi + c * colWidthPx; + drawCell(tl.headerCellLines[c] || [], colX, yPx, colWidthPx, tl.headerRowH * dpi, tl.align[c] || 'left', true); + } + // Header bottom separator. + ctx.beginPath(); + ctx.strokeStyle = '#888'; + ctx.lineWidth = Math.max(1, dpi / 144); + ctx.moveTo(tableLeftIn * dpi, yPx + tl.headerRowH * dpi); + ctx.lineTo((tableLeftIn + tl.colWidthIn * tl.colCount) * dpi, yPx + tl.headerRowH * dpi); + ctx.stroke(); + yPx += tl.headerRowH * dpi; + // Rows. + for (const row of tl.rows) { + for (let c = 0; c < tl.colCount; c++) { + const colX = tableLeftIn * dpi + c * colWidthPx; + drawCell(row.cells[c] || [], colX, yPx, colWidthPx, row.rowH * dpi, tl.align[c] || 'left', false); + } + // Row bottom separator. + ctx.beginPath(); + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = Math.max(0.5, dpi / 200); + ctx.moveTo(tableLeftIn * dpi, yPx + row.rowH * dpi); + ctx.lineTo((tableLeftIn + tl.colWidthIn * tl.colCount) * dpi, yPx + row.rowH * dpi); + ctx.stroke(); + yPx += row.rowH * dpi; + } + continue; + } if (collect && fontKey) { // Emit as real PDF text — push positioned line ops, skip // the canvas raster for this block. PDF y is the baseline,