diff --git a/src/App.svelte b/src/App.svelte index 3b187a3..574ce2c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -17,6 +17,7 @@ import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks'; import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow'; import { getTextBoxDebug } from './lib/editor/extensions/textBox'; + import { getTableCellDebug } from './lib/editor/extensions/tableCellAlign'; import { getColorDebug } from './lib/utils/colorDebug'; import { resetHistoryLog } from './lib/utils/historyLog.svelte'; import { countText, type TextStats } from './lib/utils/wordCount'; @@ -653,7 +654,7 @@ try { const json = editor.getJSON() as Parameters[0]; const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl, noteSettings()); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -808,6 +809,11 @@ pageBreaks: snapshot, columnsFlow: getColumnsFlowDebug(editor.view), textBoxes: getTextBoxDebug(editor.view), + // Cell alignment reads as broken whenever the content fills the box, so the + // dump carries the spacing that decides that: the sheet and the model it uses. + tableCells: getTableCellDebug(editor.view), + spacingModel, + styles: styleSheet(), colors: getColorDebug(editor), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index ea8b2fa..f1dcfcd 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -67,7 +67,7 @@ One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, per ## Debug tooling (dev only) -In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry) and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans) — used to diagnose pagination and color round-trip issues. +In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry), `getTableCellDebug(view)` (per cell: the `verticalAlign` attr beside the computed value, cell/row height, the gap above and below the content, and every block's margins/`--space-before` — cell alignment reads as broken whenever the content fills the box, so the numbers that decide that travel with it), the style sheet + spacing model, and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans). ## Per-section header/footer diff --git a/src/lib/editor/extensions/tableCellAlign.ts b/src/lib/editor/extensions/tableCellAlign.ts index f64fc3b..fc94103 100644 --- a/src/lib/editor/extensions/tableCellAlign.ts +++ b/src/lib/editor/extensions/tableCellAlign.ts @@ -1,4 +1,5 @@ import { Extension } from '@tiptap/core'; +import type { EditorView } from '@tiptap/pm/view'; // Where a cell's content sits in its box: a `verticalAlign` attr on table cells, // rendered as the cell's CSS vertical-align. null = top, which is what both @@ -30,3 +31,98 @@ export const TableCellAlign = Extension.create({ ]; }, }); + +// What the debug dump reports per cell. gapAbove/gapBelow measure against the cell's content +// box, so a block's own margin reads as a gap the alignment cannot take back. +export type CellBlockDebug = { + tag: string; + styleName: string | null; + height: number; + marginTop: number; + marginBottom: number; + paddingTop: number; + paddingBottom: number; + spaceBefore: string; + lineHeight: string; +}; + +export type TableCellDebugEntry = { + pos: number; + row: number; + col: number; + textPreview: string; + verticalAlign: CellVerticalAlign | null; + inlineStyle: string | null; + computedVerticalAlign: string; + cellHeight: number; + paddingTop: number; + paddingBottom: number; + gapAbove: number; + gapBelow: number; + rowHeightAttr: number | null; + rowHeight: number; + blocks: CellBlockDebug[]; +}; + +const CELL_DEBUG_LIMIT = 120; +const px = (v: string) => Math.round(parseFloat(v) * 10) / 10 || 0; +const round1 = (v: number) => Math.round(v * 10) / 10; + +export function getTableCellDebug(view: EditorView): TableCellDebugEntry[] { + const out: TableCellDebugEntry[] = []; + if (typeof getComputedStyle !== 'function') return out; + view.state.doc.descendants((node, pos) => { + if (node.type.name !== 'tableCell' && node.type.name !== 'tableHeader') return; + if (out.length >= CELL_DEBUG_LIMIT) return false; + const td = view.nodeDOM(pos); + if (!(td instanceof HTMLElement)) return false; + const tr = td.parentElement; + const cs = getComputedStyle(td); + const cell = td.getBoundingClientRect(); + // The content box: what vertical-align distributes the free space in. + const top = cell.top + px(cs.borderTopWidth) + px(cs.paddingTop); + const bottom = cell.bottom - px(cs.borderBottomWidth) - px(cs.paddingBottom); + const blocks: CellBlockDebug[] = []; + let first: DOMRect | null = null; + let last: DOMRect | null = null; + for (const child of td.children) { + if (!(child instanceof HTMLElement) || child.dataset.pageBreakSpacer != null) continue; + const s = getComputedStyle(child); + // Resize handles and the like are absolutely positioned; only in-flow blocks count. + if (s.position === 'absolute') continue; + const r = child.getBoundingClientRect(); + first ??= r; + last = r; + blocks.push({ + tag: child.tagName, + styleName: child.dataset.style ?? null, + height: round1(r.height), + marginTop: px(s.marginTop), + marginBottom: px(s.marginBottom), + paddingTop: px(s.paddingTop), + paddingBottom: px(s.paddingBottom), + spaceBefore: s.getPropertyValue('--space-before').trim(), + lineHeight: s.lineHeight, + }); + } + out.push({ + pos, + row: tr?.parentElement ? [...tr.parentElement.children].indexOf(tr) : -1, + col: tr ? [...tr.children].indexOf(td) : -1, + textPreview: (node.textContent ?? '').slice(0, 40), + verticalAlign: (node.attrs.verticalAlign as CellVerticalAlign | null) ?? null, + inlineStyle: td.getAttribute('style'), + computedVerticalAlign: cs.verticalAlign, + cellHeight: round1(cell.height), + paddingTop: px(cs.paddingTop), + paddingBottom: px(cs.paddingBottom), + gapAbove: first ? round1(first.top - top) : 0, + gapBelow: last ? round1(bottom - last.bottom) : 0, + rowHeightAttr: (tr && view.state.doc.resolve(pos).parent.attrs.rowHeight as number | null) ?? null, + rowHeight: tr ? round1(tr.getBoundingClientRect().height) : 0, + blocks, + }); + return false; // cells hold blocks, not other cells + }); + return out; +}