mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
book(I2.2): full table layout in pagination + DOM + canvas + PDF
GFM tables are now first-class content blocks across every reflow
rendering surface: tile thumbnails, Reader Mode, PDF export with
real text emission. Replaces the I2.1 "[Table — N×M]" placeholder.
Pagination engine:
- For \`token.type === 'table'\`, computes equal-width columns
(availW / colCount), wraps each cell's text into lines at the
column's content width, and tracks row heights = max line count
per row × line height + cell padding.
- Returns the block with \`tokenType: 'table'\` and a \`tableLayout\`
payload carrying { colCount, colWidthIn, cellPadIn, align,
headerCellLines, rows, header/row line-heights, totalHeightIn }.
- blockH baked from the layout so the page-break logic treats
tables identically to other blocks.
DOM (paintReflowPageOverlay):
- Table blocks render as a real <table> with table-layout:fixed
and per-column text-align, header in bold with a solid bottom
border, body rows separated by hairline borders. white-space:
pre-wrap preserves the engine's wrap output.
Canvas (renderBookReflowPhysicalPageToCanvas):
- Each cell draws via fillText at the column's offset; alignment
honours the cell's column align directive. Hairline strokes
separate the header row + each body row so the table reads as
a grid even at lowish DPIs.
PDF real-text emission:
- When the block's font resolves to a StandardFonts key, each
cell's lines emit as textOps with leftIn / widthIn / align
scoped to the column's content rectangle. Header cells force
the bold variant so the printer sees real bold glyphs.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
d16c475dca
commit
4b2f124fde
1 file changed
+193
-9
+193
-9
@@ -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 <table> 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,
|
||||
|
||||
Reference in new issue
Block a user