feat: introduce columns

This commit is contained in:
Steffen Becker committed 2026-07-08 19:17:16 +00:00
1 parent 4c045135c1
commit de283adab5
18 files changed
+1801 -35

No files matched your search

+1 -1
View File
@@ -51,6 +51,7 @@
**Page & layout**
- Page margins (cm) and page orientation (portrait / landscape)
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols`
- Headers & footers with page-number / page-count fields and configurable edge distances
- Zoom (20–300 %)
- Show formatting marks (spaces, tabs, paragraph marks)
@@ -81,7 +82,6 @@
Planned word-processor features (to match Word/LibreOffice):
- Footnotes / endnotes (currently dropped on import)
- Clear formatting (remove all marks)
- Multi-column (newspaper) layout
- Comments / annotations (currently dropped on import)
- Horizontal rule
- Page numbering options (start value / format) + different first-page header
+2
View File
@@ -12,6 +12,7 @@
import { importOdt } from './lib/import/odt';
import { importDocx } from './lib/import/docx';
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
import { getColorDebug } from './lib/utils/colorDebug';
import { countText, type TextStats } from './lib/utils/wordCount';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
@@ -557,6 +558,7 @@
zoom,
doc: editor.getJSON(),
pageBreaks: snapshot,
columnsFlow: getColumnsFlowDebug(editor.view),
colors: getColorDebug(editor),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
+202
View File
@@ -16,6 +16,7 @@
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { listContext } from '../editor/extensions/indent';
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
@@ -612,6 +613,7 @@
lineHeightOpen = false;
fontColorOpen = false;
highlightColorOpen = false;
columnsOpen = false;
layoutOpen = !layoutOpen;
if (layoutOpen) {
syncMarginInputs();
@@ -635,6 +637,7 @@
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
columnsOpen = false;
if (which === 'font') highlightColorOpen = false;
else fontColorOpen = false;
}
@@ -652,6 +655,7 @@
highlightColorOpen = false;
layoutOpen = false;
specialCharOpen = false;
columnsOpen = false;
}
function insertTable(rows: number, cols: number, range: { from: number; to: number }) {
@@ -665,6 +669,86 @@
editor.chain().focus().setTableOfContents().run();
}
// --- Multi-column section (columns.ts) ---
let columnsOpen = $state(false);
let columnGapInput = $state('');
// Count/gap of the columns section around the cursor; count 1 = not in one.
let colState = $derived.by(() => {
if (tick < 0 || !editor) return { count: 1, gapCm: DEFAULT_COLUMN_GAP_CM, inColumns: false };
const found = findColumns(editor.state);
return found
? { count: found.node.attrs.count as number, gapCm: found.node.attrs.gapCm as number, inColumns: true }
: { count: 1, gapCm: DEFAULT_COLUMN_GAP_CM, inColumns: false };
});
function openColumnsPicker() {
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
fontColorOpen = false;
highlightColorOpen = false;
layoutOpen = false;
tableOpen = false;
specialCharOpen = false;
columnsOpen = !columnsOpen;
if (columnsOpen) columnGapInput = fmtCm(colState.gapCm);
}
function columnsClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) columnsOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
// The current gap straight from the document (colState may not have recomputed yet
// within the same handler after a command ran).
function liveColumnGap(): number {
const found = editor ? findColumns(editor.state) : null;
return (found?.node.attrs.gapCm as number | undefined) ?? DEFAULT_COLUMN_GAP_CM;
}
function applyColumnCount(n: number) {
if (!editor || hfActive) return;
editor.chain().focus().setColumns(n).run();
columnGapInput = fmtCm(liveColumnGap());
}
function setColumnGap(value: number) {
if (!editor) return;
editor.chain().focus().setColumnGap(value).run();
columnGapInput = fmtCm(liveColumnGap());
}
function commitColumnGapInput() {
const n = parseFloat(columnGapInput);
if (Number.isNaN(n)) {
columnGapInput = fmtCm(liveColumnGap()); // revert invalid input
return;
}
setColumnGap(n);
}
function onColumnGapKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
commitColumnGapInput();
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
columnGapInput = fmtCm(liveColumnGap());
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setColumnGap(liveColumnGap() + MARGIN_STEP);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setColumnGap(liveColumnGap() - MARGIN_STEP);
}
}
// --- Special character insertion (SpecialCharPicker.svelte) ---
let specialCharOpen = $state(false);
@@ -678,6 +762,7 @@
highlightColorOpen = false;
layoutOpen = false;
tableOpen = false;
columnsOpen = false;
}
function insertSpecialChar(char: string, range: { from: number; to: number }) {
@@ -1238,6 +1323,95 @@
<path d="M5.5 6.5h5M8 6.5v4" stroke="currentColor" stroke-linecap="round" />
</svg>
</button>
<div class="columns-wrap" use:columnsClickOutside>
<button
class:active={colState.inColumns}
onclick={openColumnsPicker}
disabled={!!hfActive}
title={hfActive ? t().toolbarExpanded.columnsNotInHf : t().toolbarExpanded.columns}
aria-label={t().toolbarExpanded.columns}
aria-haspopup="true"
aria-expanded={columnsOpen}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h5M2 6h5M2 8.5h5M2 11h5M2 13.5h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M9 3.5h5M9 6h5M9 8.5h5M9 11h5M9 13.5h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
{#if columnsOpen}
<div class="columns-dropdown">
<div class="lh-section-label">{t().toolbarExpanded.columns}</div>
<div class="columns-row">
<button
class="orientation-btn"
class:active={colState.count === 1}
aria-pressed={colState.count === 1}
onclick={() => applyColumnCount(1)}
title={t().toolbarExpanded.columnsOne}
>
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
<path d="M3 3h16M3 6h16M3 9h16M3 12h16M3 15h10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>1</span>
</button>
<button
class="orientation-btn"
class:active={colState.count === 2}
aria-pressed={colState.count === 2}
onclick={() => applyColumnCount(2)}
title={t().toolbarExpanded.columnsTwo}
>
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
<path d="M3 3h7M3 6h7M3 9h7M3 12h7M3 15h5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M12 3h7M12 6h7M12 9h7M12 12h7M12 15h5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>2</span>
</button>
<button
class="orientation-btn"
class:active={colState.count === 3}
aria-pressed={colState.count === 3}
onclick={() => applyColumnCount(3)}
title={t().toolbarExpanded.columnsThree}
>
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
<path d="M3 3h4M3 6h4M3 9h4M3 12h4M3 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M9 3h4M9 6h4M9 9h4M9 12h4M9 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M15 3h4M15 6h4M15 9h4M15 12h4M15 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>3</span>
</button>
</div>
<div class="lh-section-label">{t().toolbarExpanded.columnGap}</div>
<div class="columns-gap-row">
<div class="margin-input-wrap">
<input
type="text"
class="margin-input"
bind:value={columnGapInput}
disabled={!colState.inColumns}
onkeydown={onColumnGapKeydown}
onblur={commitColumnGapInput}
inputmode="decimal"
title={t().toolbarExpanded.columnGap}
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" disabled={!colState.inColumns} onclick={() => setColumnGap(liveColumnGap() + MARGIN_STEP)} title={t().toolbarExpanded.increaseShort} aria-label={t().toolbarExpanded.increase(t().toolbarExpanded.columnGap)}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="margin-step" tabindex="-1" disabled={!colState.inColumns} onclick={() => setColumnGap(liveColumnGap() - MARGIN_STEP)} title={t().toolbarExpanded.decreaseShort} aria-label={t().toolbarExpanded.decrease(t().toolbarExpanded.columnGap)}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
</div>
{/if}
</div>
<button
onclick={insertToc}
disabled={!!hfActive}
@@ -1731,6 +1905,34 @@
color: white;
}
.columns-wrap {
position: relative;
}
.columns-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
width: 200px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 2px 2px 6px;
}
.columns-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
padding: 4px 6px 2px;
}
.columns-gap-row {
padding: 4px 6px 2px;
}
.margin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
+10 -2
View File
@@ -41,11 +41,14 @@ import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
import { ColumnsFlow } from './extensions/columnsFlow';
import { TableOfContents } from './extensions/tableOfContents';
export const extensions = [
// textBox has its own group so only the document (not cells/lists) admits it.
Document.extend({ content: '(block | textBox)+' }),
// textBox and columns have their own groups so only the document (not
// cells/lists/boxes) admits them.
Document.extend({ content: '(block | textBox | columns)+' }),
Paragraph,
Text,
Bold,
@@ -73,6 +76,11 @@ export const extensions = [
// Block-level text box / basic shape with editable content; round-trips to ODF
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
TextBox,
// Multi-column (newspaper) section; round-trips to ODF <text:section> +
// style:columns and DOCX continuous sections with w:cols. ColumnsFlow splits a
// section into per-page fragments so the text flows across page breaks.
Columns,
ColumnsFlow,
// Generated table of contents from headings; round-trips to ODF
// <text:table-of-content> (export/odt.ts, import/odt.ts) and a DOCX TOC field.
TableOfContents,
+289
View File
@@ -0,0 +1,289 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { TextSelection } from '@tiptap/pm/state';
import type { EditorState, Transaction } from '@tiptap/pm/state';
// A multi-column (newspaper) section: a block region whose content flows through
// balanced CSS columns. The document may hold it as several adjacent fragments with
// equal attrs — a "chain" — so the flow can continue across page breaks
// (columnsFlow.ts splits/joins fragments); export merges a chain into one section.
export const DEFAULT_COLUMN_GAP_CM = 0.5;
export const MAX_COLUMN_GAP_CM = 5;
export const MAX_COLUMN_COUNT = 3;
export interface ColumnsAttrs {
count: number; // 2 or 3; a count-1 columns node never exists (1 unwraps)
gapCm: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
columns: {
setColumns: (count: number) => ReturnType;
setColumnGap: (gapCm: number) => ReturnType;
};
}
}
// Shared fit margin for the "does the first block fit the remaining page space"
// test: the balanced-height estimate (block height / count) undershoots the real
// fragment height by margins + line quantization. pageBreaks.ts (push vs. wait for
// the flow) and columnsFlow.ts (split vs. leave) MUST use the same margin, or the
// two disagree at the boundary and split/push/join cycle forever.
export const COLUMNS_FIT_MARGIN_PX = 32;
export function clampColumnCount(n: number): number {
return Math.min(MAX_COLUMN_COUNT, Math.max(2, Math.round(n)));
}
export function clampColumnGap(g: number): number {
return Math.min(MAX_COLUMN_GAP_CM, Math.max(0, g));
}
export function sameColumnsAttrs(a: PMNode, b: PMNode): boolean {
return a.attrs.count === b.attrs.count && a.attrs.gapCm === b.attrs.gapCm;
}
// The columns fragment enclosing the selection (any ancestor of the cursor).
export function findColumns(state: EditorState): { pos: number; node: PMNode } | null {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
const n = $from.node(d);
if (n.type.name === 'columns') return { pos: $from.before(d), node: n };
}
return null;
}
type Fragment = { pos: number; node: PMNode };
// The maximal run of adjacent equal-attr fragments containing the one at `pos` —
// the user-visible "section". Commands act on the whole chain.
export function columnsChain(state: EditorState, pos: number): Fragment[] {
const all: Fragment[] = [];
state.doc.forEach((node, offset) => {
if (node.type.name === 'columns') all.push({ pos: offset, node });
});
const idx = all.findIndex((f) => f.pos === pos);
if (idx < 0) return [];
let lo = idx;
while (
lo > 0 &&
all[lo - 1].pos + all[lo - 1].node.nodeSize === all[lo].pos &&
sameColumnsAttrs(all[lo - 1].node, all[lo].node)
) lo--;
let hi = idx;
while (
hi + 1 < all.length &&
all[hi].pos + all[hi].node.nodeSize === all[hi + 1].pos &&
sameColumnsAttrs(all[hi].node, all[hi + 1].node)
) hi++;
return all.slice(lo, hi + 1);
}
const WRAPPABLE = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
// Top-level blocks of [start, end) as columns content: wrappable blocks collected
// as-is, existing columns fragments absorbed (their content joins the run);
// null when the range holds anything else (table, text box, TOC).
function collectWrappable(state: EditorState, start: number, end: number): { blocks: PMNode[]; from: number; to: number } | null {
const blocks: PMNode[] = [];
let ok = true;
let from = -1;
let to = -1;
state.doc.nodesBetween(start, end, (n, pos, parent) => {
if (parent?.type.name !== 'doc') return false;
if (from < 0) from = pos;
to = pos + n.nodeSize;
if (WRAPPABLE.has(n.type.name)) blocks.push(n);
else if (n.type.name === 'columns') blocks.push(...childArray(n));
else ok = false;
return false;
});
return ok && blocks.length ? { blocks: mergeJoinedBlocks(blocks), from, to } : null;
}
function childArray(n: PMNode): PMNode[] {
const out: PMNode[] = [];
n.forEach((c) => out.push(c));
return out;
}
// Re-merge paragraphs split by columnsFlow's page-boundary line split (the second
// part carries joinPrev). Used wherever fragment content leaves the chain context:
// unwrap, absorb-into-a-new-section; export does the same on the JSON level.
export function mergeJoinedBlocks(blocks: PMNode[]): PMNode[] {
const out: PMNode[] = [];
for (const b of blocks) {
const prev = out[out.length - 1];
if (
b.type.name === 'paragraph' && b.attrs.joinPrev &&
prev?.type.name === 'paragraph'
) {
out[out.length - 1] = prev.type.create(prev.attrs, prev.content.append(b.content));
} else {
out.push(b);
}
}
return out;
}
// Word's no-selection behavior: apply columns to the whole document. Wrappable runs
// (absorbing existing sections) become one section each; tables/boxes/TOCs stay
// between them. Replacements run bottom-up so earlier positions stay valid.
function applyToWholeDoc(state: EditorState, tr: Transaction, attrs: ColumnsAttrs): boolean {
const type = state.schema.nodes.columns;
type Run = { from: number; to: number; blocks: PMNode[] };
const runs: Run[] = [];
let run: Run | null = null;
state.doc.forEach((n, offset) => {
if (WRAPPABLE.has(n.type.name) || n.type.name === 'columns') {
const blocks = n.type.name === 'columns' ? childArray(n) : [n];
if (run) { run.to = offset + n.nodeSize; run.blocks.push(...blocks); }
else run = { from: offset, to: offset + n.nodeSize, blocks };
} else if (run) {
runs.push(run);
run = null;
}
});
if (run) runs.push(run);
if (!runs.length) return false;
for (const r of [...runs].reverse()) {
tr.replaceWith(r.from, r.to, type.create(attrs, mergeJoinedBlocks(r.blocks)));
}
return true;
}
export const Columns = Node.create({
name: 'columns',
// Own group (not `block`): only the doc admits it, so it can't nest in table
// cells, list items, text boxes, or other columns sections.
group: 'columns',
content: '(paragraph | heading | bulletList | orderedList)+',
// Not isolating/selectable: the section must feel like flowing text, not an
// object — selection, Backspace, and joins cross fragment boundaries freely
// (a join merges fragments; columnsFlow re-splits as needed).
isolating: false,
defining: true,
selectable: false,
addGlobalAttributes() {
return [
{
types: ['paragraph'],
attributes: {
// Layout-internal: marks the second part of a paragraph that columnsFlow
// split at a page boundary. Never rendered/parsed; export and unwrap
// merge a joinPrev paragraph back into its predecessor.
joinPrev: {
default: false,
rendered: false,
keepOnSplit: false,
},
},
},
];
},
addAttributes() {
return {
count: {
default: 2,
parseHTML: el => {
const n = parseInt((el as HTMLElement).getAttribute('data-columns') ?? '', 10);
return Number.isFinite(n) ? clampColumnCount(n) : 2;
},
renderHTML: () => ({}),
},
gapCm: {
default: DEFAULT_COLUMN_GAP_CM,
parseHTML: el => {
const g = parseFloat((el as HTMLElement).getAttribute('data-column-gap') ?? '');
return Number.isFinite(g) ? clampColumnGap(g) : DEFAULT_COLUMN_GAP_CM;
},
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-columns]' }];
},
renderHTML({ HTMLAttributes, node }) {
const a = node.attrs as ColumnsAttrs;
return ['div', mergeAttributes(HTMLAttributes, {
'data-columns': String(a.count),
'data-column-gap': String(a.gapCm),
class: 'columns-node',
style: `column-count:${a.count};column-gap:${a.gapCm}cm`,
}), 0];
},
addCommands() {
return {
// Inside a section: 1 unwraps the whole chain, 2/3 update its count. Outside:
// with a selection, 2/3 wrap the covered top-level blocks (fails on tables
// etc.); with a bare cursor, the whole document gets columns (Word behavior).
setColumns:
(count: number) =>
({ state, dispatch }) => {
const type = state.schema.nodes.columns;
if (!type) return false;
const found = findColumns(state);
if (found) {
const chain = columnsChain(state, found.pos);
if (dispatch) {
const tr = state.tr;
if (count <= 1) {
const first = chain[0];
const last = chain[chain.length - 1];
const blocks = mergeJoinedBlocks(chain.flatMap((f) => childArray(f.node)));
tr.replaceWith(first.pos, last.pos + last.node.nodeSize, blocks);
} else {
const attrs = { ...found.node.attrs, count: clampColumnCount(count) };
for (const f of chain) tr.setNodeMarkup(f.pos, undefined, attrs);
}
dispatch(tr.scrollIntoView());
}
return true;
}
if (count <= 1) return false;
const attrs: ColumnsAttrs = { count: clampColumnCount(count), gapCm: DEFAULT_COLUMN_GAP_CM };
const { $from, $to, empty } = state.selection;
if (empty) {
const tr = state.tr;
if (!applyToWholeDoc(state, tr, attrs)) return false;
if (dispatch) {
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min($from.pos, tr.doc.content.size))));
dispatch(tr.scrollIntoView());
}
return true;
}
if ($from.depth < 1 || $to.depth < 1) return false;
const covered = collectWrappable(state, $from.before(1), $to.after(1));
if (!covered) return false;
if (dispatch) {
const tr = state.tr.replaceWith(covered.from, covered.to, type.create(attrs, covered.blocks));
tr.setSelection(TextSelection.near(tr.doc.resolve(covered.from + 1)));
dispatch(tr.scrollIntoView());
}
return true;
},
setColumnGap:
(gapCm: number) =>
({ state, dispatch }) => {
const found = findColumns(state);
if (!found || !Number.isFinite(gapCm)) return false;
if (dispatch) {
const tr = state.tr;
const attrs = { ...found.node.attrs, gapCm: clampColumnGap(gapCm) };
for (const f of columnsChain(state, found.pos)) tr.setNodeMarkup(f.pos, undefined, attrs);
dispatch(tr);
}
return true;
},
};
},
});
+528
View File
@@ -0,0 +1,528 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { Transaction } from '@tiptap/pm/state';
import { canJoin, canSplit } from '@tiptap/pm/transform';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import type { Node as PMNode } from '@tiptap/pm/model';
import { readVerticalMargins, FORCE_PAGE_RECALC } from './pageBreaks';
import { sameColumnsAttrs, COLUMNS_FIT_MARGIN_PX } from './columns';
// Cross-page column flow: the document holds a columns section as adjacent
// equal-attr fragments ("chain"). This layout pass keeps the fragmentation in sync
// with the page grid — an overflowing fragment is split at a block boundary (or,
// for a paragraph taller than the page slot, at a LINE boundary: the second part
// carries the layout-internal joinPrev attr and is re-merged by joins/export), and
// a fragment with room pulls the continuation back. Splits/joins are pure layout:
// they never enter the undo history, and export merges a chain into one section.
//
// Fill order is sequential like Word (column 1 fills to the page bottom, then
// column 2, then the next page): fragments get a node decoration with an explicit
// height + column-fill:auto — full page slot for fragments with a continuation,
// content height for the open last fragment of a document-final chain (so typing
// grows column 1 first). A chain followed by real content keeps CSS balance on its
// last fragment (Word balances the end of a continuous section).
const flowKey = new PluginKey<number>('columnsFlow');
const FLOW_TX = 'columnsFlowTx';
// Split underfill guard: the balanced-height estimate (sum of block heights / count)
// ignores line quantization, so leave a little headroom.
const SAFETY_PX = 16;
// A pull-back (join) must fit by clearly more than the estimate error (~one line +
// margins), or a join whose real height exceeds the estimate re-splits forever.
const HYSTERESIS_PX = 40;
const MIN_BLOCK_PX = 16;
const DEFAULT_LINE_PX = 19;
// Height slack on the open last fragment: a freshly typed line must still fit
// column 1 before the next pass grows the decoration, or every line flashes into
// column 2 for one frame.
const OPEN_HEIGHT_SLACK_PX = 40;
// Below this many lines of leftover room, a boundary block splits at the block
// boundary instead of pulling a line or two over (not worth a paragraph split).
const MIN_TAIL_LINES = 2;
// Pass budget per external change: each pass performs one split/join, so a paste
// spanning many pages needs several; bounded against estimate-driven oscillation.
const MAX_FLOW_PASSES = 48;
type Fragment = { pos: number; node: PMNode };
// Live per-fragment view of the flow's decisions, for the dev Debug dump.
export type ColumnsFlowDebug = {
timestamp: string;
fragments: Array<{
pos: number;
blocks: number;
page: number;
top: number;
availablePx: number;
contentPx: number;
usedPerColumnPx: number;
count: number;
hasNext: boolean;
openAtDocEnd: boolean;
decorated: boolean;
firstBlockNeedPx: number;
overflow: boolean;
join: null | { needPx: number; headIsJoinPrev: boolean; roomPx: number; wouldJoin: boolean };
}>;
};
const flowDebugAccessors = new WeakMap<EditorView, () => ColumnsFlowDebug | null>();
export function getColumnsFlowDebug(view: EditorView): ColumnsFlowDebug | null {
return flowDebugAccessors.get(view)?.() ?? null;
}
// A block's flowed height in document px: sum of its column fragments' rects
// (a block split across columns yields one rect per column).
function blockHeightPx(el: Element, scale: number): number {
let h = 0;
for (const r of Array.from(el.getClientRects())) h += r.height;
return h / scale;
}
// Total content height of a fragment's blocks as poured into one column flow:
// block rects plus the collapsed inter-block margins (computed style, unscaled).
function contentHeightPx(children: Element[], scale: number): number {
let sum = 0;
let prevMb = 0;
for (let i = 0; i < children.length; i++) {
const cs = getComputedStyle(children[i]);
const mt = parseFloat(cs.marginTop) || 0;
const mb = parseFloat(cs.marginBottom) || 0;
sum += blockHeightPx(children[i], scale);
if (i > 0) sum += Math.max(prevMb, mt);
prevMb = mb;
}
return sum;
}
// One rect per rendered text line of `el`, in document order (multicol renders
// column 1's lines before column 2's, so a new line = a top jump in either
// direction). Viewport coordinates.
function lineRects(el: Element): { left: number; top: number; height: number }[] {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const lines: { left: number; top: number; height: number }[] = [];
let lastTop = -Infinity;
let textNode: Node | null;
while ((textNode = walker.nextNode())) {
if (!textNode.textContent) continue;
const range = document.createRange();
range.selectNodeContents(textNode);
for (const r of Array.from(range.getClientRects())) {
if (r.width <= 0 || r.height <= 0) continue;
if (Math.abs(r.top - lastTop) > 2) {
lines.push({ left: r.left, top: r.top, height: r.height });
lastTop = r.top;
}
}
}
return lines;
}
// Median top-to-top line advance (viewport px); 0 when there are too few lines.
function lineAdvance(lines: { top: number }[]): number {
const gaps: number[] = [];
for (let i = 1; i < lines.length; i++) {
const g = lines[i].top - lines[i - 1].top;
if (g > 2) gaps.push(g);
}
gaps.sort((a, b) => a - b);
return gaps.length ? gaps[gaps.length >> 1] : 0;
}
export const ColumnsFlow = Extension.create({
name: 'columnsFlow',
addProseMirrorPlugins() {
let rafId: number | null = null;
let passes = 0;
let decorations = DecorationSet.empty;
let lastDecoKey = '';
const plugin = new Plugin<number>({
key: flowKey,
// Counts external changes (user edits, margin/orientation changes) so the
// view hook can reset the pass budget; the plugin's own transactions don't.
state: {
init: () => 0,
apply(tr, value) {
if (tr.docChanged) decorations = decorations.map(tr.mapping, tr.doc);
if ((tr.docChanged && !tr.getMeta(FLOW_TX)) || tr.getMeta(FORCE_PAGE_RECALC)) return value + 1;
return value;
},
},
props: {
decorations() {
return decorations;
},
},
view(editorView) {
function fragments(): Fragment[] {
const out: Fragment[] = [];
editorView.state.doc.forEach((node, offset) => {
if (node.type.name === 'columns') out.push({ pos: offset, node });
});
return out;
}
// Border-box top within .tiptap in document px (offsetTop chain — immune
// to the .paper zoom transform, includes rendered spacers above).
function topWithin(el: HTMLElement): number {
let top = 0;
let node: HTMLElement | null = el;
while (node && node !== editorView.dom) {
top += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return top;
}
function getScale(): number {
const r = editorView.dom.getBoundingClientRect();
const h = editorView.dom.offsetHeight;
return h ? r.height / h : 1;
}
function dispatchFlow(tr: Transaction): void {
editorView.dispatch(tr.setMeta('addToHistory', false).setMeta(FLOW_TX, true));
}
function hasNextInChain(frags: Fragment[], i: number): boolean {
const next = frags[i + 1];
return !!next &&
next.pos === frags[i].pos + frags[i].node.nodeSize &&
sameColumnsAttrs(frags[i].node, next.node);
}
// True when nothing but empty paragraphs follows the fragment — the chain
// is "open" at the document end and fills sequentially while typing.
function openAtDocEnd(frag: Fragment): boolean {
const after = frag.pos + frag.node.nodeSize;
let open = true;
editorView.state.doc.forEach((n, off) => {
if (off < after) return;
if (n.type.name === 'columns') return; // continuation, judged via its own end
if (!(n.type.name === 'paragraph' && n.content.size === 0)) open = false;
});
return open;
}
// Split the paragraph at child `blockIndex` at the line whose prefix
// consumes `budgetPx` of content flow (Word's mid-paragraph page break);
// the second part is marked joinPrev so joins/export restore the original.
// `force` takes at least one line even when the budget is tiny (progress
// guard for a paragraph taller than the whole page slot).
function splitParagraphInBlock(
frag: Fragment, children: Element[], blockIndex: number, budgetPx: number,
scale: number, force: boolean,
): boolean {
const block = frag.node.child(blockIndex);
if (block.type.name !== 'paragraph') return false;
const blockEl = children[blockIndex];
if (!blockEl) return false;
const lines = lineRects(blockEl);
if (lines.length < 2) return false;
const advance = (lineAdvance(lines) || DEFAULT_LINE_PX * scale) / scale;
let budgetLines = Math.floor(budgetPx / advance);
if (budgetLines < MIN_TAIL_LINES) {
if (!force) return false;
budgetLines = 1;
}
const prefixLines = Math.min(Math.max(budgetLines, 1), lines.length - 1);
const target = lines[prefixLines];
const coords = editorView.posAtCoords({ left: target.left + 1, top: target.top + target.height / 2 });
if (!coords) return false;
const pos = coords.pos;
let blockStart = frag.pos + 1;
for (let j = 0; j < blockIndex; j++) blockStart += frag.node.child(j).nodeSize;
if (pos <= blockStart + 1 || pos >= blockStart + block.nodeSize - 1) return false;
const typesAfter = [
{ type: frag.node.type, attrs: frag.node.attrs },
{ type: block.type, attrs: { ...block.attrs, joinPrev: true } },
];
if (!canSplit(editorView.state.doc, pos, 2, typesAfter)) return false;
dispatchFlow(editorView.state.tr.split(pos, 2, typesAfter));
return true;
}
// One reflow step: fix the first out-of-sync boundary (merge a stray
// joinPrev part, split an overflowing fragment, or pull a continuation
// back) and dispatch. Convergence comes from re-running until quiescent.
function reflow(): boolean {
if (editorView.composing || !editorView.dom.isConnected) return false;
const frags = fragments();
if (!frags.length) return false;
const vm = readVerticalMargins(editorView.dom);
const scale = getScale();
for (let i = 0; i < frags.length; i++) {
const { pos, node } = frags[i];
// A joinPrev paragraph inside a fragment (left over after a fragment
// join) merges into its predecessor right away.
let childPos = pos + 1;
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j);
if (
j > 0 && child.type.name === 'paragraph' && child.attrs.joinPrev &&
node.child(j - 1).type.name === 'paragraph' &&
canJoin(editorView.state.doc, childPos)
) {
dispatchFlow(editorView.state.tr.join(childPos));
return true;
}
childPos += child.nodeSize;
}
const el = editorView.nodeDOM(pos) as HTMLElement | null;
if (!el || !el.classList.contains('columns-node')) continue;
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
if (top < contentStart - 0.5 || top >= contentEnd) continue;
const available = contentEnd - top;
const count = Math.max(1, node.attrs.count as number);
const children = Array.from(el.children).filter(
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
);
if (children.length !== node.childCount) continue;
// The rendered box height is our own decoration, so overflow and room
// are judged from the content itself (per-column share of the flow).
const usedPerColumn = contentHeightPx(children, scale) / count;
if (usedPerColumn > available + 1) {
// Overflow. Same first-block fit test (incl. margin) as pageBreaks'
// push decision — the two must agree, or split/push/join cycle.
const first = Math.max(blockHeightPx(children[0], scale) / count, MIN_BLOCK_PX);
if (first + COLUMNS_FIT_MARGIN_PX > available) {
// First block alone doesn't fit. Mid-page, pageBreaks pushes the
// fragment; at a page top pushing can't help — split the paragraph
// at a line boundary instead (Word's mid-paragraph page break).
if (
available >= vm.contentHeight - 1 &&
splitParagraphInBlock(frags[i], children, 0, count * (available - SAFETY_PX), scale, true)
) {
return true;
}
continue;
}
if (node.childCount < 2) continue;
let sum = 0;
let k = 0;
for (let j = 0; j < children.length - 1; j++) {
const h = blockHeightPx(children[j], scale);
if ((sum + h) / count > available - SAFETY_PX && j > 0) break;
sum += h;
k = j + 1;
}
// Leftover room after k whole blocks: fill it with the boundary
// block's first lines (line split) rather than leaving it empty.
const tailBudget = count * (available - SAFETY_PX) - sum;
if (k < node.childCount && splitParagraphInBlock(frags[i], children, k, tailBudget, scale, false)) {
return true;
}
let boundary = pos + 1;
for (let j = 0; j < k; j++) boundary += node.child(j).nodeSize;
const typesAfter = [{ type: node.type, attrs: node.attrs }];
if (!canSplit(editorView.state.doc, boundary, 1, typesAfter)) continue;
dispatchFlow(editorView.state.tr.split(boundary, 1, typesAfter));
return true;
}
// Fits: pull the adjacent continuation back when its head would
// clearly fit too (the overflow branch then re-splits further down).
if (hasNextInChain(frags, i)) {
const next = frags[i + 1];
const nextEl = editorView.nodeDOM(next.pos) as HTMLElement | null;
const nextFirst = nextEl?.firstElementChild;
if (!nextFirst) continue;
const joinPos = pos + node.nodeSize;
const headBlock = next.node.child(0);
const headIsJoinPrev =
headBlock.type.name === 'paragraph' && headBlock.attrs.joinPrev &&
node.child(node.childCount - 1).type.name === 'paragraph';
// A joinPrev head needs only one more line row to make progress; a
// multi-line paragraph head can be pulled PARTIALLY (the overflow
// split hands the excess back via a line split), so ~two line rows
// of room justify the pull; any other block needs its full share.
const headLines = headBlock.type.name === 'paragraph' ? lineRects(nextFirst) : [];
const headAdvance = (lineAdvance(headLines) || DEFAULT_LINE_PX * scale) / scale;
const need = headIsJoinPrev
? Math.max(headAdvance, MIN_BLOCK_PX)
: headLines.length >= 2
? Math.max(MIN_TAIL_LINES * headAdvance, MIN_BLOCK_PX)
: Math.max(blockHeightPx(nextFirst, scale) / count, MIN_BLOCK_PX);
if (usedPerColumn + need <= available - HYSTERESIS_PX && canJoin(editorView.state.doc, joinPos)) {
// Depth 2 rejoins the split paragraph in the same step.
const tr = editorView.state.tr;
try {
tr.join(joinPos, headIsJoinPrev ? 2 : 1);
} catch {
continue;
}
dispatchFlow(tr);
return true;
}
}
}
return false;
}
// Sequential-fill heights: a fragment with a continuation spans its page
// slot; the open last fragment of a document-final chain hugs its content
// (plus slack) so typing fills column 1 first. A chain followed by real
// content keeps its last fragment undecorated (CSS balance).
function updateDecorations(): void {
const frags = fragments();
const vm = readVerticalMargins(editorView.dom);
const scale = getScale();
const items: { from: number; to: number; height: number }[] = [];
for (let i = 0; i < frags.length; i++) {
const { pos, node } = frags[i];
const el = editorView.nodeDOM(pos) as HTMLElement | null;
if (!el || !el.classList.contains('columns-node')) continue;
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
if (top < contentStart - 0.5 || top >= contentEnd) continue;
const available = contentEnd - top;
const count = Math.max(1, node.attrs.count as number);
const children = Array.from(el.children).filter(
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
);
let height: number;
if (hasNextInChain(frags, i)) {
height = available;
} else if (openAtDocEnd(frags[i])) {
// Full single-flow content height: while it's below the page slot,
// everything pours into column 1 (column 2 only once page 1 is full).
const contentH = contentHeightPx(children, scale);
height = Math.min(available, contentH + OPEN_HEIGHT_SLACK_PX);
} else {
continue; // balanced end of a mid-document section
}
items.push({ from: pos, to: pos + node.nodeSize, height: Math.round(height) });
}
const key = items.map((d) => `${d.from}:${d.to}:${d.height}`).join('|');
if (key === lastDecoKey) return;
lastDecoKey = key;
decorations = items.length
? DecorationSet.create(editorView.state.doc, items.map((d) =>
Decoration.node(d.from, d.to, {
style: `height:${d.height}px;column-fill:auto;overflow:hidden`,
})))
: DecorationSet.empty;
// Re-render + let pageBreaks re-measure the new box heights.
editorView.dispatch(
editorView.state.tr.setMeta(FLOW_TX, true).setMeta(FORCE_PAGE_RECALC, true),
);
}
// Live analysis of every fragment (same math as reflow/updateDecorations),
// exposed to the dev Debug dump via getColumnsFlowDebug.
function buildDebug(): ColumnsFlowDebug | null {
if (!editorView.dom.isConnected) return null;
const frags = fragments();
const vm = readVerticalMargins(editorView.dom);
const scale = getScale();
const out: ColumnsFlowDebug['fragments'] = [];
for (let i = 0; i < frags.length; i++) {
const { pos, node } = frags[i];
const el = editorView.nodeDOM(pos) as HTMLElement | null;
if (!el || !el.classList.contains('columns-node')) continue;
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentEnd = (page - 1) * vm.cycle + vm.top + vm.contentHeight;
const available = contentEnd - top;
const count = Math.max(1, node.attrs.count as number);
const children = Array.from(el.children).filter(
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
);
const contentPx = contentHeightPx(children, scale);
const usedPerColumn = contentPx / count;
const hasNext = hasNextInChain(frags, i);
let join: ColumnsFlowDebug['fragments'][number]['join'] = null;
if (hasNext) {
const next = frags[i + 1];
const nextEl = editorView.nodeDOM(next.pos) as HTMLElement | null;
const nextFirst = nextEl?.firstElementChild;
if (nextFirst) {
const headBlock = next.node.child(0);
const headIsJoinPrev =
headBlock.type.name === 'paragraph' && headBlock.attrs.joinPrev &&
node.child(node.childCount - 1).type.name === 'paragraph';
const headLines = headBlock.type.name === 'paragraph' ? lineRects(nextFirst) : [];
const headAdvance = (lineAdvance(headLines) || DEFAULT_LINE_PX * scale) / scale;
const needPx = headIsJoinPrev
? Math.max(headAdvance, MIN_BLOCK_PX)
: headLines.length >= 2
? Math.max(MIN_TAIL_LINES * headAdvance, MIN_BLOCK_PX)
: Math.max(blockHeightPx(nextFirst, scale) / count, MIN_BLOCK_PX);
const roomPx = available - HYSTERESIS_PX - usedPerColumn;
join = { needPx: Math.round(needPx), headIsJoinPrev, roomPx: Math.round(roomPx), wouldJoin: usedPerColumn + needPx <= available - HYSTERESIS_PX };
}
}
out.push({
pos,
blocks: node.childCount,
page,
top: Math.round(top),
availablePx: Math.round(available),
contentPx: Math.round(contentPx),
usedPerColumnPx: Math.round(usedPerColumn),
count,
hasNext,
openAtDocEnd: openAtDocEnd(frags[i]),
decorated: /column-fill:\s*auto/.test(el.style.cssText),
firstBlockNeedPx: Math.round(children[0] ? Math.max(blockHeightPx(children[0], scale) / count, MIN_BLOCK_PX) : 0),
overflow: usedPerColumn > available + 1,
join,
});
}
return { timestamp: new Date().toISOString(), fragments: out };
}
flowDebugAccessors.set(editorView, buildDebug);
function run() {
rafId = null;
if (passes < MAX_FLOW_PASSES && reflow()) {
passes++;
return; // geometry changes; decorations follow next pass
}
updateDecorations();
}
function schedule() {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(run);
}
schedule();
return {
update(_view, prevState) {
if (flowKey.getState(prevState) !== flowKey.getState(editorView.state)) {
passes = 0; // fresh budget per external change
}
schedule();
},
destroy() {
if (rafId !== null) cancelAnimationFrame(rafId);
flowDebugAccessors.delete(editorView);
},
};
},
});
return [plugin];
},
});
+89 -7
View File
@@ -1,6 +1,7 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { COLUMNS_FIT_MARGIN_PX } from './columns';
export type PageBreakDebugSnapshot = {
timestamp: string;
@@ -120,7 +121,7 @@ const PAGE_GAP = 20;
const DEFAULT_MARGIN_TOP = 96;
const DEFAULT_MARGIN_BOTTOM = 96;
type VMargins = {
export type VMargins = {
top: number;
bottom: number;
contentHeight: number;
@@ -131,7 +132,8 @@ type VMargins = {
// Reads vertical page margins + page height (px) from the --user-* props the Layout
// panel writes. Document-px values, so they pair directly with offsetTop below. Side
// margins don't affect pagination (only line wrapping), so they're ignored.
function readVerticalMargins(dom: HTMLElement): VMargins {
// Exported for columnsFlow.ts, which shares this geometry.
export function readVerticalMargins(dom: HTMLElement): VMargins {
const cs = getComputedStyle(dom);
const top = parseFloat(cs.getPropertyValue('--user-margin-top'));
const bottom = parseFloat(cs.getPropertyValue('--user-margin-bottom'));
@@ -188,6 +190,11 @@ type Leaf = {
cellStart?: boolean;
rowStart?: boolean;
rowEnd?: boolean;
// A multi-column section fragment (columns.ts). columnsFlow.ts splits an
// overflowing fragment at a block boundary, so pagination must NOT push it when
// the flow can act: it only pushes when the first block alone can't fit the
// remaining page space (then the flow splits it at the next page top).
columnsFragment?: { blockCount: number; firstBlockNeededPx: number };
};
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
@@ -513,7 +520,7 @@ export const PageBreaks = Extension.create({
return { naturalLineTop, docPos: lo };
}
function collectLeaves(contentHeight: number): Leaf[] {
function collectLeaves(contentHeight: number, scaleFactor: number): Leaf[] {
const dom = editorView.dom;
const leaves: Leaf[] = [];
// cumulativeSpacerHeight accumulates spacer offsetHeights (unscaled
@@ -643,8 +650,8 @@ export const PageBreaks = Extension.create({
});
continue;
}
// A text box (textBox.ts) paginates atomically: its content lives inside a
// rotated/floated node view where a line-split spacer can't render sanely.
// A text box paginates atomically: a line-split spacer can't render
// sanely inside a rotated/floated node view.
if (child.classList.contains('textbox-node')) {
leaves.push({
el: child,
@@ -655,6 +662,30 @@ export const PageBreaks = Extension.create({
});
continue;
}
// A multi-column section fragment: also atomic for spacer purposes (a
// spacer inside a multicol container rebalances around it), but the
// placement logic defers overflow to columnsFlow.ts via columnsFragment.
if (child.classList.contains('columns-node')) {
const count = Math.max(1, parseInt(child.dataset?.columns ?? '', 10) || 1);
const first = child.firstElementChild as HTMLElement | null;
let firstPx = 0;
if (first) {
for (const r of Array.from(first.getClientRects())) firstPx += r.height;
firstPx = firstPx / scaleFactor / count;
}
leaves.push({
el: child,
kind: 'atomic',
naturalTop: topWithin(child) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight,
inTableCell,
columnsFragment: {
blockCount: child.children.length,
firstBlockNeededPx: Math.max(firstPx, 16),
},
});
continue;
}
// A paragraph holding a floated/block image paginates atomically (pushed
// whole, spacer placed before it): a line-split spacer inside it would sit
// next to the float, where ProseMirror drops the widget.
@@ -729,7 +760,7 @@ export const PageBreaks = Extension.create({
const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80);
const scale = getScaleFactor();
const leaves = collectLeaves(CONTENT_HEIGHT);
const leaves = collectLeaves(CONTENT_HEIGHT, scale);
let cumulativeShift = 0;
// Per-cell flow bracketing for a too-tall row (see Leaf flow markers): each
@@ -783,6 +814,45 @@ export const PageBreaks = Extension.create({
// Set when a leaf overflows but no spacer can bridge it (block > one page).
let noPushReason: string | null = null;
// The empty trailing paragraph after a document-final columns chain: its
// fragment box may run to the page bottom (sequential fill), which would
// shove this invisible paragraph onto a phantom extra page. Leave it in
// place and keep it out of the page count; typed text re-enables it.
if (
i === leaves.length - 1 &&
leaf.el.tagName === 'P' &&
!(leaf.el.textContent ?? '').length &&
leaves[i - 1]?.columnsFragment
) {
leavesDebug.push({
index: i,
tag: leaf.el.tagName,
kind: leaf.kind,
inTableCell: !!leaf.inTableCell,
tableRow: null,
naturalTop: leaf.naturalTop,
naturalHeight: leaf.naturalHeight,
textPreview: '',
intraSpacerHeights: [],
effectiveTop,
effectiveBottom,
pageOfTop: page,
pageOfBottom: getPageForY(effectiveBottom, CYCLE_PX),
contentStart,
contentEnd,
overflowsPageEnd: effectiveBottom > contentEnd,
});
placementsDebug.push({
leafIndex: i,
docPos: null,
height: 0,
reason: 'trailing-empty-after-columns',
spacerKind: 'none',
columns: null,
});
continue;
}
// A manual page break forces the block to the next page's top (never the first
// leaf, so no leading blank page); once settled at a page top the guard stops.
// A forced block that then overflows is split by the normal logic next pass.
@@ -819,7 +889,19 @@ export const PageBreaks = Extension.create({
});
} else if (effectiveBottom > contentEnd) {
if (leaf.kind === 'atomic') {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
const cf = leaf.columnsFragment;
if (cf && cf.blockCount > 1 && cf.firstBlockNeededPx + COLUMNS_FIT_MARGIN_PX <= contentEnd - effectiveTop) {
// columnsFlow.ts will split the fragment so a prefix fills this
// page; pushing it whole here would fight that.
noPushReason = 'columns-await-flow';
} else if (cf && effectiveTop <= contentStart + 0.5) {
// Already at a page top and still too tall (a paragraph taller
// than the page slot): pushing just moves the problem one page
// down forever — the flow line-splits the paragraph instead.
noPushReason = 'columns-line-split-pending';
} else if (leaf.naturalHeight <= CONTENT_HEIGHT || cf) {
// A columns fragment may be pushed even when taller than a page:
// the flow splits it again once it sits at the next page top.
const target = pageContentStart(page + 1, vm.top, CYCLE_PX);
const { docPos, row } = leafSpacer(leaf);
breaks.push({
+1 -1
View File
@@ -7,7 +7,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
const trailingNodeKey = new PluginKey('trailingNode');
// Block types that can't sit at the document end without trapping the cursor.
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox']);
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox', 'columns']);
export const TrailingNode = Extension.create({
name: 'trailingNode',
+79 -17
View File
@@ -3,7 +3,7 @@ import {
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber,
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
TextWrappingType, TextWrappingSide,
} from 'docx';
@@ -21,7 +21,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
import { normalizeColor, HEADING_STYLE_OVERRIDES, mergeJoinedParagraphsJson, type HfExport } from './odt';
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
@@ -732,6 +732,56 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
return out;
}
// One body section: a run of ordinary blocks (columns: null) or one columns node's
// content. DOCX has no block-level column container — a mid-document multi-column
// region is its own section, delimited by continuous section breaks.
type BodyGroup = {
columns: { count: number; gapCm: number } | null;
children: (Paragraph | Table | TableOfContents)[];
};
function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): BodyGroup[] {
const groups: BodyGroup[] = [];
let plain: TiptapNode[] = [];
// Adjacent equal-attr fragments (a columnsFlow page-split chain) = one section;
// their blocks are collected raw so page-split paragraphs merge before serializing.
let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null;
const flushPlain = () => {
if (!plain.length) return;
groups.push({ columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
plain = [];
};
const flushCols = () => {
if (!cols) return;
groups.push({
columns: { count: cols.count, gapCm: cols.gapCm },
children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm, textBoxes),
});
cols = null;
};
for (const node of content) {
if (node.type === 'columns') {
const a = node.attrs ?? {};
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
const gapCm = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? Math.min(5, Math.max(0, a.gapCm)) : 0.5;
if (cols && cols.count === count && cols.gapCm === gapCm) {
cols.blocks.push(...(node.content ?? []));
} else {
flushCols();
flushPlain();
cols = { count, gapCm, blocks: [...(node.content ?? [])] };
}
} else {
flushCols();
plain.push(node);
}
}
flushCols();
flushPlain();
if (!groups.length) groups.push({ columns: null, children: [] });
return groups;
}
// ---- document styles -------------------------------------------------------
function headingStyle(o: { fontSize: string; marginTop: string; marginBottom: string }) {
return {
@@ -768,8 +818,7 @@ export async function buildDocx(
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const textBoxes: TextBoxDocx[] = [];
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm, textBoxes);
if (body.length === 0) body.push(new Paragraph({}));
const groups = bodyGroups(docJson.content ?? [], num, contentWidthCm, textBoxes);
// A TOC field is empty until its field is calculated; ask the reader to update fields
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
@@ -782,27 +831,40 @@ export async function buildDocx(
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
// Identical page geometry on every sectPr (Word requires it per section). Fresh
// Header/Footer instances per section so each sectPr — including the body-final
// one our importer reads — carries its own references (Word's "Link to Previous").
const pageProps = {
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
margin: {
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
},
};
const mkHeaders = () => headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined;
const mkFooters = () => footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined;
const doc = new Document({
creator: 'Web ODF Editor',
defaultTabStop: cmToTwip(1.25),
...(hasToc ? { features: { updateFields: true } } : {}),
styles: buildStyles(language),
numbering: { config: num.config },
sections: [{
// A columns group is its own section; w:type=continuous describes the break
// BEFORE a section, so it goes on every section but the first.
sections: groups.map((g, i) => ({
properties: {
page: {
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
margin: {
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
},
},
page: pageProps,
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
...(g.columns
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
: {}),
},
headers: headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined,
footers: footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined,
children: body,
}],
headers: mkHeaders(),
footers: mkFooters(),
children: g.children.length ? g.children : [new Paragraph({})],
})),
});
const blob = await Packer.toBlob(doc);
+106 -4
View File
@@ -83,6 +83,12 @@ const TOC_SENT = '';
// <draw:frame>/<draw:text-box> or <draw:custom-shape>. U+E008.
const TBX = '';
// Sentinel bracketing a hoisted multi-column section's blocks (marker paragraphs
// COL S{i} COL … COL E{i} COL): replaceColumns hoists like replaceTextBoxes;
// applyColumns wraps the region into a <text:section> with a minted section style
// carrying <style:columns>. U+E009.
const COL = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -313,6 +319,64 @@ function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
return { ...doc, content };
}
// A multi-column section's geometry, collected by replaceColumns.
type ColumnsExport = { count: number; gapCm: number };
function columnsDescriptor(node: TiptapNode): ColumnsExport {
const a = node.attrs ?? {};
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
const gapRaw = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? a.gapCm : 0.5;
const clamped = Math.min(5, Math.max(0, gapRaw));
return { count, gapCm: Math.round(clamped * 1000) / 1000 };
}
// Re-merge paragraphs split by columnsFlow at a page boundary (the second part
// carries the layout-internal joinPrev attr) — the file must hold ONE paragraph.
// Also used by the DOCX exporter.
export function mergeJoinedParagraphsJson(blocks: TiptapNode[]): TiptapNode[] {
const out: TiptapNode[] = [];
for (const b of blocks) {
const prev = out[out.length - 1];
if (b.type === 'paragraph' && b.attrs?.joinPrev && prev?.type === 'paragraph') {
out[out.length - 1] = { ...prev, content: [...(prev.content ?? []), ...(b.content ?? [])] };
} else {
out.push(b);
}
}
return out;
}
// Swap each top-level columns section for marker paragraphs bracketing its hoisted
// child blocks (same mechanism as replaceTextBoxes); applyColumns re-wraps the
// serialized region into a <text:section>. Adjacent equal-attr fragments are a
// columnsFlow.ts page-split chain — one section, so they coalesce here.
function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode {
if (!doc.content?.length) return doc;
const content: TiptapNode[] = [];
const children = doc.content;
for (let c = 0; c < children.length; c++) {
const child = children[c];
if (child.type === 'columns') {
const i = cols.length;
const desc = columnsDescriptor(child);
cols.push(desc);
const inner: TiptapNode[] = [...(child.content ?? [])];
while (c + 1 < children.length && children[c + 1].type === 'columns') {
const next = columnsDescriptor(children[c + 1]);
if (next.count !== desc.count || next.gapCm !== desc.gapCm) break;
inner.push(...(children[c + 1].content ?? []));
c++;
}
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}S${i}${COL}` }] });
content.push(...mergeJoinedParagraphsJson(inner));
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}E${i}${COL}` }] });
continue;
}
content.push(child);
}
return { ...doc, content };
}
// One generated table of contents, collected by replaceTableOfContents and emitted by
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
type TocEntry = { text: string; level: number; page: number };
@@ -1709,6 +1773,41 @@ function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Arra
return rezipOdt(files);
}
// Section style for a multi-column region: balanced columns with a uniform gap.
// text:dont-balance-text-columns sits on <style:columns> (not section-properties).
function columnsSectionStyle(cols: ColumnsExport, index: number): string {
return (
`<style:style style:name="ColSec${index + 1}" style:family="section">` +
`<style:section-properties style:editable="false">` +
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"` +
` text:dont-balance-text-columns="false"/>` +
`</style:section-properties></style:style>`
);
}
// Resolve the columns marker paragraphs: wrap each S{i}…E{i} region (the section's
// hoisted, fully serialized blocks) into a <text:section> — a block-level element,
// so no anchor paragraph — and inject the minted section styles.
function applyColumns(odtBytes: Uint8Array, cols: ColumnsExport[]): Uint8Array {
if (!cols.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
content = content.replace(
new RegExp(`<text:p\\b[^>]*>${COL}S(\\d+)${COL}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${COL}E\\1${COL}</text:p>`, 'g'),
(_m, idx: string, inner: string) => {
const i = Number(idx);
if (!cols[i]) return '';
return `<text:section text:style-name="ColSec${i + 1}" text:name="ColumnsSection${i + 1}">${inner}</text:section>`;
},
);
content = injectAutomaticStyles(content, cols.map((c, i) => columnsSectionStyle(c, i)).join(''));
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Minted automatic paragraph style for a TOC entry level: per-level left indent and a
// right tab stop at the text width with a dotted leader (so the page number right-aligns
// with dots, matching the on-screen TOC and Word/LibreOffice).
@@ -1814,12 +1913,14 @@ export type HfExport = {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
// Text boxes are hoisted after replacePageBreaks (so PGB never lands on their
// blocks) and before the inline passes (which then cover the hoisted blocks too).
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
// lands on their blocks) and before the inline passes (which then cover the hoisted
// blocks too).
const images: ImageExport[] = [];
const tocs: TocExport[] = [];
const textBoxes: TextBoxExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes))), images);
const columns: ColumnsExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -1942,7 +2043,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withBreaks = applyInlineSentinels(cleaned);
const withImages = applyImages(withBreaks, images);
const withTextBoxes = applyTextBoxes(withImages, textBoxes);
const withToc = applyToc(withTextBoxes, tocs, contentWidthCm);
const withColumns = applyColumns(withTextBoxes, columns);
const withToc = applyToc(withColumns, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null);
+6
View File
@@ -119,6 +119,12 @@ const de: Messages = {
imageNotInHf: 'Bilder sind in Kopf-/Fußzeilen nicht verfügbar',
insertTextBox: 'Textfeld einfügen',
textBoxNotInHf: 'Textfelder sind in Kopf-/Fußzeilen nicht verfügbar',
columns: 'Spalten',
columnsOne: 'Eine Spalte',
columnsTwo: 'Zwei Spalten',
columnsThree: 'Drei Spalten',
columnGap: 'Spaltenabstand (cm)',
columnsNotInHf: 'Spalten sind in Kopf-/Fußzeilen nicht verfügbar',
insertToc: 'Inhaltsverzeichnis einfügen',
tocNotInHf: 'Ein Inhaltsverzeichnis ist in Kopf-/Fußzeilen nicht verfügbar',
insertLink: 'Link einfügen',
+6
View File
@@ -122,6 +122,12 @@ const en = {
imageNotInHf: 'Images are not available in headers/footers',
insertTextBox: 'Insert text box',
textBoxNotInHf: 'Text boxes are not available in headers/footers',
columns: 'Columns',
columnsOne: 'One column',
columnsTwo: 'Two columns',
columnsThree: 'Three columns',
columnGap: 'Column gap (cm)',
columnsNotInHf: 'Columns are not available in headers/footers',
insertToc: 'Insert table of contents',
tocNotInHf: 'A table of contents is not available in headers/footers',
insertLink: 'Insert link',
+88 -2
View File
@@ -107,10 +107,30 @@ export function importDocx(bytes: Uint8Array): OdtImportResult {
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
// by the body-final sectPr, covering whole-document multi-column files).
const { groups, midSectPrs } = splitBodySections(Array.from(body.children));
const finalSectPr = fc(body, 'sectPr');
const blocks: Node[] = [];
for (const g of groups) {
const inner = convertBlocks(g.els, ctx, 'body');
const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx);
if (cols) pushColumnRuns(inner, cols, blocks, ctx);
else blocks.push(...inner);
}
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
const sect = parseSectPr(fc(body, 'sectPr'), ctx);
let sect = parseSectPr(finalSectPr, ctx);
// LibreOffice-produced multi-section files may reference headers/footers only on
// an earlier section; page geometry still comes from the body-final sectPr.
if (!sect.header && !sect.footer) {
const prev = [...midSectPrs].reverse().find((s) => fc(s, 'headerReference') || fc(s, 'footerReference'));
if (prev) {
const prevSect = parseSectPr(prev, ctx);
sect = { ...sect, header: prevSect.header, footer: prevSect.footer, headerDistCm: prevSect.headerDistCm, footerDistCm: prevSect.footerDistCm };
}
}
return {
content: { type: 'doc', content: blocks },
@@ -838,6 +858,72 @@ function flattenTable(tbl: Element, ctx: Ctx): Node[] {
}
// ---- section: page geometry + headers/footers ------------------------------
// Split body children into sections: a <w:p> whose pPr carries a sectPr ends the
// group before it (a continuous section break). The marker paragraph is dropped
// unless it has run content (ours and Word's are empty). The trailing group has
// sectPr null — the caller pairs it with the body-final <w:sectPr>.
function splitBodySections(children: Element[]): {
groups: { els: Element[]; sectPr: Element | null }[];
midSectPrs: Element[];
} {
const groups: { els: Element[]; sectPr: Element | null }[] = [];
const midSectPrs: Element[] = [];
let els: Element[] = [];
for (const el of children) {
if (el.namespaceURI === W && el.localName === 'sectPr') continue; // body-final
const sectPr = el.namespaceURI === W && el.localName === 'p' ? fc(fc(el, 'pPr'), 'sectPr') : null;
if (sectPr) {
if (fc(el, 'r') || fc(el, 'hyperlink')) els.push(el);
groups.push({ els, sectPr });
midSectPrs.push(sectPr);
els = [];
continue;
}
els.push(el);
}
groups.push({ els, sectPr: null });
return { groups, midSectPrs };
}
// A sectPr's w:cols → columns attrs when it declares more than one column.
function sectPrColumns(sectPr: Element | null, ctx: Ctx): { count: number; gapCm: number } | null {
const cols = fc(sectPr, 'cols');
if (!cols) return null;
const colEls = fcAll(cols, 'col');
const num = intAttr(cols, W, 'num') ?? (colEls.length > 1 ? colEls.length : null);
if (!num || num <= 1) return null;
let count = num;
if (count > 3) {
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
count = 3;
}
const space = intAttr(cols, W, 'space') ?? (colEls[0] ? intAttr(colEls[0], W, 'space') : null) ?? 283;
return { count, gapCm: Math.min(5, Math.max(0, round2(twipToCm(space)))) };
}
// Block types a columns node can contain (mirrors import/odt.ts).
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
// Wrap a multi-column section's converted blocks into columns nodes: maximal runs of
// allowed types become one node each; anything else is emitted between them.
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
let run: Node[] = [];
const flush = () => {
if (run.length) out.push({ type: 'columns', attrs: { count: cols.count, gapCm: cols.gapCm }, content: run });
run = [];
};
for (const block of inner) {
if (COLUMNS_ALLOWED.has(block.type)) {
run.push(block);
} else {
ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
flush();
out.push(block);
}
}
flush();
}
function parseSectPr(sect: Element | null, ctx: Ctx): {
margins: PageMargins | null; orientation: Orientation | null;
header: HfDoc; footer: HfDoc; headerDistCm: number | null; footerDistCm: number | null;
+33 -1
View File
@@ -369,6 +369,35 @@ function parseXml(xml: string): Document {
// ---- block conversion -----------------------------------------------------------
// Block types a columns section (columns.ts) can contain.
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
// Wrap a multi-column <text:section>'s converted blocks into columns nodes: maximal
// runs of allowed types become one node each; anything else (tables, text boxes,
// nested sections) is emitted between the runs at top level with a warning.
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
let count = cols.count;
if (count > 3) {
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
count = 3;
}
let run: Node[] = [];
const flush = () => {
if (run.length) out.push({ type: 'columns', attrs: { count, gapCm: cols.gapCm }, content: run });
run = [];
};
for (const block of inner) {
if (COLUMNS_ALLOWED.has(block.type)) {
run.push(block);
} else {
ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
flush();
out.push(block);
}
}
flush();
}
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
@@ -398,7 +427,10 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
if (list) out.push(list);
pushWithPending(null);
} else if (el.localName === 'section') {
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
const inner = convertBlocks(Array.from(el.children), ctx, kind, boldByDefault);
const cols = ctx.resolver.sectionColumns(el.getAttributeNS(NS.text, 'style-name'));
if (kind === 'body' && cols) pushColumnRuns(inner, cols, out, ctx);
else out.push(...inner);
} else if (el.localName === 'table-of-content' && kind === 'body') {
// Generated table of contents → a tableOfContents node (regenerated live).
out.push(convertToc(el));
+28
View File
@@ -101,6 +101,9 @@ export class StyleResolver {
private defaults = new Map<string, StyleEntry>(); // family → style:default-style
private fontFaces = new Map<string, string>(); // style:name → first font-family
private listStyles = new Map<string, Element>(); // style:name → text:list-style
// Section styles kept as raw elements: <style:columns> is a child element of
// <style:section-properties>, which the attribute-only StyleEntry drops.
private sectionStyleEls = new Map<string, Element>();
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
@@ -140,6 +143,7 @@ export class StyleResolver {
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.style, 'family');
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
if (name && family === 'section') this.sectionStyleEls.set(name, el);
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
const family = el.getAttributeNS(NS.style, 'family');
if (family) this.defaults.set(family, entryFromStyleElement(el));
@@ -267,6 +271,30 @@ export class StyleResolver {
return name ? this.listStyles.get(name) ?? null : null;
}
// A section style's column layout: { count, gapCm } when it declares more than one
// column, else null. The gap falls back to the per-column indent form (first
// column's fo:end-indent + second's fo:start-indent) LibreOffice sometimes writes.
sectionColumns(name: string | null): { count: number; gapCm: number } | null {
const el = name ? this.sectionStyleEls.get(name) : null;
if (!el) return null;
const props = el.getElementsByTagNameNS(NS.style, 'section-properties')[0];
const cols = props?.getElementsByTagNameNS(NS.style, 'columns')[0];
if (!cols) return null;
const count = parseInt(cols.getAttributeNS(NS.fo, 'column-count') ?? '', 10);
if (!Number.isFinite(count) || count <= 1) return null;
let gap = lengthToCm(cols.getAttributeNS(NS.fo, 'column-gap'));
if (gap == null) {
const colEls = Array.from(cols.getElementsByTagNameNS(NS.style, 'column'));
if (colEls.length >= 2) {
const end = lengthToCm(colEls[0].getAttributeNS(NS.fo, 'end-indent')) ?? 0;
const start = lengthToCm(colEls[1].getAttributeNS(NS.fo, 'start-indent')) ?? 0;
if (end + start > 0) gap = end + start;
}
}
const gapCm = Math.min(5, Math.max(0, gap ?? 0.5));
return { count, gapCm: Math.round(gapCm * 100) / 100 };
}
// The master page governing the document (prefer "Standard", else the first).
private masterPageEl(): Element | null {
const doc = this.stylesDoc;
+13
View File
@@ -343,6 +343,19 @@
margin-bottom: 0;
}
/* Multi-column section (columns.ts); column-count/gap come from the node's
inline style. Balanced fill matches ODF/Word section behavior; no visual
affordance — the section must read as plain flowing text. */
.paper .tiptap .columns-node {
column-fill: balance;
}
.paper .tiptap .columns-node > :first-child {
margin-top: 0;
}
.paper .tiptap .columns-node > :last-child {
margin-bottom: 0;
}
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
.paper .tiptap {
tab-size: 1.25cm;
+145
View File
@@ -54,6 +54,7 @@ describe('DOCX export → import round trip', () => {
para([text('bold in box', [{ type: 'bold' }])]),
] },
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
@@ -70,13 +71,82 @@ describe('DOCX export → import round trip', () => {
let doc: N;
let result: any;
let documentXml = '';
it('imports without throwing', async () => {
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
result = importDocx(bytes);
doc = result.content;
expect(doc.type).toBe('doc');
});
it('emits the columns region as its own continuous section with w:cols', () => {
// Three sections: before / columns / after (each earlier sectPr rides an empty
// paragraph's pPr; the last is the body-final one).
expect(documentXml.match(/<w:sectPr/g)!.length).toBe(3);
expect(documentXml).toMatch(/<w:cols[^>]*w:num="2"/);
expect(documentXml).toMatch(/<w:cols[^>]*w:space="454"/); // 0.8cm in twips
expect(documentXml.match(/<w:type w:val="continuous"\/>/g)!.length).toBe(2);
// Word wants the page geometry repeated per sectPr.
expect(documentXml.match(/<w:pgSz/g)!.length).toBe(3);
expect(documentXml.match(/<w:pgMar/g)!.length).toBe(3);
// The body-final sectPr (the one the importer reads) still references the header.
const finalSect = documentXml.slice(documentXml.lastIndexOf('<w:sectPr'));
expect(finalSect).toContain('<w:headerReference');
});
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
const fragmented = {
type: 'doc',
content: [
para('lead'),
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag one a'), para('frag one b')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag two')] },
{ type: 'columns', attrs: { count: 3, gapCm: 0.5 }, content: [para('other section')] },
para('tail'),
],
} as any;
const bytes = await buildDocx(fragmented);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml.match(/<w:sectPr/g)!.length).toBe(4); // lead / merged 2-col / 3-col / tail
const res = importDocx(bytes).content as N;
const cols = walk(res, 'columns');
expect(cols.map((c) => c.content!.length)).toEqual([3, 1]);
expect(cols[0].attrs.count).toBe(2);
expect(cols[1].attrs.count).toBe(3);
});
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
const split = {
type: 'doc',
content: [
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('first half ')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [
{ type: 'paragraph', attrs: { joinPrev: true }, content: [text('second half.')] },
para('tail'),
] },
],
} as any;
const bytes = await buildDocx(split);
const res = importDocx(bytes).content as N;
const cols = walk(res, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].content!.length).toBe(2);
expect(walk(cols[0].content![0], 'text').map((t) => t.text).join('')).toBe('first half second half.');
});
it('round-trips the columns section (count + gap + content, marker dropped)', () => {
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(2);
expect(cols[0].attrs.gapCm).toBe(0.8);
const texts = walk(cols[0], 'text').map((t) => t.text);
expect(texts).toEqual(['newspaper one', 'newspaper two']);
// The empty section-break marker paragraphs must not leak into the document.
const paras = walk(doc, 'paragraph');
expect(paras.filter((p) => !p.content && !p.attrs?.fontSize).length).toBe(0);
});
it('round-trips headings + paragraph block attrs', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
@@ -516,3 +586,78 @@ describe('DOCX table of contents (TOC field) round trip', () => {
expect(headings.map(h => walk(h, 'text')[0]?.text)).toEqual(['Alpha', 'Beta']);
});
});
describe('DOCX import of foreign multi-column sections', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
it('wraps a mid-body continuous section with w:cols and drops the empty marker paragraph', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>intro text</w:t></w:r></w:p>
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>col one</w:t></w:r></w:p>
<w:p><w:r><w:t>col two</w:t></w:r></w:p>
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/><w:cols w:num="2" w:space="708"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>outro text</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(2);
expect(cols[0].attrs.gapCm).toBe(1.25); // 708 twips
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['col one', 'col two']);
// A sectPr paragraph closes the group before it: intro (single-col) / columns /
// trailing outro on the body-final sectPr. Empty markers are dropped.
const top = doc.content!.map((n) => n.type);
expect(top).toEqual(['paragraph', 'columns', 'paragraph']);
expect(walk(doc.content![0], 'text')[0].text).toBe('intro text');
expect(walk(doc.content![2], 'text')[0].text).toBe('outro text');
});
it('keeps a text-bearing sectPr paragraph and clamps >3 columns', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>kept marker text</w:t></w:r><w:pPr><w:sectPr><w:type w:val="continuous"/><w:cols w:num="4" w:space="200"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>tail</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(3);
expect(walk(cols[0], 'text')[0].text).toBe('kept marker text');
expect(result.warnings).toContain('Sections with more than 3 columns were reduced to 3 columns');
});
it('wraps a whole-document multi-column file (cols on the body-final sectPr only)', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>all of it</w:t></w:r></w:p>
<w:p><w:r><w:t>flows in columns</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="3" w:space="425"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(3);
expect(cols[0].attrs.gapCm).toBe(0.75); // 425 twips
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['all of it', 'flows in columns']);
});
it('splits a columned section around a table with a warning', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>before table</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>in table</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>after table</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="2" w:space="283"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
expect(doc.content!.map((n) => n.type)).toEqual(['columns', 'table', 'columns']);
expect(result.warnings).toContain('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
});
});
+175
View File
@@ -36,6 +36,7 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
const COLS = (attrs: N, ...content: N[]): N => ({ type: 'columns', attrs, content });
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
@@ -76,6 +77,7 @@ const fixture: N = {
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))),
{ type: 'bulletList', content: [
LI(P(null, T('bullet one'))),
LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
@@ -751,6 +753,179 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
});
});
describe('Leg 8: multi-column sections (ODT)', () => {
const colsDoc: N = {
type: 'doc',
content: [
P(null, T('before')),
COLS({ count: 2, gapCm: 0.8 },
P(null, T('col text with '), T('bold', { type: 'bold' })),
{ type: 'bulletList', content: [
LI(P(null, T('col bullet one'))),
LI(P(null, T('col bullet two'))),
] },
H({ level: 3 }, T('Col heading')),
),
P(null, T('between')),
COLS({ count: 3, gapCm: 0.5 }, P(null, T('three column text'))),
P(null, T('after')),
],
};
it('emits <text:section> with a section style and round-trips attrs + content', async () => {
const bytes = await buildOdt(colsDoc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <text:section> with the minted style', /<text:section text:style-name="ColSec1"/.test(xml));
check('mints a section-family style', xml.includes('style:family="section"'));
check('2-col style carries count + gap', xml.includes('fo:column-count="2"') && xml.includes('fo:column-gap="0.8cm"'));
check('3-col style carries count + default gap', xml.includes('fo:column-count="3"') && xml.includes('fo:column-gap="0.5cm"'));
check('columns balance (dont-balance false)', xml.includes('text:dont-balance-text-columns="false"'));
check('section content is real block markup', /<text:section[^>]*>[\s\S]*?<text:list/.test(xml));
check('no leftover COL sentinel', !xml.includes(''));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
check('both sections round-trip', cols.length === 2, (res.content.content ?? []).map((n: N) => n.type));
const [two, three] = cols;
check('2-col attrs exact', two?.attrs?.count === 2 && two?.attrs?.gapCm === 0.8, two?.attrs);
check('3-col attrs exact', three?.attrs?.count === 3 && three?.attrs?.gapCm === 0.5, three?.attrs);
check('marks + list + heading survive inside the section',
two?.content?.[0]?.content?.some((n: N) => n.marks?.some((m: N) => m.type === 'bold')) &&
two?.content?.[1]?.type === 'bulletList' && two?.content?.[2]?.type === 'heading', two?.content);
check('document JSON round-trips', firstDiff(normalize(colsDoc), normalize(res.content)) === null,
firstDiff(normalize(colsDoc), normalize(res.content)));
});
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
const fragmented: N = {
type: 'doc',
content: [
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag one a')), P(null, T('frag one b'))),
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag two'))),
COLS({ count: 3, gapCm: 0.5 }, P(null, T('other section'))),
P(null, T('tail')),
],
};
const bytes = await buildOdt(fragmented, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('two sections emitted (chain merged, 3-col separate)',
(xml.match(/<text:section /g) ?? []).length === 2, (xml.match(/<text:section /g) ?? []).length);
const res = importOdt(bytes);
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
check('chain reimports as one node with all three blocks',
cols.length === 2 && cols[0]?.content?.length === 3 && cols[1]?.content?.length === 1,
cols.map((n: N) => n.content?.length));
});
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
const split: N = {
type: 'doc',
content: [
COLS({ count: 2, gapCm: 0.5 },
P(null, T('intact lead. ')),
P(null, T('first half of the long paragraph ')),
),
COLS({ count: 2, gapCm: 0.5 },
P({ joinPrev: true }, T('second half of it.')),
P(null, T('intact tail.')),
),
P(null, T('after')),
],
};
const bytes = await buildOdt(split, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('split paragraph exported as ONE text:p',
/first half of the long paragraph second half of it\./.test(xml.replace(/<[^>]+>/g, '')), null);
check('one section, three paragraphs inside',
(xml.match(/<text:section /g) ?? []).length === 1, null);
const res = importOdt(bytes);
const col = (res.content.content ?? []).find((n: N) => n.type === 'columns');
check('reimports as one section with three paragraphs', col?.content?.length === 3, col?.content?.length);
const texts = (col?.content ?? []).map((p: N) => (p.content ?? []).map((t: N) => t.text).join(''));
check('merged paragraph text intact',
texts[1] === 'first half of the long paragraph second half of it.', texts);
});
});
describe('Leg 9: foreign multi-column sections → importOdt', () => {
it('derives the gap from column indents, moves tables out, clamps counts, splices style-less sections', () => {
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">
<office:automatic-styles>
<style:style style:name="Sect1" style:family="section">
<style:section-properties style:editable="false">
<style:columns fo:column-count="2">
<style:column style:rel-width="4818*" fo:start-indent="0cm" fo:end-indent="0.25cm"/>
<style:column style:rel-width="4818*" fo:start-indent="0.25cm" fo:end-indent="0cm"/>
</style:columns>
</style:section-properties>
</style:style>
<style:style style:name="Sect2" style:family="section">
<style:section-properties>
<style:columns fo:column-count="2" fo:column-gap="1cm"/>
</style:section-properties>
</style:style>
<style:style style:name="Sect3" style:family="section">
<style:section-properties>
<style:columns fo:column-count="4" fo:column-gap="0.3cm"/>
</style:section-properties>
</style:style>
</office:automatic-styles>
<office:body><office:text>
<text:section text:style-name="Sect1" text:name="S1">
<text:p>lo col one</text:p>
<text:p>lo col two</text:p>
</text:section>
<text:section text:style-name="Sect2" text:name="S2">
<text:p>before table</text:p>
<table:table>
<table:table-column/>
<table:table-row><table:table-cell><text:p>in table</text:p></table:table-cell></table:table-row>
</table:table>
<text:p>after table</text:p>
</text:section>
<text:section text:style-name="Sect3" text:name="S3">
<text:p>four cols</text:p>
</text:section>
<text:section text:name="S4">
<text:p>plain section text</text:p>
</text:section>
</office:text></office:body>
</office:document-content>`;
const foreign = zipSync({
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
'content.xml': [strToU8(contentXml), { level: 6 }],
} as any);
const f = importOdt(foreign);
const c = f.content.content!;
const cols = c.filter((n: N) => n.type === 'columns');
check('4 columns nodes (S1, S2 split around the table, S3)', cols.length === 4, c.map((n: N) => n.type));
const s1 = cols[0];
check('S1: gap derived from column indents (0.25+0.25)', s1?.attrs?.count === 2 && s1?.attrs?.gapCm === 0.5, s1?.attrs);
check('S1: both paragraphs inside', s1?.content?.length === 2 && s1?.content?.[0]?.content?.[0]?.text === 'lo col one', s1?.content);
const s2idx = c.findIndex((n: N) => n.type === 'columns' && n.content?.[0]?.content?.[0]?.text === 'before table');
check('S2: table moved out between two columns nodes',
c[s2idx]?.attrs?.gapCm === 1 && c[s2idx + 1]?.type === 'table' &&
c[s2idx + 2]?.type === 'columns' && c[s2idx + 2]?.content?.[0]?.content?.[0]?.text === 'after table',
c.slice(s2idx, s2idx + 3).map((n: N) => n.type));
check('S2: move-out warning reported',
f.warnings.includes('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'), f.warnings);
const s3 = cols[cols.length - 1];
check('S3: count clamped to 3', s3?.attrs?.count === 3 && s3?.attrs?.gapCm === 0.3, s3?.attrs);
check('S3: clamp warning reported', f.warnings.includes('Sections with more than 3 columns were reduced to 3 columns'), f.warnings);
check('style-less section spliced to plain paragraphs',
c.some((n: N) => n.type === 'paragraph' && n.content?.[0]?.text === 'plain section text'), c.map((n: N) => n.type));
});
});
describe('Leg 5: table of contents (text:table-of-content)', () => {
const tocDoc: N = {
type: 'doc',